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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/components/config/VisualConfigEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,7 @@ export function VisualConfigEditor({
handledJumpRef.current = jumpRequest; // handle each request once, even if deps re-fire
const { fieldId, sectionId } = jumpRequest;
const targetFieldId =
(fieldId === 'tlsCert' || fieldId === 'tlsKey') && !values.tlsEnable
? 'tlsEnable'
: fieldId;
(fieldId === 'tlsCert' || fieldId === 'tlsKey') && !values.tlsEnable ? 'tlsEnable' : fieldId;

const el = document.getElementById(configFieldDomId(targetFieldId));
if (!el) {
Expand Down Expand Up @@ -1138,6 +1136,12 @@ export function VisualConfigEditor({
'config_management.visual.sections.network.strategy_fill_first'
),
},
{
value: 'weighted-round-robin',
label: t(
'config_management.visual.sections.network.strategy_weighted_round_robin'
),
},
]}
id={`${routingStrategyLabelId}-select`}
disabled={disabled}
Expand Down
2 changes: 1 addition & 1 deletion src/components/config/configSearchIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,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', 'fill-first', 'weighted-round-robin'],
},
{
fieldId: 'disableImageGeneration',
Expand Down
40 changes: 33 additions & 7 deletions src/components/quota/QuotaCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,9 @@ export interface QuotaProgressBarProps {
export function QuotaProgressBar({
percent,
highThreshold,
mediumThreshold
mediumThreshold,
}: QuotaProgressBarProps) {
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const normalized = percent === null ? null : clamp(percent, 0, 100);
const fillClass =
normalized === null
Expand All @@ -58,6 +57,17 @@ export interface QuotaRenderHelpers {
QuotaProgressBar: (props: QuotaProgressBarProps) => ReactElement;
}

const parseIntegerField = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isInteger(value)) return value;
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return fallback;
const parsed = Number(trimmed);
if (Number.isInteger(parsed)) return parsed;
}
return fallback;
};

