feat(auth-files): implement excluded models selection with UI support and backend integration

This commit is contained in:
Supra4E8C
2026-07-31 04:53:01 +08:00
parent afd7da059d
commit 20bb8559d4
17 changed files with 636 additions and 46 deletions
@@ -66,6 +66,69 @@
border-color: var(--danger-color) !important;
}
.excludedModelChips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.excludedModelChip {
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 100%;
gap: 4px;
padding: 4px 5px 4px 9px;
border: 1px solid color-mix(in srgb, var(--primary-color) 45%, var(--border-color));
border-radius: 999px;
background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-primary));
color: var(--text-primary);
font-family: $font-mono;
font-size: 11px;
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
&:hover:not(:disabled) {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 1px;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
}
.excludedRulesLabel {
margin-top: 2px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
}
.invalidPreview {
margin: 0;
max-height: 240px;
@@ -16,6 +16,7 @@ import {
supportsAuthFileWebsockets,
} from '@/features/authFiles/constants';
import { MAX_CREDENTIAL_WEIGHT } from '@/utils/credentialWeight';
import { AuthFileExcludedModelsField } from './AuthFileExcludedModelsField';
import styles from './AuthFileDetailsSheet.module.scss';
/** API 边界归一化补写的派生字段——INFO 视图里只展示后端原始形状,避免重复噪音。 */
@@ -229,18 +230,12 @@ export function AuthFileDetailsSheet(props: AuthFileDetailsSheetProps) {
<div className="hint">{t('auth_files.using_api_hint')}</div>
</div>
)}
<div className="form-group">
<label>{t('auth_files.excluded_models_label')}</label>
<textarea
className="input"
value={editor.excludedModelsText}
placeholder={t('auth_files.excluded_models_placeholder')}
rows={4}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('excludedModelsText', e.target.value)}
/>
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
</div>
<AuthFileExcludedModelsField
fileName={editor.fileName}
value={editor.excludedModelsText}
disabled={disableControls || editor.saving || !editor.json}
onChange={(value) => onChange('excludedModelsText', value)}
/>
<div className="form-group">
<label>{t('auth_files.headers_label')}</label>
<textarea
@@ -0,0 +1,160 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Select, type SelectOption } from '@/components/ui/Select';
import { IconX } from '@/components/ui/icons';
import { authFilesApi } from '@/services/api';
import type { AuthFileModelItem } from '@/features/authFiles/constants';
import {
isModelExcludedByWildcard,
parseExcludedModelRules,
replaceCustomExcludedModelRules,
splitExcludedModelRules,
toggleExcludedModel,
} from '@/features/authFiles/excludedModelSelection';
import styles from './AuthFileDetailsSheet.module.scss';
interface AuthFileExcludedModelsFieldProps {
fileName: string;
value: string;
disabled: boolean;
onChange: (value: string) => void;
}
const modelOptionLabel = (model: AuthFileModelItem): string => {
const displayName = model.display_name?.trim();
return displayName && displayName !== model.id ? `${model.id}${displayName}` : model.id;
};
export function AuthFileExcludedModelsField({
fileName,
value,
disabled,
onChange,
}: AuthFileExcludedModelsFieldProps) {
const { t } = useTranslation();
const latestValueRef = useRef(value);
const [models, setModels] = useState<AuthFileModelItem[]>([]);
const [loading, setLoading] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => {
latestValueRef.current = value;
}, [value]);
useEffect(() => {
let cancelled = false;
setModels([]);
setLoading(true);
setLoadFailed(false);
void authFilesApi
.getModelsForAuthFile(fileName)
.then((items) => {
if (cancelled) return;
const byId = new Map<string, AuthFileModelItem>();
items.forEach((item) => {
const id = item.id?.trim();
if (id) byId.set(id.toLowerCase(), { ...item, id });
});
parseExcludedModelRules(latestValueRef.current).forEach((rule) => {
if (!rule.includes('*') && !byId.has(rule.toLowerCase())) {
byId.set(rule.toLowerCase(), { id: rule });
}
});
setModels(
[...byId.values()].sort((left, right) =>
left.id.localeCompare(right.id, undefined, { sensitivity: 'base' })
)
);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [fileName]);
const rules = useMemo(() => parseExcludedModelRules(value), [value]);
const candidateIds = useMemo(() => models.map((model) => model.id), [models]);
const { selectedIds, customRules } = useMemo(
() => splitExcludedModelRules(rules, candidateIds),
[candidateIds, rules]
);
const selectedKeys = useMemo(
() => new Set(selectedIds.map((id) => id.toLowerCase())),
[selectedIds]
);
const availableOptions = useMemo<SelectOption[]>(
() =>
models
.filter(
(model) =>
!selectedKeys.has(model.id.toLowerCase()) &&
!isModelExcludedByWildcard(customRules, model.id)
)
.map((model) => ({ value: model.id, label: modelOptionLabel(model) })),
[customRules, models, selectedKeys]
);
const commitRules = (nextRules: string[]) => onChange(nextRules.join('\n'));
return (
<div className="form-group">
<label>{t('auth_files.excluded_models_label')}</label>
<Select
value=""
options={availableOptions}
onChange={(modelId) => commitRules(toggleExcludedModel(rules, modelId, true))}
placeholder={
loading
? t('auth_files.excluded_models_loading')
: t('auth_files.excluded_models_select', { count: selectedIds.length })
}
ariaLabel={t('auth_files.excluded_models_select_label')}
disabled={disabled || loading || availableOptions.length === 0}
/>
{selectedIds.length > 0 ? (
<div className={styles.excludedModelChips}>
{selectedIds.map((modelId) => (
<span key={modelId.toLowerCase()} className={styles.excludedModelChip}>
<span>{modelId}</span>
<button
type="button"
onClick={() => commitRules(toggleExcludedModel(rules, modelId, false))}
disabled={disabled}
aria-label={t('auth_files.excluded_models_remove', { model: modelId })}
>
<IconX size={12} />
</button>
</span>
))}
</div>
) : null}
<label className={styles.excludedRulesLabel}>
{t('auth_files.excluded_models_custom_label')}
</label>
<textarea
className="input"
value={customRules.join('\n')}
placeholder={t('auth_files.excluded_models_custom_placeholder')}
rows={3}
disabled={disabled}
onChange={(event) =>
commitRules(replaceCustomExcludedModelRules(rules, candidateIds, event.target.value))
}
/>
<div className="hint">
{loadFailed
? t('auth_files.excluded_models_load_failed')
: t('auth_files.excluded_models_hint')}
</div>
</div>
);
}
@@ -0,0 +1,67 @@
export const parseExcludedModelRules = (text: string): string[] => {
const seen = new Set<string>();
const rules: string[] = [];
text.split(/\r?\n/).forEach((raw) => {
const rule = raw.trim();
const key = rule.toLowerCase();
if (!rule || seen.has(key)) return;
seen.add(key);
rules.push(rule);
});
return rules;
};
export const matchesExcludedModelRule = (rule: string, modelId: string): boolean => {
const normalizedRule = rule.trim().toLowerCase();
const normalizedModel = modelId.trim().toLowerCase();
if (!normalizedRule || !normalizedModel) return false;
if (!normalizedRule.includes('*')) return normalizedRule === normalizedModel;
const escaped = normalizedRule
.split('*')
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('.*');
return new RegExp(`^${escaped}$`, 'i').test(normalizedModel);
};
export const isModelExcludedByWildcard = (rules: readonly string[], modelId: string): boolean =>
rules.some((rule) => rule.includes('*') && matchesExcludedModelRule(rule, modelId));
export const splitExcludedModelRules = (
rules: readonly string[],
candidateIds: readonly string[]
): { selectedIds: string[]; customRules: string[] } => {
const candidateByKey = new Map(candidateIds.map((id) => [id.trim().toLowerCase(), id]));
const selectedIds: string[] = [];
const customRules: string[] = [];
rules.forEach((rule) => {
const candidate = !rule.includes('*') ? candidateByKey.get(rule.toLowerCase()) : undefined;
if (candidate) selectedIds.push(candidate);
else customRules.push(rule);
});
return { selectedIds, customRules };
};
export const toggleExcludedModel = (
rules: readonly string[],
modelId: string,
excluded: boolean
): string[] => {
const key = modelId.trim().toLowerCase();
const next = rules.filter((rule) => rule.includes('*') || rule.toLowerCase() !== key);
if (excluded && key) next.push(modelId.trim());
return parseExcludedModelRules(next.join('\n'));
};
export const replaceCustomExcludedModelRules = (
rules: readonly string[],
candidateIds: readonly string[],
customText: string
): string[] => {
const { selectedIds } = splitExcludedModelRules(rules, candidateIds);
return parseExcludedModelRules(
[...selectedIds, ...parseExcludedModelRules(customText)].join('\n')
);
};
@@ -14,6 +14,7 @@ import { hasDisableAllModelsRule } from '@/components/providers/utils';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
import { PROVIDER_DESCRIPTORS } from '../../descriptors';
import { readThinkingLevels } from '../../thinkingLevels';
import type {
ApiKeyEntryInput,
ModelEntryInput,
@@ -119,6 +120,7 @@ function buildInitialForm(
testModel: m.testModel,
image: m.image === true,
thinkingJson: formatJsonObject(m.thinking),
thinkingLevels: readThinkingLevels(m.thinking),
}))
: [emptyModel()],
headers: cfg.headers
@@ -162,6 +164,7 @@ function buildInitialForm(
priority: m.priority,
testModel: m.testModel,
thinkingJson: formatJsonObject(m.thinking),
thinkingLevels: readThinkingLevels(m.thinking),
}))
: [emptyModel()],
headers: cfg.headers
@@ -1,6 +1,8 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IconChevronDown, IconPlus, IconX } from '@/components/ui/icons';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { THINKING_LEVELS, type ThinkingLevel } from '../../thinkingLevels';
import type { ModelEntryInput } from '../../types';
import styles from './sharedForm.module.scss';
@@ -56,7 +58,16 @@ export function ModelEntriesEditor({
{visible.map((entry, idx) => {
const hasExtendedOptions = supportsImage || supportsThinking;
const expanded = hasExtendedOptions && expandedIdx === idx;
const hasThinking = (entry.thinkingJson ?? '').trim().length > 0;
const thinkingLevels = entry.thinkingLevels ?? [];
const hasThinking = entry.thinkingLevelsTouched
? thinkingLevels.length > 0
: (entry.thinkingJson ?? '').trim().length > 0;
const toggleThinkingLevel = (level: ThinkingLevel) => {
const nextLevels = thinkingLevels.includes(level)
? thinkingLevels.filter((item) => item !== level)
: THINKING_LEVELS.filter((item) => item === level || thinkingLevels.includes(item));
onUpdate(idx, { thinkingLevels: nextLevels, thinkingLevelsTouched: true });
};
return (
<div key={idx} className={styles.modelEntry}>
<div className={styles.modelAliasRow}>
@@ -133,23 +144,36 @@ export function ModelEntriesEditor({
</label>
) : null}
{supportsThinking ? (
<div className={styles.field}>
<label className={styles.label}>
<fieldset className={styles.thinkingFieldset}>
<legend className={styles.label}>
{t('providersPage.form.thinkingConfig')}
<span className={styles.labelHint}>
{' '}
· {t('providersPage.form.thinkingConfigHint')}
</span>
</label>
<textarea
className={styles.textarea}
rows={4}
value={entry.thinkingJson ?? ''}
onChange={(e) => onUpdate(idx, { thinkingJson: e.target.value })}
disabled={mutating}
placeholder={'{"levels":["low","medium","high"]}'}
/>
</div>
</legend>
<div className={styles.thinkingLevelGrid}>
{THINKING_LEVELS.map((level) => (
<SelectionCheckbox
key={level}
checked={thinkingLevels.includes(level)}
disabled={mutating}
onChange={() => toggleThinkingLevel(level)}
className={`${styles.thinkingLevelOption} ${
thinkingLevels.includes(level) ? styles.thinkingLevelOptionSelected : ''
}`}
labelClassName={styles.thinkingLevelLabel}
label={
<>
<span>{t(`providersPage.form.thinkingLevels.${level}`)}</span>
<code>{level}</code>
</>
}
/>
))}
</div>
{(entry.thinkingJson ?? '').trim() && !entry.thinkingLevelsTouched ? (
<p className={styles.thinkingExistingHint}>
{t('providersPage.form.thinkingExistingHint')}
</p>
) : null}
</fieldset>
) : null}
</div>
) : null}
@@ -19,6 +19,7 @@ import { maskApiKey } from '@/utils/format';
import { MAX_CREDENTIAL_WEIGHT } from '@/utils/credentialWeight';
import type { ModelInfo } from '@/utils/models';
import type { ApiKeyFunUsageSummary } from '../../sponsor';
import { readThinkingLevels } from '../../thinkingLevels';
import { isSponsorPartialMutationError } from '../../sponsorMutationRecovery';
import {
discoveryBrandForSponsorProtocol,
@@ -161,6 +162,7 @@ const modelsFromConfig = (
testModel: model.testModel,
image: model.image === true,
thinkingJson: model.thinking ? JSON.stringify(model.thinking, null, 2) : '',
thinkingLevels: readThinkingLevels(model.thinking),
}))
: [emptyModel()];
@@ -973,6 +973,69 @@
background: var(--bg-secondary);
}
.thinkingFieldset {
display: grid;
gap: 8px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.thinkingExistingHint {
margin: 0;
color: var(--warning-color);
font-size: 11px;
line-height: 1.5;
}
.thinkingLevelGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
@media (max-width: 520px) {
grid-template-columns: 1fr;
}
}
.thinkingLevelOption {
width: 100%;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-primary);
box-sizing: border-box;
}
.thinkingLevelLabel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
flex: 1;
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
code {
color: var(--muted-foreground);
font-size: 10px;
font-weight: 400;
}
}
.thinkingLevelOptionSelected {
border-color: color-mix(in srgb, var(--primary-color) 50%, var(--border-color));
background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-primary));
.thinkingLevelLabel {
color: var(--text-primary);
}
}
.entrySummary {
display: flex;
min-width: 0;
+54
View File
@@ -0,0 +1,54 @@
export const THINKING_LEVELS = [
'none',
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
'auto',
] as const;
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
const THINKING_LEVEL_SET = new Set<string>(THINKING_LEVELS);
const SERIALIZED_LEVEL_ORDER: readonly ThinkingLevel[] = [
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
'none',
'auto',
];
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
export const readThinkingLevels = (value: unknown): ThinkingLevel[] => {
if (!isRecord(value)) return [];
const selected = new Set<ThinkingLevel>();
if (Array.isArray(value.levels)) {
value.levels.forEach((rawLevel) => {
if (typeof rawLevel !== 'string') return;
const level = rawLevel.trim().toLowerCase();
if (THINKING_LEVEL_SET.has(level)) selected.add(level as ThinkingLevel);
});
}
if (value.zero_allowed === true) selected.add('none');
if (value.dynamic_allowed === true) selected.add('auto');
return THINKING_LEVELS.filter((level) => selected.has(level));
};
export const buildThinkingFromLevels = (
levels: readonly ThinkingLevel[] | undefined
): Record<string, unknown> | undefined => {
if (!levels?.length) return undefined;
const selected = new Set(levels);
return {
levels: SERIALIZED_LEVEL_ORDER.filter((level) => selected.has(level)),
};
};
+4
View File
@@ -3,6 +3,7 @@
*/
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import type { ThinkingLevel } from './thinkingLevels';
export type ProviderBrand =
| 'gemini'
@@ -142,7 +143,10 @@ export interface ModelEntryInput {
priority?: number;
testModel?: string;
image?: boolean;
/** Original backend value, preserved until the standard-level selector is changed. */
thinkingJson?: string;
thinkingLevels?: ThinkingLevel[];
thinkingLevelsTouched?: boolean;
}
export type SponsorProtocol = 'openai' | 'codex' | 'claude' | 'gemini';
@@ -24,6 +24,7 @@ import {
xaiToResource,
} from './adapters';
import { PROVIDER_BRAND_ORDER } from './descriptors';
import { buildThinkingFromLevels } from './thinkingLevels';
import type {
ProviderBrand,
ProviderEntryFormInput,
@@ -136,7 +137,9 @@ const buildModelAliases = (
alias: m.alias?.trim() || undefined,
priority: m.priority,
testModel: m.testModel,
thinking: parseThinkingJson(m.thinkingJson),
thinking: m.thinkingLevelsTouched
? buildThinkingFromLevels(m.thinkingLevels)
: parseThinkingJson(m.thinkingJson),
};
if (includeImage) {
entry.image = m.image === true;
+20 -4
View File
@@ -354,8 +354,14 @@
"note_placeholder": "Enter a note, e.g.: John's account",
"note_hint": "Optional. Used to describe the purpose or owner of this credential; leave empty to omit.",
"excluded_models_label": "Excluded models (excluded_models)",
"excluded_models_placeholder": "model-1\nmodel-2\nmodel-prefix-*",
"excluded_models_hint": "One model pattern per line. Wildcards (*) are supported; matching models won't be routed through this credential.",
"excluded_models_loading": "Loading credential models…",
"excluded_models_select": "Select models to exclude ({{count}} selected)",
"excluded_models_select_label": "Select credential models to exclude",
"excluded_models_remove": "Stop excluding model {{model}}",
"excluded_models_custom_label": "Custom rules",
"excluded_models_custom_placeholder": "One rule per line, e.g. model-prefix-*",
"excluded_models_hint": "Select credential models directly; custom rules support wildcards (*).",
"excluded_models_load_failed": "Credential models could not be loaded; custom rules are still available.",
"headers_label": "Custom Headers (headers)",
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
"headers_hint": "Enter custom HTTP headers as a JSON object, e.g., {\"X-My-Header\": \"value\"}",
@@ -1564,8 +1570,18 @@
"proxyBadge": "Proxy",
"modelBadgeImage": "Image",
"modelBadgeThinking": "Thinking",
"thinkingConfig": "Thinking config (JSON)",
"thinkingConfigHint": "Supports levels, min, max, zero_allowed, dynamic_allowed",
"thinkingConfig": "Allowed thinking levels",
"thinkingExistingHint": "The current backend config remains unchanged until you change a selection; then it will be replaced by the selected standard levels.",
"thinkingLevels": {
"none": "Disable thinking",
"minimal": "Minimal",
"low": "Low",
"medium": "Medium",
"high": "High",
"xhigh": "Extra high",
"max": "Maximum",
"auto": "Automatic"
},
"cloakCacheUserId": "Cache user_id",
"cloakCacheUserIdHint": "Reuse the Claude cloak user_id per API key",
"experimentalCchSigning": "Experimental CCH signing",
+20 -4
View File
@@ -353,8 +353,14 @@
"note_placeholder": "Введите заметку, например: аккаунт Ивана",
"note_hint": "Необязательно. Используется для описания назначения или владельца учётных данных; оставьте пустым, чтобы не записывать.",
"excluded_models_label": "Исключённые модели (excluded_models)",
"excluded_models_placeholder": "model-1\nmodel-2\nmodel-prefix-*",
"excluded_models_hint": "Один шаблон модели на строку. Поддерживаются подстановочные знаки (*); совпавшие модели не будут направляться через эти учётные данные.",
"excluded_models_loading": "Загрузка моделей учётных данных…",
"excluded_models_select": "Выберите исключаемые модели (выбрано: {{count}})",
"excluded_models_select_label": "Выберите модели учётных данных для исключения",
"excluded_models_remove": "Отменить исключение модели {{model}}",
"excluded_models_custom_label": "Пользовательские правила",
"excluded_models_custom_placeholder": "Одно правило на строку, например model-prefix-*",
"excluded_models_hint": "Выбирайте модели напрямую; пользовательские правила поддерживают подстановочный знак (*).",
"excluded_models_load_failed": "Не удалось загрузить модели; пользовательские правила по-прежнему доступны.",
"prefix_proxy_invalid_json": "Этот файл авторизации не является JSON-объектом, поэтому поля нельзя редактировать.",
"prefix_proxy_html_challenge": "Скачанное содержимое является HTML-страницей проверки, а не JSON-объектом авторизации. Повторно авторизуйтесь или замените файл перед редактированием полей.",
"prefix_proxy_saved_success": "Файл авторизации \"{{name}}\" успешно обновлён",
@@ -1542,8 +1548,18 @@
"proxyBadge": "Прокси",
"modelBadgeImage": "Image",
"modelBadgeThinking": "Thinking",
"thinkingConfig": "Thinking config (JSON)",
"thinkingConfigHint": "Поддерживает levels, min, max, zero_allowed, dynamic_allowed",
"thinkingConfig": "Допустимые уровни thinking",
"thinkingExistingHint": "Текущая серверная конфигурация сохранится до изменения выбора, после чего будет заменена выбранными стандартными уровнями.",
"thinkingLevels": {
"none": "Отключить thinking",
"minimal": "Минимальный",
"low": "Низкий",
"medium": "Средний",
"high": "Высокий",
"xhigh": "Очень высокий",
"max": "Максимальный",
"auto": "Автоматический"
},
"cloakCacheUserId": "Кэшировать user_id",
"cloakCacheUserIdHint": "Переиспользовать Claude cloak user_id для каждого API-ключа",
"experimentalCchSigning": "Экспериментальная CCH-подпись",
+20 -4
View File
@@ -354,8 +354,14 @@
"note_placeholder": "输入备注信息,例如:张三的账号",
"note_hint": "可选,用于标记凭证用途或归属;留空则不写入。",
"excluded_models_label": "排除模型(excluded_models",
"excluded_models_placeholder": "model-1\nmodel-2\nmodel-prefix-*",
"excluded_models_hint": "每行填写一个模型匹配规则,支持通配符(*);命中的模型不会通过该凭证路由。",
"excluded_models_loading": "正在加载凭证模型…",
"excluded_models_select": "选择要排除的模型(已选 {{count}} 个)",
"excluded_models_select_label": "选择要排除的凭证模型",
"excluded_models_remove": "取消排除模型 {{model}}",
"excluded_models_custom_label": "自定义规则",
"excluded_models_custom_placeholder": "每行一个规则,例如 model-prefix-*",
"excluded_models_hint": "可直接选择凭证模型;自定义规则支持通配符(*)。",
"excluded_models_load_failed": "无法加载凭证模型,仍可使用自定义规则。",
"headers_label": "自定义请求头(headers",
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
"headers_hint": "以 JSON 对象格式输入自定义 HTTP 请求头,例如:{\"X-My-Header\": \"value\"}",
@@ -1564,8 +1570,18 @@
"proxyBadge": "代理",
"modelBadgeImage": "图片",
"modelBadgeThinking": "Thinking",
"thinkingConfig": "Thinking 配置(JSON)",
"thinkingConfigHint": "可配置 levels、min、max、zero_allowed、dynamic_allowed",
"thinkingConfig": "允许的思考档位",
"thinkingExistingHint": "当前后端配置会保持不变;修改选项后将替换为所选标准档位。",
"thinkingLevels": {
"none": "关闭思考",
"minimal": "最少",
"low": "低",
"medium": "中",
"high": "高",
"xhigh": "超高",
"max": "最大",
"auto": "自动"
},
"cloakCacheUserId": "缓存 user_id",
"cloakCacheUserIdHint": "按 API key 复用 Claude cloak 生成的 user_id",
"experimentalCchSigning": "实验性 CCH 签名",
+20 -4
View File
@@ -354,8 +354,14 @@
"note_placeholder": "輸入備註資訊,例如:張三的帳號",
"note_hint": "選填,用於標記憑證用途或歸屬;留空則不寫入。",
"excluded_models_label": "排除模型(excluded_models",
"excluded_models_placeholder": "model-1\nmodel-2\nmodel-prefix-*",
"excluded_models_hint": "每行填寫一個模型比對規則,支援萬用字元(*);符合的模型不會透過此憑證路由。",
"excluded_models_loading": "正在載入憑證模型…",
"excluded_models_select": "選擇要排除的模型(已選 {{count}} 個)",
"excluded_models_select_label": "選擇要排除的憑證模型",
"excluded_models_remove": "取消排除模型 {{model}}",
"excluded_models_custom_label": "自訂規則",
"excluded_models_custom_placeholder": "每行一個規則,例如 model-prefix-*",
"excluded_models_hint": "可直接選擇憑證模型;自訂規則支援萬用字元(*)。",
"excluded_models_load_failed": "無法載入憑證模型,仍可使用自訂規則。",
"headers_label": "自訂請求標頭(headers",
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
"headers_hint": "以 JSON 物件格式輸入自訂 HTTP 請求標頭,例如:{\"X-My-Header\": \"value\"}",
@@ -1590,8 +1596,18 @@
"proxyBadge": "代理",
"modelBadgeImage": "圖片",
"modelBadgeThinking": "Thinking",
"thinkingConfig": "Thinking 設定(JSON)",
"thinkingConfigHint": "可設定 levels、min、max、zero_allowed、dynamic_allowed",
"thinkingConfig": "允許的思考檔位",
"thinkingExistingHint": "目前後端設定會保持不變;修改選項後將替換為所選標準檔位。",
"thinkingLevels": {
"none": "關閉思考",
"minimal": "最少",
"low": "低",
"medium": "中",
"high": "高",
"xhigh": "超高",
"max": "最大",
"auto": "自動"
},
"cloakCacheUserId": "快取 user_id",
"cloakCacheUserIdHint": "按 API key 複用 Claude cloak 產生的 user_id",
"experimentalCchSigning": "實驗性 CCH 簽名",
@@ -0,0 +1,50 @@
import { describe, expect, test } from 'bun:test';
import {
isModelExcludedByWildcard,
matchesExcludedModelRule,
parseExcludedModelRules,
replaceCustomExcludedModelRules,
splitExcludedModelRules,
toggleExcludedModel,
} from '../src/features/authFiles/excludedModelSelection';
describe('auth-file excluded model selection', () => {
test('normalizes lines and matches backend wildcard semantics case-insensitively', () => {
expect(parseExcludedModelRules(' GPT-5-*\ngpt-5-*\nclaude-opus ')).toEqual([
'GPT-5-*',
'claude-opus',
]);
expect(matchesExcludedModelRule('gpt-5-*', 'GPT-5-Codex')).toBe(true);
expect(matchesExcludedModelRule('*-preview', 'gemini-3-pro-preview')).toBe(true);
expect(matchesExcludedModelRule('gpt-5-*', 'gpt-4.1')).toBe(false);
});
test('separates selectable exact models from custom rules', () => {
expect(
splitExcludedModelRules(
['GPT-5-Codex', 'gpt-5-*', 'unlisted-model'],
['gpt-5-codex', 'claude-opus']
)
).toEqual({
selectedIds: ['gpt-5-codex'],
customRules: ['gpt-5-*', 'unlisted-model'],
});
});
test('adds and removes exact selections without changing wildcard rules', () => {
const added = toggleExcludedModel(['gpt-5-*'], 'claude-opus', true);
expect(added).toEqual(['gpt-5-*', 'claude-opus']);
expect(toggleExcludedModel(added, 'CLAUDE-OPUS', false)).toEqual(['gpt-5-*']);
expect(isModelExcludedByWildcard(added, 'gpt-5-mini')).toBe(true);
});
test('updates custom rules while retaining selected credential models', () => {
expect(
replaceCustomExcludedModelRules(
['gpt-5-codex', 'old-*'],
['gpt-5-codex', 'claude-opus'],
'new-*\nlegacy-model'
)
).toEqual(['gpt-5-codex', 'new-*', 'legacy-model']);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'bun:test';
import {
buildThinkingFromLevels,
readThinkingLevels,
THINKING_LEVELS,
} from '../src/features/providers/thinkingLevels';
describe('standard thinking level selector', () => {
test('only exposes levels recognized by the backend', () => {
expect(THINKING_LEVELS).toEqual([
'none',
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
'auto',
]);
});
test('reads standard levels and legacy capability flags', () => {
expect(
readThinkingLevels({
levels: ['LOW', 'custom', 'high', 'none'],
zero_allowed: true,
dynamic_allowed: true,
})
).toEqual(['none', 'low', 'high', 'auto']);
});
test('writes canonical backend levels and omits an empty selection', () => {
expect(buildThinkingFromLevels([])).toBeUndefined();
expect(buildThinkingFromLevels(['auto', 'high', 'none', 'low'])).toEqual({
levels: ['low', 'high', 'none', 'auto'],
});
});
});