fix(auth-files): separate account and provider-wide model exclusions

- Align model management with Management Center by separating model viewing, account exclusions, and provider-wide exclusions
- Read and update excluded_models per credential without affecting other accounts
- Make checked models represent exclusions and support manual wildcard rules
- Limit Select All to candidate models and preserve wildcard and off-list rules when clearing selections
- Allow rule editing when the model list is empty or unavailable, including removal of wildcard bans
- Update translations and add regression coverage for account isolation and rule persistence
This commit is contained in:
lzt404 committed 2026-09-07 19:28:21 +08:00
1 parent 6ab7af0833
commit 492aa75e81
10 files changed
+720 -167

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { Check, Copy, LoaderCircle, Search, X } from 'lucide-react';
import { useI18n } from '../i18n';
import { managementApi } from '../services/managementApi';
import { oauthModelsFromPayload, type OAuthModelDefinition } from '../services/oauthModels';
type AuthFileModelsDialogProps = {
name: string;
onClose: () => void;
};
export function AuthFileModelsDialog({ name, onClose }: AuthFileModelsDialogProps) {
const { t } = useI18n();
const [models, setModels] = useState<OAuthModelDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [copied, setCopied] = useState('');
useEffect(() => {
let cancelled = false;
setLoading(true);
setModels([]);
setError('');
void managementApi.get('/auth-files/models', { name })
.then((payload) => { if (!cancelled) setModels(oauthModelsFromPayload(payload)); })
.catch((requestError: unknown) => { if (!cancelled) setError(String(requestError)); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [name]);
const query = search.trim().toLowerCase();
const visibleModels = models.filter((model) =>
[model.id, model.displayName ?? ''].join(' ').toLowerCase().includes(query),
);
const copyModel = async (id: string) => {
try {
await navigator.clipboard.writeText(id);
setCopied(id);
} catch (copyError) {
setError(String(copyError));
}
};
return (
<div className="model-discovery-backdrop" onMouseDown={(event) => { if (event.currentTarget === event.target) onClose(); }}>
<section className="model-discovery-dialog auth-model-dialog auth-model-view-dialog" role="dialog" aria-modal="true" aria-labelledby="auth-model-view-title" onKeyDown={(event) => { if (event.key === 'Escape') onClose(); }}>
<div className="model-discovery-header">
<div>
<h2 id="auth-model-view-title">{t('authFiles.models.viewTitle')}</h2>
<span className="auth-model-target">{name}</span>
<span>{t('authFiles.models.viewDescription')}</span>
</div>
<button type="button" className="icon-button quiet" onClick={onClose} title={t('common.close')}><X size={18} /></button>
</div>
<div className="model-discovery-search">
<Search size={16} aria-hidden="true" />
<input autoFocus value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder={t('authFiles.models.search')} />
</div>
<div className="model-discovery-content">
{loading ? (
<div className="model-discovery-message"><LoaderCircle size={20} className="spin" />{t('authFiles.models.loading')}</div>
) : (
<div className="model-discovery-results">
<div>{error ? <div className="model-discovery-inline-error" role="alert">{error}</div> : null}</div>
{visibleModels.length === 0 ? (
<div className="model-discovery-message">{t(models.length ? 'authFiles.models.noMatch' : 'authFiles.models.viewEmpty')}</div>
) : (
<div className="model-discovery-list">
{visibleModels.map((model) => (
<button type="button" className="model-discovery-row auth-model-view-row" key={model.id} onClick={() => void copyModel(model.id)} title={t('authFiles.models.copyModel')}>
<span><strong>{model.id}</strong>{model.displayName ? <small>{model.displayName}</small> : null}</span>
{copied === model.id ? <Check size={16} /> : <Copy size={16} />}
</button>
))}
</div>
)}
</div>
)}
</div>
<div className="model-discovery-actions">
<button type="button" className="secondary-button" onClick={onClose}>{t('common.close')}</button>
</div>
</section>
</div>
);
}
+29 -12
View File
@@ -614,7 +614,7 @@ export const jaOverrides = {
'authFiles.quota.resets': '手動リセット {count} 回',
'authFiles.quota.expiry': '最も早い有効期限 {time}',
'authFiles.models.noneForProvider': 'このプロバイダーには設定可能なモデル定義がありません',
'authFiles.models.updated': '{provider} の公開モデルを更新しました',
'authFiles.models.updated': '{provider} 全体のモデル設定を更新しました',
'authFiles.priority.button': '優先度 {priority}',
'authFiles.priority.title': '認証情報の優先度を変更',
'authFiles.priority.label': '優先度',
@@ -651,27 +651,44 @@ export const jaOverrides = {
'authFiles.quota.querying': '照会中',
'authFiles.quota.refresh': 'クォータを更新',
'authFiles.quota.fetch': 'クォータを取得',
'authFiles.models.settings': '公開モデルを設定',
'authFiles.models.settings': 'このアカウントのモデル除外を編集',
'authFiles.models.button': 'モデル',
'authFiles.copyName': 'ファイル名をコピー',
'authFiles.openDirectory': 'フォルダーを開く',
'common.enable': '有効化',
'common.disable': '無効化',
'authFiles.runtimeFootnote': '{count} 件の認証情報は実行時ストレージ由来です。対応するプロバイダーまたは認証元でのみ管理できます。',
'authFiles.models.title': '公開モデル',
'authFiles.models.description': '{provider} · 既定ではすべて公開されます。不要なモデルは選択解除できます',
'authFiles.models.title': 'アカウントのモデル除外',
'authFiles.models.description': '{provider} · チェックしたモデルをこのアカウントで除外します。全体のルールも適用されます',
'authFiles.models.credentialUpdated': '{name} のモデル除外ルールを更新しました',
'authFiles.models.globalButton': 'OAuth 全体のモデル除外',
'authFiles.models.provider': 'プロバイダー',
'authFiles.models.globalDescription': 'チェックしたモデルをすべての {provider} OAuth アカウントで除外します。個別のルールも適用されます',
'authFiles.models.runtimeUnsupported': '実行時の認証情報のモデルは、対応するプロバイダーまたは認証元で設定してください',
'authFiles.models.invalidMetadata': '認証ファイルを読み取れないため、モデル設定を読み込めませんでした',
'authFiles.models.invalidExclusions': '認証ファイルのモデル除外ルールが無効です。ファイルを確認してください',
'authFiles.models.excludeButton': 'モデル除外',
'authFiles.models.viewTitle': '対応モデル',
'authFiles.models.viewDescription': 'この認証情報に現在登録されているモデルです。モデル名をクリックするとコピーできます',
'authFiles.models.viewEmpty': 'この認証情報で現在利用できるモデルはありません',
'authFiles.models.copyModel': 'モデル名をコピー',
'authFiles.models.excludeAll': 'すべて除外',
'authFiles.models.excludeAllHint': '候補一覧の各モデルを追加します。* は自動で追加されません',
'authFiles.models.clearSelected': '選択をクリア',
'authFiles.models.clearHint': '一覧内の完全一致の選択を解除し、ワイルドカードと一覧外のルールを保持します',
'authFiles.models.rulesPlaceholder': 'gpt-image-*',
'authFiles.models.rulesLabel': '除外ルール(1 行に 1 件)',
'authFiles.models.rulesHint': '* は任意の文字列に一致し、大文字と小文字は区別しません。* を入力するとすべてのモデルを除外します。選択のクリアではワイルドカードと一覧外のルールを保持します。',
'authFiles.models.wildcardBlocked': '{rule} により除外されています。下のルールを編集してください',
'authFiles.models.catalogUnavailable': '候補モデルを読み込めませんでした。下の欄で除外ルールの編集と保存は可能です',
'authFiles.models.search': 'モデル名を検索',
'authFiles.models.summary': '合計 {total} 件 · 公開 {open} 件',
'authFiles.models.closeVisible': '表示中を非公開',
'authFiles.models.openVisible': '表示中を公開',
'authFiles.models.openAll': 'すべて公開',
'authFiles.models.closeAll': 'すべて非公開',
'authFiles.models.loading': 'モデル定義を読み込んでいます',
'authFiles.models.summary': '候補 {total} 件 · 除外 {excluded} 件',
'authFiles.models.loading': 'モデル一覧を読み込んでいます',
'authFiles.models.loadFailed': 'モデルの読み込みに失敗しました',
'authFiles.models.noMatch': '一致するモデルがありません',
'authFiles.models.empty': 'モデル定義がありません',
'authFiles.models.empty': '候補モデルがありません。下の欄に除外ルールを入力できます',
'common.saving': '保存中',
'authFiles.models.save': '保存({count} 件を公開)',
'authFiles.models.save': '保存({count} ルール)',
'agents.description.claudeCode': 'CPA の Anthropic 互換 API を使用',
'agents.description.claudeDesktop': 'CPA 3P 推論ゲートウェイを使用(Windows、macOS、Linux Beta 対応)',
'agents.description.codex': 'CPA Responses API を使用',
+29 -12
View File
@@ -614,7 +614,7 @@ export const en: Record<MessageKey, string> = {
'authFiles.quota.resets': '{count} manual resets',
'authFiles.quota.expiry': 'Earliest expiry {time}',
'authFiles.models.noneForProvider': 'This provider has no configurable model definitions',
'authFiles.models.updated': 'Open models updated for {provider}',
'authFiles.models.updated': 'Provider-wide models updated for {provider}',
'authFiles.priority.button': 'Priority {priority}',
'authFiles.priority.title': 'Edit Credential Priority',
'authFiles.priority.label': 'Priority',
@@ -651,27 +651,44 @@ export const en: Record<MessageKey, string> = {
'authFiles.quota.querying': 'Querying',
'authFiles.quota.refresh': 'Refresh Quota',
'authFiles.quota.fetch': 'Fetch Quota',
'authFiles.models.settings': 'Configure open models',
'authFiles.models.settings': 'Edit exclusions for this account',
'authFiles.models.button': 'Models',
'authFiles.copyName': 'Copy file name',
'authFiles.openDirectory': 'Open Folder',
'common.enable': 'Enable',
'common.disable': 'Disable',
'authFiles.runtimeFootnote': '{count} credentials come from runtime storage and can only be managed through their provider or authentication source.',
'authFiles.models.title': 'Open Models',
'authFiles.models.description': '{provider} · All models are open by default; disable any you do not need',
'authFiles.models.title': 'Account Model Exclusions',
'authFiles.models.description': '{provider} · Checked models are excluded by this account; provider-wide rules also apply',
'authFiles.models.credentialUpdated': 'Model exclusions updated for {name}',
'authFiles.models.globalButton': 'OAuth Global Model Exclusions',
'authFiles.models.provider': 'Provider',
'authFiles.models.globalDescription': 'Checked models are excluded for every {provider} OAuth account; account-specific rules still apply',
'authFiles.models.runtimeUnsupported': 'Manage models for runtime credentials in their provider or authentication source',
'authFiles.models.invalidMetadata': 'Could not read the credential file; model settings were not loaded',
'authFiles.models.invalidExclusions': 'The credential file has invalid model exclusion rules; check the file first',
'authFiles.models.excludeButton': 'Exclude Models',
'authFiles.models.viewTitle': 'Supported Models',
'authFiles.models.viewDescription': 'Models currently registered for this credential; click a model name to copy it',
'authFiles.models.viewEmpty': 'This credential currently has no available models',
'authFiles.models.copyModel': 'Copy model name',
'authFiles.models.excludeAll': 'Exclude All',
'authFiles.models.excludeAllHint': 'Add each model in the candidate list; this does not automatically add *',
'authFiles.models.clearSelected': 'Clear Selection',
'authFiles.models.clearHint': 'Clear exact selections in the list, preserving wildcard and off-list rules',
'authFiles.models.rulesPlaceholder': 'gpt-image-*',
'authFiles.models.rulesLabel': 'Exclusion rules (one per line)',
'authFiles.models.rulesHint': '* matches any characters; matching is case-insensitive. Enter * to exclude all models. Clearing selections preserves wildcard and off-list rules.',
'authFiles.models.wildcardBlocked': 'Excluded by {rule}; edit the rule below',
'authFiles.models.catalogUnavailable': 'Could not load candidate models. You can still edit and save exclusion rules below',
'authFiles.models.search': 'Search model names',
'authFiles.models.summary': '{total} total · {open} open',
'authFiles.models.closeVisible': 'Close Visible',
'authFiles.models.openVisible': 'Open Visible',
'authFiles.models.openAll': 'Open All',
'authFiles.models.closeAll': 'Close All',
'authFiles.models.loading': 'Loading model definitions',
'authFiles.models.summary': '{total} candidates · {excluded} excluded',
'authFiles.models.loading': 'Loading model list',
'authFiles.models.loadFailed': 'Failed to load models',
'authFiles.models.noMatch': 'No matching models',
'authFiles.models.empty': 'No model definitions',
'authFiles.models.empty': 'No candidate models; enter exclusion rules below',
'common.saving': 'Saving',
'authFiles.models.save': 'Save ({count} open)',
'authFiles.models.save': 'Save ({count} rules)',
'agents.description.claudeCode': 'Uses the CPA Anthropic-compatible API',
'agents.description.claudeDesktop': 'Uses the CPA third-party inference gateway on Windows, macOS, and Linux Beta',
'agents.description.codex': 'Uses the CPA Responses API',
+29 -12
View File
@@ -613,7 +613,7 @@ export const zhCN = {
'authFiles.quota.resets': '主动重置 {count} 次',
'authFiles.quota.expiry': '最早过期 {time}',
'authFiles.models.noneForProvider': '该提供商没有可配置的模型定义',
'authFiles.models.updated': '{provider} 开放模型已更新',
'authFiles.models.updated': '{provider} 渠道全局模型已更新',
'authFiles.priority.button': '优先级 {priority}',
'authFiles.priority.title': '修改凭证优先级',
'authFiles.priority.label': '优先级',
@@ -650,27 +650,44 @@ export const zhCN = {
'authFiles.quota.querying': '查询中',
'authFiles.quota.refresh': '刷新额度',
'authFiles.quota.fetch': '获取额度',
'authFiles.models.settings': '设置开放模型',
'authFiles.models.settings': '编辑此账号的排除模型',
'authFiles.models.button': '模型',
'authFiles.copyName': '复制文件名',
'authFiles.openDirectory': '打开文件夹',
'common.enable': '启用',
'common.disable': '停用',
'authFiles.runtimeFootnote': '{count} 个凭据来自运行时存储,只能在对应提供商或认证源中管理。',
'authFiles.models.title': '开放模型',
'authFiles.models.description': '{provider} · 默认全部开放,可取消不需要的模型',
'authFiles.models.title': '账号排除模型',
'authFiles.models.description': '{provider} · 勾选表示此账号主动排除;渠道全局规则仍会叠加生效',
'authFiles.models.credentialUpdated': '{name} 的模型排除规则已更新',
'authFiles.models.globalButton': 'OAuth 全局模型禁用',
'authFiles.models.provider': '渠道',
'authFiles.models.globalDescription': '勾选将禁用全部 {provider} OAuth 账号的对应模型;账号自身规则仍然生效',
'authFiles.models.runtimeUnsupported': '运行时凭据请在对应提供商或认证源中设置模型',
'authFiles.models.invalidMetadata': '无法读取凭证文件内容,未加载模型设置',
'authFiles.models.invalidExclusions': '凭证文件的模型排除规则格式无效,请先检查该文件',
'authFiles.models.excludeButton': '排除模型',
'authFiles.models.viewTitle': '支持的模型',
'authFiles.models.viewDescription': '显示此凭证当前注册的模型;点击模型名可复制',
'authFiles.models.viewEmpty': '此凭证当前没有可用模型',
'authFiles.models.copyModel': '复制模型名',
'authFiles.models.excludeAll': '全部排除',
'authFiles.models.excludeAllHint': '将当前候选列表中的模型逐个加入排除规则,不会自动写入 *',
'authFiles.models.clearSelected': '清空勾选',
'authFiles.models.clearHint': '清空列表内的精确勾选,保留通配符和列表外规则',
'authFiles.models.rulesPlaceholder': 'gpt-image-*',
'authFiles.models.rulesLabel': '排除规则(每行一条)',
'authFiles.models.rulesHint': '* 匹配任意字符,匹配不区分大小写。填写 * 可排除所有模型;清空勾选会保留通配符和列表外规则。',
'authFiles.models.wildcardBlocked': '由 {rule} 排除,请在下方规则框中修改',
'authFiles.models.catalogUnavailable': '候选模型加载失败,仍可在下方编辑和保存排除规则',
'authFiles.models.search': '搜索模型名称',
'authFiles.models.summary': '共 {total} 个 · 已开放 {open} 个',
'authFiles.models.closeVisible': '关闭当前',
'authFiles.models.openVisible': '开放当前',
'authFiles.models.openAll': '全部开放',
'authFiles.models.closeAll': '全部关闭',
'authFiles.models.loading': '正在读取模型定义',
'authFiles.models.summary': '候选 {total} 个 · 已排除 {excluded} 个',
'authFiles.models.loading': '正在读取模型列表',
'authFiles.models.loadFailed': '读取模型失败',
'authFiles.models.noMatch': '没有匹配的模型',
'authFiles.models.empty': '暂无模型定义',
'authFiles.models.empty': '暂无候选模型,可在下方填写排除规则',
'common.saving': '保存中',
'authFiles.models.save': '保存(开放 {count} 个)',
'authFiles.models.save': '保存({count} 条规则)',
'agents.description.claudeCode': '使用 CPA 的 Anthropic 兼容接口',
'agents.description.claudeDesktop': '使用 CPA 3P 推理网关,支持 Windows、macOS 和 Linux Beta',
'agents.description.codex': '使用 CPA Responses 接口',
+121 -87
View File
@@ -1,6 +1,7 @@
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { confirm } from '@tauri-apps/plugin-dialog';
import { InlineNotice, useAppNotice } from '../appNotice';
import { AuthFileModelsDialog } from '../components/AuthFileModelsDialog';
import {
Check,
Copy,
@@ -11,6 +12,7 @@ import {
Pencil,
RefreshCw,
Search,
Settings2,
Trash2,
X,
} from 'lucide-react';
@@ -53,12 +55,18 @@ import {
parseAuthFilePriority,
} from '../services/authFiles';
import {
exclusionsForOpenOAuthModels,
oauthExcludedRulesFromPayload,
oauthModelsFromPayload,
modelMatchesRule,
normalizeOAuthExcludedRules,
openOAuthModelNames,
setOAuthModelsExcluded,
type OAuthModelDefinition,
} from '../services/oauthModels';
import {
loadOAuthModelSettings,
saveOAuthModelSettings,
type OAuthModelSettings,
type OAuthModelTarget,
} from '../services/oauthModelSettings';
import { getCurrentLocale, translate, useI18n } from '../i18n';
import { formatQuotaReset, useQuotaClock } from '../services/quotaTime';
@@ -166,11 +174,10 @@ export function AuthFileManagementPage() {
const { showNotice } = feedback;
const [copied, setCopied] = useState('');
const [priorityEditor, setPriorityEditor] = useState<PriorityEditor | null>(null);
const [oauthModelProvider, setOauthModelProvider] = useState('');
const [oauthModelProviderLabel, setOauthModelProviderLabel] = useState('');
const [oauthModels, setOauthModels] = useState<OAuthModelDefinition[]>([]);
const [oauthExcludedRules, setOauthExcludedRules] = useState<string[]>([]);
const [openOauthModelNames, setOpenOauthModelNames] = useState<Set<string>>(new Set());
const [oauthModelTarget, setOauthModelTarget] = useState<OAuthModelTarget | null>(null);
const [oauthModelSettings, setOauthModelSettings] = useState<OAuthModelSettings | null>(null);
const [oauthExcludedRulesText, setOauthExcludedRulesText] = useState('');
const [modelViewName, setModelViewName] = useState<string | null>(null);
const [oauthModelSearch, setOauthModelSearch] = useState('');
const [oauthModelLoading, setOauthModelLoading] = useState(false);
const [oauthModelSaving, setOauthModelSaving] = useState(false);
@@ -178,6 +185,16 @@ export function AuthFileManagementPage() {
const quotas = useQuotaCache();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const oauthModelRequestRef = useRef(0);
const oauthModelSaveRef = useRef(false);
const oauthModels = oauthModelSettings?.models ?? [];
const oauthExcludedRules = normalizeOAuthExcludedRules(oauthExcludedRulesText.split(/\r?\n/));
const excludedOauthModelCount = oauthModels.length - openOAuthModelNames(oauthModels, oauthExcludedRules).size;
const oauthModelProviders = useMemo(() => Array.from(new Map(
files.filter((file) => providerKey(file)).map((file) => [providerKey(file), {
provider: providerKey(file),
label: providerName(file),
}]),
).values()).sort((a, b) => a.label.localeCompare(b.label)), [files]);
const loadFiles = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true);
@@ -218,35 +235,27 @@ export function AuthFileManagementPage() {
};
const closeOauthModels = () => {
if (oauthModelSaveRef.current) return;
oauthModelRequestRef.current += 1;
setOauthModelProvider('');
setOauthModelTarget(null);
setOauthModelSettings(null);
};
const openOauthModels = async (file: AuthFile) => {
const provider = providerKey(file);
if (!provider) return;
const openOauthModelSettings = async (target: OAuthModelTarget) => {
if (oauthModelSaveRef.current) return;
const requestId = oauthModelRequestRef.current + 1;
oauthModelRequestRef.current = requestId;
setOauthModelProvider(provider);
setOauthModelProviderLabel(providerName(file));
setOauthModels([]);
setOauthExcludedRules([]);
setOpenOauthModelNames(new Set());
setOauthModelTarget(target);
setOauthModelSettings(null);
setOauthExcludedRulesText('');
setOauthModelSearch('');
setOauthModelError('');
setOauthModelLoading(true);
try {
const [definitionsPayload, excludedPayload] = await Promise.all([
managementApi.get(`/model-definitions/${encodeURIComponent(provider)}`),
managementApi.get('/oauth-excluded-models'),
]);
const settings = await loadOAuthModelSettings(target);
if (oauthModelRequestRef.current !== requestId) return;
const models = oauthModelsFromPayload(definitionsPayload);
const excludedRules = oauthExcludedRulesFromPayload(excludedPayload, provider);
setOauthModels(models);
setOauthExcludedRules(excludedRules);
setOpenOauthModelNames(openOAuthModelNames(models, excludedRules));
if (models.length === 0) setOauthModelError(t('authFiles.models.noneForProvider'));
setOauthModelSettings(settings);
setOauthExcludedRulesText(settings.excludedRules.join('\n'));
} catch (requestError) {
if (oauthModelRequestRef.current === requestId) setOauthModelError(String(requestError));
} finally {
@@ -254,27 +263,31 @@ export function AuthFileManagementPage() {
}
};
const openOauthModels = (file: AuthFile) => {
const name = readString(file, 'name');
const provider = providerKey(file);
if (!name || !provider || isRuntimeOnly(file)) return;
return openOauthModelSettings({ scope: 'credential', name, provider, label: providerName(file) });
};
const saveOauthModels = async () => {
if (!oauthModelProvider) return;
const excludedModels = exclusionsForOpenOAuthModels(
oauthExcludedRules,
oauthModels,
openOauthModelNames,
);
if (!oauthModelSettings || oauthModelLoading || oauthModelSaveRef.current) return;
const settings = oauthModelSettings;
oauthModelSaveRef.current = true;
setOauthModelSaving(true);
setOauthModelError('');
try {
if (excludedModels.length > 0 || oauthExcludedRules.length > 0) {
await managementApi.patch('/oauth-excluded-models', {
provider: oauthModelProvider,
models: excludedModels,
});
}
showNotice({ key: 'authFiles.models.updated', variables: { provider: oauthModelProviderLabel } });
await saveOAuthModelSettings(settings, oauthExcludedRules);
showNotice(settings.target.scope === 'credential'
? { key: 'authFiles.models.credentialUpdated', variables: { name: settings.target.name } }
: { key: 'authFiles.models.updated', variables: { provider: settings.target.label } });
oauthModelSaveRef.current = false;
closeOauthModels();
if (settings.target.scope === 'credential') void loadFiles(false);
} catch (requestError) {
setOauthModelError(String(requestError));
} finally {
oauthModelSaveRef.current = false;
setOauthModelSaving(false);
}
};
@@ -287,29 +300,11 @@ export function AuthFileManagementPage() {
);
}, [oauthModelSearch, oauthModels]);
const allVisibleOauthModelsOpen = visibleOauthModels.length > 0
&& visibleOauthModels.every((model) => openOauthModelNames.has(model.id.toLowerCase()));
const toggleOauthModel = (model: OAuthModelDefinition) => {
const key = model.id.toLowerCase();
setOpenOauthModelNames((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleAllVisibleOauthModels = () => {
setOpenOauthModelNames((current) => {
const next = new Set(current);
visibleOauthModels.forEach((model) => {
const key = model.id.toLowerCase();
if (allVisibleOauthModelsOpen) next.delete(key);
else next.add(key);
});
return next;
});
const setOauthModelsExcluded = (models: OAuthModelDefinition[], excluded: boolean) => {
if (oauthModelSaveRef.current) return;
setOauthExcludedRulesText((current) =>
setOAuthModelsExcluded(current.split(/\r?\n/), models, excluded).join('\n'),
);
};
useEffect(() => {
@@ -484,6 +479,12 @@ export function AuthFileManagementPage() {
</div>
<div className="management-heading-actions">
<span className="muted-summary">{t('authFiles.summary', { files: files.length, disabled: disabledCount })}</span>
<button type="button" className="secondary-button compact-button" onClick={() => {
const provider = oauthModelProviders.find((item) => item.label === providerFilter) ?? oauthModelProviders[0];
if (provider) void openOauthModelSettings({ ...provider, scope: 'provider' });
}} disabled={loading || busy || oauthModelSaving || oauthModelProviders.length === 0}>
<Settings2 size={16} />{t('authFiles.models.globalButton')}
</button>
<button type="button" className="secondary-button compact-button" onClick={() => void loadFiles()} disabled={loading || busy}>
<RefreshCw size={16} />{t('common.refresh')}
</button>
@@ -541,7 +542,8 @@ export function AuthFileManagementPage() {
</div>
<div className="auth-file-actions">
{quotaProviderForFile(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void refreshQuota(file)} disabled={busy || disabled || quotas[quotaKey(file)]?.status === 'loading'}>{disabled ? t('authFiles.status.disabled') : quotas[quotaKey(file)]?.status === 'loading' ? t('authFiles.quota.querying') : quotas[quotaKey(file)]?.status === 'success' ? t('authFiles.quota.refresh') : t('authFiles.quota.fetch')}</button> : null}
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void openOauthModels(file)} disabled={busy} title={t('authFiles.models.settings')}>{t('authFiles.models.button')}</button> : null}
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => setModelViewName(name)} disabled={busy} title={t('authFiles.models.viewTitle')}>{t('authFiles.models.button')}</button> : null}
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void openOauthModels(file)} disabled={busy || oauthModelSaving || isRuntimeOnly(file) || !readString(file, 'name')} title={t(isRuntimeOnly(file) ? 'authFiles.models.runtimeUnsupported' : 'authFiles.models.settings')}>{t('authFiles.models.excludeButton')}</button> : null}
<button type="button" className="secondary-button compact-button auth-file-priority-button" onClick={() => openPriorityEditor(file)} disabled={busy} title={t('authFiles.priority.hint')}><Pencil size={14} />{t('authFiles.priority.button', { priority })}</button>
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy} title={t('authFiles.copyName')}>{copied === name ? <Check size={16} /> : <Copy size={16} />}</button>
<button type="button" className={`${disabled ? 'primary-button' : 'secondary-button'} compact-button`} onClick={() => void toggleStatus(file)} disabled={busy}>{disabled ? t('common.enable') : t('common.disable')}</button>
@@ -604,54 +606,86 @@ export function AuthFileManagementPage() {
</div>
) : null}
{oauthModelProvider ? (
{modelViewName ? <AuthFileModelsDialog name={modelViewName} onClose={() => setModelViewName(null)} /> : null}
{oauthModelTarget ? (
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && !oauthModelSaving && closeOauthModels()}>
<section className="model-discovery-dialog" role="dialog" aria-modal="true" aria-labelledby="oauth-model-title">
<section className="model-discovery-dialog auth-model-dialog" role="dialog" aria-modal="true" aria-labelledby="oauth-model-title" onKeyDown={(event) => { if (event.key === 'Escape') closeOauthModels(); }}>
<div className="model-discovery-header">
<div><h2 id="oauth-model-title">{t('authFiles.models.title')}</h2><span>{t('authFiles.models.description', { provider: oauthModelProviderLabel })}</span></div>
<div>
<h2 id="oauth-model-title">{t(oauthModelTarget.scope === 'credential' ? 'authFiles.models.title' : 'authFiles.models.globalButton')}</h2>
{oauthModelTarget.scope === 'credential' ? (
<span className="auth-model-target" title={oauthModelTarget.name}>{oauthModelTarget.name}</span>
) : (
<label className="auth-model-provider">
<span>{t('authFiles.models.provider')}</span>
<select value={oauthModelTarget.provider} disabled={oauthModelSaving} onChange={(event) => {
const provider = oauthModelProviders.find((item) => item.provider === event.currentTarget.value);
if (provider) void openOauthModelSettings({ ...provider, scope: 'provider' });
}}>
{oauthModelProviders.map((provider) => <option key={provider.provider} value={provider.provider}>{provider.label}</option>)}
</select>
</label>
)}
<span>{t(oauthModelTarget.scope === 'credential' ? 'authFiles.models.description' : 'authFiles.models.globalDescription', { provider: oauthModelTarget.label })}</span>
</div>
<button type="button" className="icon-button quiet" onClick={closeOauthModels} disabled={oauthModelSaving} title={t('common.close')}><X size={18} /></button>
</div>
<div className="model-discovery-search">
<Search size={16} aria-hidden="true" />
<input value={oauthModelSearch} onChange={(event) => setOauthModelSearch(event.currentTarget.value)} placeholder={t('authFiles.models.search')} />
<input autoFocus value={oauthModelSearch} onChange={(event) => setOauthModelSearch(event.currentTarget.value)} placeholder={t('authFiles.models.search')} />
</div>
<div className="model-discovery-toolbar">
<span>{t('authFiles.models.summary', { total: oauthModels.length, open: openOauthModelNames.size })}</span>
<span>{t('authFiles.models.summary', { total: oauthModels.length, excluded: excludedOauthModelCount })}</span>
<div>
<button type="button" className="secondary-button compact-button" onClick={toggleAllVisibleOauthModels} disabled={oauthModelLoading || visibleOauthModels.length === 0}>{allVisibleOauthModelsOpen ? t('authFiles.models.closeVisible') : t('authFiles.models.openVisible')}</button>
<button type="button" className="secondary-button compact-button" onClick={() => setOpenOauthModelNames(new Set(oauthModels.map((model) => model.id.toLowerCase())))} disabled={oauthModelLoading || oauthModels.length === 0 || openOauthModelNames.size === oauthModels.length}>{t('authFiles.models.openAll')}</button>
<button type="button" className="secondary-button compact-button" onClick={() => setOpenOauthModelNames(new Set())} disabled={oauthModelLoading || openOauthModelNames.size === 0}>{t('authFiles.models.closeAll')}</button>
<button type="button" className="secondary-button compact-button" onClick={() => setOauthModelsExcluded(oauthModels, true)} disabled={oauthModelLoading || oauthModelSaving || oauthModels.length === 0} title={t('authFiles.models.excludeAllHint')}>{t('authFiles.models.excludeAll')}</button>
<button type="button" className="secondary-button compact-button" onClick={() => setOauthModelsExcluded(oauthModels, false)} disabled={oauthModelLoading || oauthModelSaving || oauthModels.length === 0} title={t('authFiles.models.clearHint')}>{t('authFiles.models.clearSelected')}</button>
</div>
</div>
<div className="model-discovery-content">
{oauthModelLoading ? (
<div className="model-discovery-message"><LoaderCircle size={20} className="spin" />{t('authFiles.models.loading')}</div>
) : oauthModelError ? (
) : oauthModelError && !oauthModelSettings ? (
<div className="model-discovery-message error"><strong>{t('authFiles.models.loadFailed')}</strong><span>{oauthModelError}</span></div>
) : visibleOauthModels.length === 0 ? (
<div className="model-discovery-message"><strong>{oauthModels.length ? t('authFiles.models.noMatch') : t('authFiles.models.empty')}</strong></div>
) : (
<div className="model-discovery-list">
{visibleOauthModels.map((model) => {
const checked = openOauthModelNames.has(model.id.toLowerCase());
return (
<label className={`model-discovery-row ${checked ? 'selected' : ''}`} key={model.id}>
<input type="checkbox" checked={checked} onChange={() => toggleOauthModel(model)} />
<span><strong title={model.id}>{model.id}</strong>{model.displayName ? <small title={model.displayName}>{model.displayName}</small> : null}</span>
{checked ? <Check size={16} aria-hidden="true" /> : null}
</label>
);
})}
<div className="model-discovery-results">
<div>
{oauthModelError ? <div className="model-discovery-inline-error" role="alert">{oauthModelError}</div> : null}
{oauthModelSettings?.catalogError ? <div className="model-discovery-inline-error" role="status">{t('authFiles.models.catalogUnavailable')}</div> : null}
</div>
{visibleOauthModels.length === 0 ? (
<div className="model-discovery-message"><strong>{oauthModels.length ? t('authFiles.models.noMatch') : t('authFiles.models.empty')}</strong></div>
) : (
<div className="model-discovery-list">
{visibleOauthModels.map((model) => {
const wildcardRule = oauthExcludedRules.find((rule) => rule.includes('*') && modelMatchesRule(model.id, rule));
const checked = oauthExcludedRules.some((rule) => modelMatchesRule(model.id, rule));
return (
<label className={['model-discovery-row', checked ? 'selected' : '', wildcardRule ? 'rule-blocked' : ''].join(' ')} key={model.id}>
<input type="checkbox" checked={checked} disabled={oauthModelSaving || Boolean(wildcardRule)} onChange={(event) => setOauthModelsExcluded([model], event.currentTarget.checked)} />
<span><strong title={model.id}>{model.id}</strong>{model.displayName ? <small title={model.displayName}>{model.displayName}</small> : null}{wildcardRule ? <small>{t('authFiles.models.wildcardBlocked', { rule: wildcardRule })}</small> : null}</span>
{checked ? <Check size={16} aria-hidden="true" /> : null}
</label>
);
})}
</div>
)}
</div>
)}
</div>
<label className="auth-model-rules" htmlFor="oauth-model-rules">
<span>{t('authFiles.models.rulesLabel')}</span>
<textarea id="oauth-model-rules" rows={3} spellCheck={false} value={oauthExcludedRulesText} disabled={oauthModelLoading || oauthModelSaving || !oauthModelSettings} onChange={(event) => setOauthExcludedRulesText(event.currentTarget.value)} placeholder={t('authFiles.models.rulesPlaceholder')} aria-describedby="oauth-model-rules-hint" />
<small id="oauth-model-rules-hint">{t('authFiles.models.rulesHint')}</small>
</label>
<div className="model-discovery-actions">
<button type="button" className="secondary-button" onClick={closeOauthModels} disabled={oauthModelSaving}>{t('common.cancel')}</button>
<button type="button" className="primary-button" onClick={() => void saveOauthModels()} disabled={oauthModelLoading || oauthModelSaving || oauthModels.length === 0}>{oauthModelSaving ? t('common.saving') : t('authFiles.models.save', { count: openOauthModelNames.size })}</button>
<button type="button" className="primary-button" onClick={() => void saveOauthModels()} disabled={oauthModelLoading || oauthModelSaving || !oauthModelSettings}>{oauthModelSaving ? t('common.saving') : t('authFiles.models.save', { count: oauthExcludedRules.length })}</button>
</div>
</section>
</div>
+100
View File
@@ -0,0 +1,100 @@
import { getCurrentLocale, translate } from '../i18n';
import { isRecord, managementApi } from './managementApi';
import {
normalizeOAuthExcludedRules,
oauthExcludedRulesFromPayload,
oauthModelCandidates,
oauthModelsFromPayload,
type OAuthModelDefinition,
} from './oauthModels';
export type OAuthModelTarget = { provider: string; label: string } & (
| { scope: 'credential'; name: string }
| { scope: 'provider' }
);
export type OAuthModelSettings = {
target: OAuthModelTarget;
models: OAuthModelDefinition[];
excludedRules: string[];
catalogError: string;
};
type OAuthModelSettingsApi = {
get: (path: string, query?: Record<string, string>) => Promise<unknown>;
patch: (path: string, body: Record<string, unknown>) => Promise<unknown>;
delete: (path: string, options?: { query?: Record<string, string> }) => Promise<unknown>;
};
export const authFileExcludedRulesFromPayload = (payload: unknown): string[] => {
let metadata = payload;
if (typeof metadata === 'string') {
try {
metadata = JSON.parse(metadata);
} catch {
// Parser errors can quote credential contents; show a fixed message instead.
throw new Error(translate(getCurrentLocale(), 'authFiles.models.invalidMetadata'));
}
}
if (!isRecord(metadata)) {
throw new Error(translate(getCurrentLocale(), 'authFiles.models.invalidMetadata'));
}
// CPA gives the canonical key precedence, including an explicit empty array or null.
const rules = Object.prototype.hasOwnProperty.call(metadata, 'excluded_models')
? metadata.excluded_models
: metadata['excluded-models'];
if (rules === undefined || rules === null) return [];
if (!Array.isArray(rules) || rules.some((rule) => typeof rule !== 'string')) {
throw new Error(translate(getCurrentLocale(), 'authFiles.models.invalidExclusions'));
}
return normalizeOAuthExcludedRules(rules);
};
export const loadOAuthModelSettings = async (
target: OAuthModelTarget,
api: OAuthModelSettingsApi = managementApi,
): Promise<OAuthModelSettings> => {
const [catalog, payload] = await Promise.all([
(target.scope === 'credential'
? api.get('/auth-files/models', { name: target.name })
: api.get(`/model-definitions/${encodeURIComponent(target.provider)}`))
.then((definitions) => ({ models: oauthModelsFromPayload(definitions), error: '' }))
.catch((error: unknown) => ({ models: [] as OAuthModelDefinition[], error: String(error) })),
target.scope === 'credential'
? api.get('/auth-files/download', { name: target.name })
: api.get('/oauth-excluded-models'),
]);
const excludedRules = target.scope === 'credential'
? authFileExcludedRulesFromPayload(payload)
: oauthExcludedRulesFromPayload(payload, target.provider);
return {
target,
models: oauthModelCandidates(catalog.models, excludedRules),
excludedRules,
catalogError: catalog.error,
};
};
export const saveOAuthModelSettings = async (
settings: OAuthModelSettings,
rules: Iterable<string>,
api: OAuthModelSettingsApi = managementApi,
): Promise<void> => {
const excludedModels = normalizeOAuthExcludedRules(rules);
if (excludedModels.length === settings.excludedRules.length
&& excludedModels.every((rule) => settings.excludedRules.includes(rule))) return;
if (settings.target.scope === 'credential') {
// Patch only this field: never upload a stale copy of tokens or other metadata.
await api.patch('/auth-files/fields', {
name: settings.target.name,
excluded_models: excludedModels,
});
} else if (excludedModels.length > 0) {
await api.patch('/oauth-excluded-models', {
provider: settings.target.provider,
models: excludedModels,
});
} else if (settings.excludedRules.length > 0) {
await api.delete('/oauth-excluded-models', { query: { provider: settings.target.provider } });
}
};
+22 -22
View File
@@ -55,27 +55,27 @@ export const openOAuthModelNames = (
);
};
export const exclusionsForOpenOAuthModels = (
currentRules: Iterable<string>,
models: OAuthModelDefinition[],
openModelNames: Iterable<string>,
) => {
const open = new Set(
Array.from(openModelNames, (name) => name.trim().toLowerCase()).filter(Boolean),
);
const modelIds = new Set(models.map((model) => model.id.toLowerCase()));
const openedModels = models.filter((model) => open.has(model.id.toLowerCase()));
const preservedRules = Array.from(currentRules, (rule) => rule.trim().toLowerCase())
.filter(Boolean)
.filter((rule) => {
if (modelIds.has(rule)) return false;
return !openedModels.some((model) => modelMatchesRule(model.id, rule));
});
const closedModels = models
.filter((model) => !open.has(model.id.toLowerCase()))
.map((model) => model.id.toLowerCase());
export const normalizeOAuthExcludedRules = (rules: Iterable<string>): string[] =>
[...new Set(Array.from(rules, (rule) => rule.trim().toLowerCase()).filter(Boolean))];
return [...preservedRules, ...closedModels]
.filter((rule, index, rules) => rules.indexOf(rule) === index)
.sort();
export const oauthModelCandidates = (
models: OAuthModelDefinition[],
rules: Iterable<string>,
): OAuthModelDefinition[] => {
const candidates = new Map(models.map((model) => [model.id.toLowerCase(), model]));
for (const rule of normalizeOAuthExcludedRules(rules)) {
if (!rule.includes('*') && !candidates.has(rule)) candidates.set(rule, { id: rule });
}
return [...candidates.values()].sort((left, right) => left.id.localeCompare(right.id));
};
export const setOAuthModelsExcluded = (
rules: Iterable<string>,
models: OAuthModelDefinition[],
excluded: boolean,
): string[] => {
const normalized = normalizeOAuthExcludedRules(rules);
if (excluded) return normalizeOAuthExcludedRules([...normalized, ...models.map((model) => model.id)]);
const modelIds = new Set(models.map((model) => model.id.toLowerCase()));
return normalized.filter((rule) => rule.includes('*') || !modelIds.has(rule));
};
+99
View File
@@ -3765,6 +3765,105 @@ textarea:focus-visible {
min-width: 112px;
}
.model-discovery-dialog.auth-model-dialog {
grid-template-rows: auto 38px auto minmax(0, 1fr) auto auto;
height: min(720px, calc(100vh - 32px));
min-height: 0;
}
.auth-model-dialog .model-discovery-header {
align-items: flex-start;
}
.auth-model-dialog .model-discovery-header > div {
display: grid;
gap: 5px;
flex: 1;
}
.auth-model-dialog .model-discovery-header span {
white-space: normal;
overflow-wrap: anywhere;
}
.auth-model-dialog .auth-model-target {
color: var(--theme-2d2a26);
font-weight: 600;
}
.auth-model-provider {
display: flex;
align-items: center;
gap: 8px;
}
.auth-model-provider select {
min-width: 0;
max-width: 100%;
padding: 5px 8px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 5px;
background: var(--theme-ffffff);
color: var(--theme-2d2a26);
}
.auth-model-rules {
display: grid;
gap: 5px;
min-width: 0;
font-size: var(--font-size-meta);
}
.auth-model-rules textarea {
width: 100%;
min-height: 64px;
max-height: 150px;
resize: vertical;
padding: 8px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 5px;
background: var(--theme-ffffff);
color: var(--theme-2d2a26);
font: inherit;
}
.auth-model-rules small {
color: var(--theme-7a746c);
line-height: 1.5;
}
.auth-model-dialog .model-discovery-row.rule-blocked {
background: var(--theme-f4f2ec);
cursor: not-allowed;
}
.model-discovery-dialog.auth-model-view-dialog {
grid-template-rows: auto 38px minmax(0, 1fr) auto;
height: min(620px, calc(100vh - 32px));
}
.auth-model-view-row {
width: 100%;
text-align: left;
border: 0;
border-radius: 0;
background: transparent;
color: inherit;
}
@media (max-height: 650px) {
.model-discovery-dialog.auth-model-dialog {
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
grid-template-rows: auto 38px auto minmax(140px, 1fr) auto auto;
}
.model-discovery-dialog.auth-model-view-dialog {
grid-template-rows: auto 38px minmax(140px, 1fr) auto;
}
}
.provider-health-dialog {
width: min(780px, 100%);
height: min(660px, calc(100vh - 32px));
+172
View File
@@ -0,0 +1,172 @@
import { describe, expect, it } from 'bun:test';
import {
authFileExcludedRulesFromPayload,
loadOAuthModelSettings,
saveOAuthModelSettings,
type OAuthModelTarget,
} from '../src/services/oauthModelSettings';
import { openOAuthModelNames, setOAuthModelsExcluded } from '../src/services/oauthModels';
const modelId = 'gpt-5.6-sol';
const models = [{ id: modelId }, { id: 'gpt-5.4' }, { id: 'gpt-image-2' }];
const account = (name: string): OAuthModelTarget => ({
scope: 'credential', provider: 'codex', label: 'Codex', name,
});
const provider: OAuthModelTarget = { scope: 'provider', provider: 'codex', label: 'Codex' };
function createApi() {
const files: Record<string, Record<string, unknown>> = {
'a.json': { type: 'codex', excluded_models: [], access_token: 'original', priority: 3 },
'b.json': { type: 'codex', excluded_models: [] },
};
const globalRules: Record<string, string[]> = { codex: [], claude: ['claude-old-*'] };
const writes: { path: string; body: Record<string, unknown> }[] = [];
const deletes: { path: string; query?: Record<string, string> }[] = [];
const reads: { path: string; query?: Record<string, string> }[] = [];
const api = {
async get(path: string, query?: Record<string, string>): Promise<unknown> {
reads.push({ path, query });
if (path.startsWith('/model-definitions/')) return { models };
if (path === '/oauth-excluded-models') return { 'oauth-excluded-models': structuredClone(globalRules) };
const file = query?.name ? files[query.name] : undefined;
if (path === '/auth-files/download' && file) return structuredClone(file);
if (path === '/auth-files/models' && file) {
const allowed = openOAuthModelNames(models, [...authFileExcludedRulesFromPayload(file), ...globalRules.codex ?? []]);
return { models: models.filter((model) => allowed.has(model.id)) };
}
throw new Error('file not found');
},
async patch(path: string, body: Record<string, unknown>): Promise<unknown> {
writes.push({ path, body });
if (path === '/auth-files/fields') {
const { name, ...fields } = body;
Object.assign(files[String(name)], fields);
} else if (path === '/oauth-excluded-models') {
globalRules[String(body.provider)] = body.models as string[];
} else throw new Error('unexpected write');
return { status: 'ok' };
},
async delete(path: string, options?: { query?: Record<string, string> }): Promise<unknown> {
if (path !== '/oauth-excluded-models' || !options?.query?.provider) throw new Error('unexpected delete');
deletes.push({ path, query: options.query });
delete globalRules[options.query.provider];
return { status: 'ok' };
},
};
return { files, globalRules, writes, deletes, reads, api };
}
describe('OAuth model settings scopes', () => {
it('saves A exclusions without changing B, global rules, or refreshed account fields', async () => {
const { api, files, globalRules, writes, reads } = createApi();
const settings = await loadOAuthModelSettings(account('a.json'), api);
files['a.json'].access_token = 'refreshed-during-edit';
files['a.json'].priority = 9;
await saveOAuthModelSettings(settings, [modelId], api);
expect(files['a.json']).toMatchObject({ excluded_models: [modelId], access_token: 'refreshed-during-edit', priority: 9 });
expect((await loadOAuthModelSettings(account('b.json'), api)).excludedRules).toEqual([]);
expect(globalRules).toEqual({ codex: [], claude: ['claude-old-*'] });
expect(writes).toEqual([{ path: '/auth-files/fields', body: { name: 'a.json', excluded_models: [modelId] } }]);
expect(reads).toContainEqual({ path: '/auth-files/models', query: { name: 'a.json' } });
expect(reads.some(({ path }) => path === '/oauth-excluded-models' || path.startsWith('/model-definitions/'))).toBe(false);
});
it('loads exact account exclusions as checked candidates without copying inherited bans', async () => {
const { api, files, globalRules } = createApi();
globalRules.codex = ['gpt-image-*'];
files['a.json'].excluded_models = ['gpt-5.4'];
const settings = await loadOAuthModelSettings(account('a.json'), api);
expect(settings.models.map((model) => model.id)).toEqual(['gpt-5.4', modelId]);
expect(settings.excludedRules).toEqual(['gpt-5.4']);
await saveOAuthModelSettings(settings, setOAuthModelsExcluded(settings.excludedRules, settings.models, true), api);
expect(files['a.json'].excluded_models).toEqual(['gpt-5.4', modelId]);
expect(globalRules.codex).toEqual(['gpt-image-*']);
globalRules.codex = [];
expect((await api.get('/auth-files/models', { name: 'a.json' }) as { models: typeof models }).models)
.toEqual([{ id: 'gpt-image-2' }]);
});
it('supports explicitly excluding a globally hidden model in account rules', async () => {
const { api, files, globalRules } = createApi();
globalRules.codex = ['gpt-image-*'];
const settings = await loadOAuthModelSettings(account('a.json'), api);
await saveOAuthModelSettings(settings, ['gpt-image-*'], api);
globalRules.codex = [];
expect(files['a.json'].excluded_models).toEqual(['gpt-image-*']);
expect((await api.get('/auth-files/models', { name: 'a.json' }) as { models: typeof models }).models)
.not.toContainEqual({ id: 'gpt-image-2' });
});
it('can remove a wildcard ban and save with no candidate models', async () => {
const { api, files } = createApi();
files['a.json'].excluded_models = ['*'];
const settings = await loadOAuthModelSettings(account('a.json'), api);
expect(settings.models).toEqual([]);
expect(settings.excludedRules).toEqual(['*']);
await saveOAuthModelSettings(settings, [], api);
expect(files['a.json'].excluded_models).toEqual([]);
expect((await loadOAuthModelSettings(account('a.json'), api)).models).toHaveLength(3);
});
it('allows manual rule editing if the model catalog fails to load', async () => {
const { api, files } = createApi();
const originalGet = api.get;
api.get = async (path, query) => {
if (path === '/auth-files/models') throw new Error('catalog unavailable');
return originalGet(path, query);
};
const settings = await loadOAuthModelSettings(account('a.json'), api);
expect(settings.catalogError).toContain('catalog unavailable');
await saveOAuthModelSettings(settings, ['future-*', '*'], api);
expect(files['a.json'].excluded_models).toEqual(['future-*', '*']);
});
it('updates and deletes provider exclusions without touching account files', async () => {
const { api, files, globalRules, writes, deletes, reads } = createApi();
files['a.json'].excluded_models = [modelId];
await saveOAuthModelSettings(await loadOAuthModelSettings(provider, api), ['gpt-image-*'], api);
expect(writes).toEqual([{ path: '/oauth-excluded-models', body: { provider: 'codex', models: ['gpt-image-*'] } }]);
await saveOAuthModelSettings(await loadOAuthModelSettings(provider, api), [], api);
expect(deletes).toEqual([{ path: '/oauth-excluded-models', query: { provider: 'codex' } }]);
expect(globalRules).toEqual({ claude: ['claude-old-*'] });
expect(files['a.json'].excluded_models).toEqual([modelId]);
expect(reads.some(({ path }) => path.startsWith('/auth-files/'))).toBe(false);
await saveOAuthModelSettings(await loadOAuthModelSettings(provider, api), [], api);
expect(deletes).toHaveLength(1);
});
it('does not rewrite unchanged account rules, including legacy metadata', async () => {
const { api, files, writes } = createApi();
delete files['a.json'].excluded_models;
files['a.json']['excluded-models'] = ['future-*'];
const settings = await loadOAuthModelSettings(account('a.json'), api);
await saveOAuthModelSettings(settings, [' FUTURE-* '], api);
expect(writes).toEqual([]);
expect(files['a.json']['excluded-models']).toEqual(['future-*']);
});
it('fails closed on missing or unreadable credential metadata', async () => {
const { api, files, writes } = createApi();
await expect(loadOAuthModelSettings(account('missing.json'), api)).rejects.toThrow('file not found');
files['a.json'].excluded_models = 'gpt-*';
await expect(loadOAuthModelSettings(account('a.json'), api)).rejects.toThrow();
expect(writes).toEqual([]);
});
});
describe('credential model exclusion metadata', () => {
it('supports legacy keys and JSON text, with canonical empty values taking precedence', () => {
expect(authFileExcludedRulesFromPayload(JSON.stringify({ 'excluded-models': [' GPT-* ', 'gpt-*'] }))).toEqual(['gpt-*']);
expect(authFileExcludedRulesFromPayload({ excluded_models: [], 'excluded-models': ['gpt-*'] })).toEqual([]);
expect(authFileExcludedRulesFromPayload({ excluded_models: null, 'excluded-models': ['gpt-*'] })).toEqual([]);
expect(authFileExcludedRulesFromPayload({ type: 'codex' })).toEqual([]);
});
it('rejects malformed metadata without exposing credential contents', () => {
for (const payload of [null, [], 'invalid json', { excluded_models: {} }, { excluded_models: [123] }]) {
expect(() => authFileExcludedRulesFromPayload(payload)).toThrow();
}
expect(() => authFileExcludedRulesFromPayload('{"access_token": "sensitive-token"'))
.toThrow('无法读取凭证文件内容,未加载模型设置');
});
});
+32 -22
View File
@@ -1,9 +1,12 @@
import { describe, expect, it } from 'bun:test';
import {
exclusionsForOpenOAuthModels,
modelMatchesRule,
normalizeOAuthExcludedRules,
oauthExcludedRulesFromPayload,
oauthModelCandidates,
oauthModelsFromPayload,
openOAuthModelNames,
setOAuthModelsExcluded,
} from '../src/services/oauthModels';
const models = [
@@ -12,8 +15,8 @@ const models = [
{ id: 'gpt-image-2', displayName: 'GPT Image 2' },
];
describe('OAuth 开放模型', () => {
it('解析模型定义和提供商排除规则', () => {
describe('OAuth model exclusion rules', () => {
it('parses model definitions and normalizes provider rules without duplicates', () => {
expect(oauthModelsFromPayload({ models: [
{ id: 'gpt-5.4', display_name: 'GPT 5.4' },
{ id: 'GPT-5.4' },
@@ -21,32 +24,39 @@ describe('OAuth 开放模型', () => {
expect(oauthExcludedRulesFromPayload({
'oauth-excluded-models': { codex: [' GPT-IMAGE-* ', 'gpt-image-*'] },
}, 'codex')).toEqual(['gpt-image-*']);
expect(normalizeOAuthExcludedRules([' GPT-* ', '', 'gpt-*', 'future-model']))
.toEqual(['gpt-*', 'future-model']);
});
it('排除规则会让图像模型默认不勾选', () => {
expect(Array.from(openOAuthModelNames(models, ['gpt-image-*']))).toEqual(['gpt-5.4']);
it('treats checked models as exclusions and unchecking removes only their exact rule', () => {
const rules = setOAuthModelsExcluded(['future-*'], [models[0]], true);
expect(rules).toEqual(['future-*', 'gpt-5.4']);
expect([...openOAuthModelNames(models, rules)]).toEqual(['gpt-image-1.5', 'gpt-image-2']);
expect(setOAuthModelsExcluded(rules, [models[0]], false)).toEqual(['future-*']);
});
it('OAuth 没有已保存排除规则时默认全选全部模型', () => {
expect(Array.from(openOAuthModelNames(models, []))).toEqual([
'gpt-5.4',
'gpt-image-1.5',
'gpt-image-2',
]);
it('excludes all candidates without converting the list to a wildcard', () => {
const rules = setOAuthModelsExcluded(['future-*'], models, true);
expect(rules).toEqual(['future-*', 'gpt-5.4', 'gpt-image-1.5', 'gpt-image-2']);
expect(rules).not.toContain('*');
expect(openOAuthModelNames([{ id: 'new-model' }], rules).has('new-model')).toBe(true);
});
it('保存时把未勾选模型转换成 OAuth 排除规则', () => {
expect(exclusionsForOpenOAuthModels([], models, ['gpt-5.4'])).toEqual([
'gpt-image-1.5',
'gpt-image-2',
]);
it('clearing selections preserves wildcard and off-list rules', () => {
expect(setOAuthModelsExcluded(['gpt-image-*', 'gpt-5.4', 'future-model', '*'], models, false))
.toEqual(['gpt-image-*', 'future-model', '*']);
expect(setOAuthModelsExcluded(['gpt-image-*'], [models[1]], false)).toEqual(['gpt-image-*']);
});
it('显式开放模型时移除与其冲突的旧通配符', () => {
expect(exclusionsForOpenOAuthModels(
['gpt-image-*', 'future-*'],
models,
['gpt-5.4', 'gpt-image-2'],
)).toEqual(['future-*', 'gpt-image-1.5']);
it('restores excluded exact models to candidates without expanding wildcard rules', () => {
expect(oauthModelCandidates([models[0]], ['gpt-image-2', 'GPT-5.4', 'future-*']))
.toEqual([models[0], { id: 'gpt-image-2' }]);
});
it('matches CPA wildcard rules case-insensitively and treats regex punctuation literally', () => {
expect(modelMatchesRule('GPT-IMAGE-2', 'gpt-image-*')).toBe(true);
expect(modelMatchesRule('gpt-5x4', 'gpt-5.4')).toBe(false);
expect(openOAuthModelNames([...models, { id: 'future-model' }], ['*']).size).toBe(0);
expect(openOAuthModelNames(models, []).size).toBe(models.length);
});
});