interface QuotaCardProps<TState extends QuotaStatusState> {
item: AuthFileItem;
quota?: TState;
Expand All @@ -83,14 +93,20 @@ export function QuotaCard<TState extends QuotaStatusState>({
canRefresh = false,
onRefresh,
resetQuotaAction,
renderQuotaItems
renderQuotaItems,
}: QuotaCardProps<TState>) {
const { t } = useTranslation();

const displayType = item.type || item.provider || defaultType;
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
const typeColor: ThemeColors =
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
const priority = parseIntegerField(item.priority, 0);
const parsedSelectionWeight = parseIntegerField(
item.selection_weight ?? item['selection-weight'],
1
);
const selectionWeight = parsedSelectionWeight >= 0 ? parsedSelectionWeight : 1;

const quotaStatus = quota?.status ?? 'idle';
const quotaLoading = quotaStatus === 'loading';
Expand All @@ -99,7 +115,9 @@ export function QuotaCard<TState extends QuotaStatusState>({
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
const idleMessageKey = onRefresh ? `${i18nPrefix}.idle` : (cardIdleMessageKey ?? `${i18nPrefix}.idle`);
const idleMessageKey = onRefresh
? `${i18nPrefix}.idle`
: (cardIdleMessageKey ?? `${i18nPrefix}.idle`);

const getTypeLabel = (type: string): string => {
const key = `auth_files.filter_${type}`;
Expand All @@ -117,13 +135,21 @@ export function QuotaCard<TState extends QuotaStatusState>({
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
...(typeColor.border ? { border: typeColor.border } : {}),
}}
>
{getTypeLabel(displayType)}
</span>
<span className={styles.fileName}>{item.name}</span>
</div>
<div className={styles.scheduleMeta}>
<span>
{t('auth_files.priority_display')}: {priority}
</span>
<span>
{t('auth_files.selection_weight_display')}: {selectionWeight}
</span>
</div>
Comment on lines +145 to +152

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The CSS class scheduleMeta is defined in src/pages/QuotaPage.module.scss, but it is being accessed here via styles.scheduleMeta. Since QuotaCard.tsx is a reusable component located in src/components/quota/, it likely imports its own scoped stylesheet (e.g., QuotaCard.module.scss). Accessing styles.scheduleMeta will result in undefined at runtime, and the styles won't be applied due to CSS Modules scoping.

Please verify where QuotaCard.tsx imports styles from, and move the .scheduleMeta class definition from QuotaPage.module.scss to the appropriate stylesheet imported by QuotaCard.tsx (such as QuotaCard.module.scss).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Verified this path: QuotaCard.tsx imports styles from @/pages/QuotaPage.module.scss, and .scheduleMeta is declared in that same module. The class is therefore present in the imported CSS module at runtime; no code move is needed for this comment.


<div className={styles.quotaSection}>
{quotaLoading ? (
Expand All @@ -144,7 +170,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${i18nPrefix}.load_failed`, {
message: quotaErrorMessage
message: quotaErrorMessage,
})}
</div>
) : quota ? (
Expand Down
10 changes: 10 additions & 0 deletions src/features/authFiles/components/AuthFileCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ export function AuthFileCard(props: AuthFileCardProps) {
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(rawStatusMessage.toLowerCase());

const priorityValue = parsePriorityValue(file.priority ?? file['priority']);
const selectionWeightRaw = file.selection_weight ?? file['selection-weight'];
const selectionWeightValue = parsePriorityValue(selectionWeightRaw);
const noteValue = typeof file.note === 'string' ? file.note.trim() : '';
const stateLabel = isRuntimeOnly
? t('auth_files.type_virtual') || '虚拟认证文件'
Expand Down Expand Up @@ -221,6 +223,14 @@ export function AuthFileCard(props: AuthFileCardProps) {
</span>
</div>
)}
{selectionWeightValue !== undefined && selectionWeightValue >= 0 && (
<div className={`${styles.metaItem} ${styles.priorityBadge}`}>
<span className={styles.metaLabel}>{t('auth_files.selection_weight_display')}</span>
<span className={`${styles.metaValue} ${styles.priorityValue}`}>
{selectionWeightValue}
</span>
</div>
)}
</div>

{rawStatusMessage && hasStatusWarning && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('priority', e.target.value)}
/>
<Input
label={t('auth_files.selection_weight_label')}
value={editor.selectionWeight}
placeholder={t('auth_files.selection_weight_placeholder')}
hint={t('auth_files.selection_weight_hint')}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('selectionWeight', e.target.value)}
/>
{editor.providerKey === 'codex' && (
<div className="form-group">
<label>{t('auth_files.codex_websockets_label')}</label>
Expand Down
37 changes: 37 additions & 0 deletions src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type PrefixProxyEditorField =
| 'prefix'
| 'proxyUrl'
| 'priority'
| 'selectionWeight'
| 'websockets'
| 'note'
| 'headersText';
Expand All @@ -43,6 +44,7 @@ export type PrefixProxyEditorState = {
prefix: string;
proxyUrl: string;
priority: string;
selectionWeight: string;
websockets: boolean;
websocketsTouched: boolean;
note: string;
Expand Down Expand Up @@ -108,6 +110,11 @@ const parseHeadersText = (
const normalizeTextField = (value: unknown): string =>
typeof value === 'string' ? value.trim() : '';

const parseSelectionWeightValue = (value: unknown): number | undefined => {
const parsed = parsePriorityValue(value);
return parsed !== undefined && parsed >= 0 ? parsed : undefined;
};

const INVALID_CONTENT_PREVIEW_LIMIT = 1000;

const buildInvalidContentPreview = (text: string): string => {
Expand Down Expand Up @@ -248,6 +255,21 @@ const buildAuthFileFieldsPatch = (
}
}

const originalSelectionWeight = parseSelectionWeightValue(
original.selection_weight ?? original['selection-weight']
);
const selectionWeightText = editor.selectionWeight.trim();
const nextSelectionWeight = parseSelectionWeightValue(selectionWeightText);
if (!selectionWeightText) {
if (originalSelectionWeight !== undefined) {
patch.selection_weight = null;
}
} else if (nextSelectionWeight === undefined) {
patch.selection_weight = -1;
Comment on lines +267 to +268

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject invalid selection weights instead of saving -1

When the auth-file editor's new selection weight field contains any non-empty invalid value (for example abc or 1.5), parseSelectionWeightValue returns undefined and this branch sends selection_weight: -1 in both the preview and PATCH payload. Selection weights are otherwise constrained to non-negative integers (and the hint says empty means default 1), so a typo can attempt to persist an invalid negative routing weight instead of being ignored or blocked like the other validation errors.

Useful? React with 👍 / 👎.

} else if (nextSelectionWeight !== originalSelectionWeight) {
patch.selection_weight = nextSelectionWeight;
}

if (editor.noteTouched) {
const originalNote = normalizeTextField(original.note);
const nextNote = editor.note.trim();
Expand Down Expand Up @@ -311,6 +333,15 @@ const buildPrefixProxyUpdatedText = (
}
}

if (patch.selection_weight !== undefined) {
delete next['selection-weight'];
if (patch.selection_weight === null) {
delete next.selection_weight;
} else {
next.selection_weight = patch.selection_weight;
}
}

if (patch.note !== undefined) {
if (patch.note) {
next.note = patch.note;
Expand Down Expand Up @@ -380,6 +411,7 @@ export function useAuthFilesPrefixProxyEditor(
prefix: '',
proxyUrl: '',
priority: '',
selectionWeight: '',
websockets: false,
websocketsTouched: false,
note: '',
Expand Down Expand Up @@ -426,6 +458,9 @@ 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 selectionWeight = parseSelectionWeightValue(
json.selection_weight ?? json['selection-weight']
);
const websockets = providerKey === 'codex' ? readCodexAuthFileWebsockets(json) : false;
const note = typeof json.note === 'string' ? json.note : '';
const headers = json.headers;
Expand All @@ -450,6 +485,7 @@ export function useAuthFilesPrefixProxyEditor(
prefix,
proxyUrl,
priority: priority !== undefined ? String(priority) : '',
selectionWeight: selectionWeight !== undefined ? String(selectionWeight) : '',
websockets,
websocketsTouched: false,
note,
Expand Down Expand Up @@ -479,6 +515,7 @@ 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 === 'selectionWeight') return { ...prev, selectionWeight: String(value) };
if (field === 'websockets') {
return { ...prev, websockets: Boolean(value), websocketsTouched: true };
}
Expand Down
21 changes: 8 additions & 13 deletions src/features/providers/adapters.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import {
hasDisableAllModelsRule,
stripDisableAllModelsRule,
} from '@/components/providers/utils';
import { hasDisableAllModelsRule, stripDisableAllModelsRule } from '@/components/providers/utils';
import { maskApiKey } from '@/utils/format';
import type {
ProviderBrand,
ProviderResource,
ProviderResourceSelector,
} from './types';
import type { ProviderBrand, ProviderResource, ProviderResourceSelector } from './types';

const countHeaders = (headers?: Record<string, string>): number =>
headers ? Object.keys(headers).length : 0;
Expand All @@ -25,6 +18,9 @@ const collectModelNames = (models?: Array<{ name?: string }>): string[] => {
const normalizePriority = (priority?: number): number =>
typeof priority === 'number' && Number.isFinite(priority) ? priority : 0;

const normalizeSelectionWeight = (weight?: number): number | undefined =>
typeof weight === 'number' && Number.isInteger(weight) && weight >= 0 ? weight : undefined;

const buildId = (brand: ProviderBrand, index: number, fragment: string) =>
`${brand}:${index}:${fragment || 'item'}`;

Expand Down Expand Up @@ -73,6 +69,7 @@ function providerKeyToResource(
modelCount: config.models?.length ?? 0,
models: collectModelNames(config.models),
priority: normalizePriority(config.priority),
selectionWeight: normalizeSelectionWeight(config.selectionWeight),
headerCount: countHeaders(config.headers),
excludedModelCount: stripDisableAllModelsRule(config.excludedModels).length,
apiKeyEntryCount: 0,
Expand All @@ -99,10 +96,7 @@ export function vertexToResource(config: ProviderKeyConfig, index: number): Prov
return providerKeyToResource('vertex', config, index);
}

export function openaiToResource(
config: OpenAIProviderConfig,
index: number
): ProviderResource {
export function openaiToResource(config: OpenAIProviderConfig, index: number): ProviderResource {
const name = (config.name ?? '').trim();
const firstEntry = config.apiKeyEntries?.[0];
const previewApiKey = firstEntry?.apiKey ? maskApiKey(firstEntry.apiKey) : null;
Expand All @@ -121,6 +115,7 @@ export function openaiToResource(
modelCount: config.models?.length ?? 0,
models: collectModelNames(config.models),
priority: normalizePriority(config.priority),
selectionWeight: normalizeSelectionWeight(config.selectionWeight),
headerCount: countHeaders(config.headers),
excludedModelCount: 0,
apiKeyEntryCount: config.apiKeyEntries?.length ?? 0,
Expand Down
Loading