mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-08-29 00:41:25 +08:00
feat(auth-files): implement excluded models selection with UI support and backend integration
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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-подпись",
|
||||
|
||||
@@ -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 签名",
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user