diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index fba254c..a9e323b 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -854,7 +854,7 @@ export const jaOverrides = { 'authFiles.quota.resets': '手動リセット {count} 回', 'authFiles.quota.expiry': '最も早い有効期限 {time}', 'authFiles.models.noneForProvider': 'このプロバイダーには設定可能なモデル定義がありません', - 'authFiles.models.updated': '{provider} 全体のモデル設定を更新しました', + 'authFiles.models.updated': '{provider} の OAuth 認証情報のモデル除外ルールを更新しました', 'authFiles.priority.button': '優先度 {priority}', 'authFiles.priority.title': '認証情報の優先度を変更', 'authFiles.priority.label': '優先度', @@ -901,9 +901,10 @@ export const jaOverrides = { '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.globalButton': 'OAuth 認証情報のモデル除外', + 'authFiles.models.provider': 'OAuth プロバイダー', + 'authFiles.models.globalDescription': 'チェックしたモデルはすべての {provider} OAuth 認証情報ファイルでのみ除外され、API 接続には影響しません。認証情報ごとのルールも適用されます', + 'authFiles.fileOnly': 'この操作は OAuth 認証情報ファイルのみが対象です。API 接続は「API 接続」ページで管理してください。', 'authFiles.models.runtimeUnsupported': '実行時の認証情報のモデルは、対応するプロバイダーまたは認証元で設定してください', 'authFiles.models.invalidMetadata': '認証ファイルを読み取れないため、モデル設定を読み込めませんでした', 'authFiles.models.invalidExclusions': '認証ファイルのモデル除外ルールが無効です。ファイルを確認してください', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 5a68b5a..61effd1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -854,7 +854,7 @@ export const en: Record = { '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': 'Provider-wide models updated for {provider}', + 'authFiles.models.updated': 'OAuth credential model exclusions updated for {provider}', 'authFiles.priority.button': 'Priority {priority}', 'authFiles.priority.title': 'Edit Credential Priority', 'authFiles.priority.label': 'Priority', @@ -901,9 +901,10 @@ export const en: Record = { '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.globalButton': 'OAuth Credential Model Exclusions', + 'authFiles.models.provider': 'OAuth Provider', + 'authFiles.models.globalDescription': 'Checked models are excluded only for all {provider} OAuth credential files, not API Access entries; credential-specific rules still apply', + 'authFiles.fileOnly': 'This action only supports OAuth credential files. Manage API Access entries on the API Access page.', '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', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index fdaf3c7..5fc6db0 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -853,7 +853,7 @@ export const zhCN = { 'authFiles.quota.resets': '主动重置 {count} 次', 'authFiles.quota.expiry': '最早过期 {time}', 'authFiles.models.noneForProvider': '该提供商没有可配置的模型定义', - 'authFiles.models.updated': '{provider} 渠道全局模型已更新', + 'authFiles.models.updated': '{provider} OAuth 凭证模型禁用规则已更新', 'authFiles.priority.button': '优先级 {priority}', 'authFiles.priority.title': '修改凭证优先级', 'authFiles.priority.label': '优先级', @@ -900,9 +900,10 @@ export const zhCN = { '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.globalButton': 'OAuth 凭证模型禁用', + 'authFiles.models.provider': 'OAuth 渠道', + 'authFiles.models.globalDescription': '勾选仅禁用全部 {provider} OAuth 凭证文件的对应模型,不影响 API 接入;凭证自身规则仍然生效', + 'authFiles.fileOnly': '此操作仅支持 OAuth 凭证文件;API 接入请在“API 接入”页面管理。', 'authFiles.models.runtimeUnsupported': '运行时凭据请在对应提供商或认证源中设置模型', 'authFiles.models.invalidMetadata': '无法读取凭证文件内容,未加载模型设置', 'authFiles.models.invalidExclusions': '凭证文件的模型排除规则格式无效,请先检查该文件', diff --git a/src/pages/AuthFileManagementPage.tsx b/src/pages/AuthFileManagementPage.tsx index a397078..f50e2b9 100644 --- a/src/pages/AuthFileManagementPage.tsx +++ b/src/pages/AuthFileManagementPage.tsx @@ -55,8 +55,11 @@ import { import { authFileName, dedupeAuthFiles, + isOAuthCredentialFile, isRuntimeOnlyAuthFile, + oauthModelProvidersFromAuthFiles, parseAuthFilePriority, + setOAuthCredentialFileDisabled, } from '../services/authFiles'; import { modelMatchesRule, @@ -140,12 +143,9 @@ export function AuthFileManagementPage() { 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 oauthModelProviders = useMemo(() => oauthModelProvidersFromAuthFiles(files) + .map((provider) => ({ provider, label: providerName({ provider }) })) + .sort((a, b) => a.label.localeCompare(b.label)), [files]); const loadFiles = useCallback(async (showLoading = true) => { if (showLoading) setLoading(true); @@ -306,15 +306,11 @@ export function AuthFileManagementPage() { }; const toggleStatus = async (file: AuthFile) => { - const name = fileName(file); feedback.clearNotice(); setBusy(true); setError(''); try { - await managementApi.patch('/auth-files/status', { - name, - disabled: !readBoolean(file, 'disabled'), - }); + await setOAuthCredentialFileDisabled(file, !readBoolean(file, 'disabled')); await loadFiles(false); } catch (requestError) { setError(String(requestError)); @@ -447,10 +443,10 @@ export function AuthFileManagementPage() { {note ?
{t('authFiles.settings.note')}

{note}

: null}
- + {providerKey(file) ? : null} - +
diff --git a/src/services/authFiles.ts b/src/services/authFiles.ts index be89124..dd63b4b 100644 --- a/src/services/authFiles.ts +++ b/src/services/authFiles.ts @@ -1,4 +1,4 @@ -import { readBoolean, readString } from './managementApi'; +import { managementApi, readBoolean, readString } from './managementApi'; import { getCurrentLocale, translate } from '../i18n'; export type AuthFileRecord = Record; @@ -10,6 +10,26 @@ export const authFileName = (file: AuthFileRecord) => export const isRuntimeOnlyAuthFile = (file: AuthFileRecord) => readBoolean(file, 'runtime_only', 'runtimeOnly'); +export const isOAuthCredentialFile = (file: AuthFileRecord): boolean => { + if (!readString(file, 'name').toLowerCase().endsWith('.json') || isRuntimeOnlyAuthFile(file)) return false; + const kinds = ['account_type', 'auth_kind', 'authKind'].map((key) => + readString(file, key).toLowerCase().replace(/[-_]/g, '')); + if (kinds.includes('apikey')) return false; + const source = readString(file, 'source').toLowerCase(); + return !source || source === 'file'; +}; + +export const setOAuthCredentialFileDisabled = async ( + file: AuthFileRecord, + disabled: boolean, + api: { patch: (path: string, body: Record) => Promise } = managementApi, +): Promise => { + if (!isOAuthCredentialFile(file)) { + throw new Error(translate(getCurrentLocale(), 'authFiles.fileOnly')); + } + await api.patch('/auth-files/status', { name: readString(file, 'name'), disabled }); +}; + export const parseAuthFilePriority = (value: unknown): number | undefined => { if (typeof value === 'number') { return Number.isSafeInteger(value) ? value : undefined; @@ -31,6 +51,7 @@ const normalizeOAuthProvider = (value: string) => { const provider = value.trim().toLowerCase(); if (provider === 'cognition') return 'devin'; if (provider === 'anthropic') return 'claude'; + if (provider === 'anti-gravity') return 'antigravity'; if (provider === 'openai') return 'codex'; return provider; }; @@ -38,6 +59,9 @@ const normalizeOAuthProvider = (value: string) => { const authFileProvider = (file: AuthFileRecord) => normalizeOAuthProvider(readString(file, 'provider', 'type')); +export const oauthModelProvidersFromAuthFiles = (files: AuthFileRecord[]): string[] => + [...new Set(files.filter(isOAuthCredentialFile).map(authFileProvider).filter(Boolean))].sort(); + export const snapshotAuthFiles = (files: AuthFileRecord[]): AuthFileSnapshot => { const grouped = new Map(); files.forEach((file) => { @@ -99,6 +123,10 @@ const authFilePriority = (file: AuthFileRecord) => { return score; }; +const authFileSourceFields = new Set([ + 'source', 'path', 'runtime_only', 'runtimeOnly', 'account_type', 'auth_kind', 'authKind', +]); + const mergeDuplicateAuthFiles = (entries: AuthFileRecord[]) => { const sorted = [...entries].sort((left, right) => { const priority = authFilePriority(right) - authFilePriority(left); @@ -111,6 +139,7 @@ const mergeDuplicateAuthFiles = (entries: AuthFileRecord[]) => { const merged = { ...sorted[0] }; sorted.slice(1).forEach((entry) => { Object.entries(entry).forEach(([key, value]) => { + if (authFileSourceFields.has(key)) return; if (key === 'cooldowns' && Object.prototype.hasOwnProperty.call(merged, key)) return; if (!hasMeaningfulValue(merged[key]) && hasMeaningfulValue(value)) merged[key] = value; }); diff --git a/tests/authFiles.test.ts b/tests/authFiles.test.ts index c782b1a..cd9c313 100644 --- a/tests/authFiles.test.ts +++ b/tests/authFiles.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from 'bun:test'; import { changedOAuthAuthFileNames, dedupeAuthFiles, + isOAuthCredentialFile, normalizeAuthFilePriorityInput, + oauthModelProvidersFromAuthFiles, parseAuthFilePriority, + setOAuthCredentialFileDisabled, snapshotAuthFiles, } from '../src/services/authFiles'; @@ -14,6 +17,7 @@ describe('认证文件列表规范化', () => { name: 'codex-user.json', provider: 'codex', runtime_only: true, + account_type: 'api_key', auth_index: 'runtime-index', email: 'user@example.com', }, @@ -32,6 +36,73 @@ describe('认证文件列表规范化', () => { expect(files[0].path).toBe('/tmp/codex-user.json'); expect(files[0].email).toBe('user@example.com'); expect(files[0].auth_index).toBe('runtime-index'); + expect(files[0].runtime_only).toBeUndefined(); + expect(files[0].account_type).toBeUndefined(); + expect(isOAuthCredentialFile(files[0])).toBe(true); + }); +}); + +const oauthFile = { name: 'codex-user.json', provider: 'codex', source: 'file', account_type: 'oauth' }; +const nonOAuthFiles = [ + { ...oauthFile, runtime_only: true }, + { ...oauthFile, runtimeOnly: true }, + { ...oauthFile, account_type: 'api_key' }, + { ...oauthFile, account_type: 'API-KEY' }, + { ...oauthFile, auth_kind: 'apikey' }, + { ...oauthFile, authKind: 'api_key' }, + { ...oauthFile, source: 'memory' }, + { ...oauthFile, source: 'config:codex[key]' }, + { ...oauthFile, name: 'codex:apikey:runtime-id' }, + { ...oauthFile, name: '' }, +]; + +describe('OAuth credential file boundaries', () => { + it('accepts disk-backed OAuth files, disabled files, and legacy disk listings', () => { + expect(isOAuthCredentialFile(oauthFile)).toBe(true); + expect(isOAuthCredentialFile({ ...oauthFile, disabled: true })).toBe(true); + expect(isOAuthCredentialFile({ name: 'legacy.JSON', type: 'codex' })).toBe(true); + expect(isOAuthCredentialFile({})).toBe(false); + }); + + it('rejects API-key and runtime records even when they use an OAuth provider or JSON name', () => { + for (const file of nonOAuthFiles) expect(isOAuthCredentialFile(file)).toBe(false); + }); + + it('offers only providers with OAuth credential files, preserving aliases and plugin providers', () => { + expect(oauthModelProvidersFromAuthFiles([ + ...nonOAuthFiles.map((file) => ({ ...file, provider: 'api-only' })), + oauthFile, + { ...oauthFile, name: 'second.json' }, + { name: 'claude.json', type: 'anthropic' }, + { name: 'devin.json', type: 'cognition' }, + { name: 'antigravity.json', type: 'anti-gravity' }, + { name: 'openai.json', type: 'openai' }, + { name: 'plugin.json', provider: 'custom-oauth', source: 'file' }, + { name: 'unknown.json' }, + ])).toEqual(['antigravity', 'claude', 'codex', 'custom-oauth', 'devin']); + expect(oauthModelProvidersFromAuthFiles(nonOAuthFiles)).toEqual([]); + }); + + it('enables and disables only the selected OAuth file without touching API configuration', async () => { + const writes: unknown[] = []; + const api = { patch: async (path: string, body: Record) => { writes.push({ path, body }); } }; + await setOAuthCredentialFileDisabled(oauthFile, true, api); + await setOAuthCredentialFileDisabled({ ...oauthFile, disabled: true }, false, api); + expect(writes).toEqual([ + { path: '/auth-files/status', body: { name: 'codex-user.json', disabled: true } }, + { path: '/auth-files/status', body: { name: 'codex-user.json', disabled: false } }, + ]); + }); + + it('rejects runtime and API-key status changes before any management request', async () => { + const writes: unknown[] = []; + const api = { patch: async (path: string, body: Record) => { writes.push({ path, body }); } }; + for (const file of nonOAuthFiles) { + for (const disabled of [true, false]) { + await expect(setOAuthCredentialFileDisabled(file, disabled, api)).rejects.toThrow('OAuth'); + } + } + expect(writes).toEqual([]); }); }); diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index 2f4c92d..df3f548 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -25,6 +25,17 @@ describe('i18n', () => { expect(Object.values(zhTW).filter((message) => /認證(?:檔案|文件)/.test(message))).toEqual([]); }); + it('makes the OAuth-only model exclusion scope explicit in every locale', () => { + for (const locale of supportedLocales) { + const description = translate(locale, 'authFiles.models.globalDescription', { provider: 'Codex' }); + expect(description).toContain('Codex'); + expect(description).toContain('OAuth'); + expect(description).toContain('API'); + expect(translate(locale, 'authFiles.fileOnly')).toContain('API'); + } + expect(translate('zh-CN', 'authFiles.models.globalDescription', { provider: 'Codex' })).toContain('不影响 API 接入'); + }); + it('normalizes English variants and keeps Chinese as the fallback', () => { expect(normalizeLocale('en-US')).toBe('en'); expect(normalizeLocale('en')).toBe('en'); diff --git a/tests/oauthModelSettings.test.ts b/tests/oauthModelSettings.test.ts index 7edc89c..311f91e 100644 --- a/tests/oauthModelSettings.test.ts +++ b/tests/oauthModelSettings.test.ts @@ -135,6 +135,25 @@ describe('OAuth model settings scopes', () => { expect(deletes).toHaveLength(1); }); + it('uses only the OAuth exclusion endpoint even when excluding every model for a provider', async () => { + const { api, files, globalRules, writes, deletes, reads } = createApi(); + const beforeFiles = structuredClone(files); + await saveOAuthModelSettings(await loadOAuthModelSettings(provider, api), ['*'], api); + expect(reads).toEqual([ + { path: '/model-definitions/codex', query: undefined }, + { path: '/oauth-excluded-models', query: undefined }, + ]); + expect(writes).toEqual([ + { path: '/oauth-excluded-models', body: { provider: 'codex', models: ['*'] } }, + ]); + expect(globalRules).toEqual({ codex: ['*'], claude: ['claude-old-*'] }); + expect(files).toEqual(beforeFiles); + await saveOAuthModelSettings(await loadOAuthModelSettings(provider, api), [], api); + expect(deletes).toEqual([{ path: '/oauth-excluded-models', query: { provider: 'codex' } }]); + expect(writes).toHaveLength(1); + expect(files).toEqual(beforeFiles); + }); + it('does not rewrite unchanged account rules, including legacy metadata', async () => { const { api, files, writes } = createApi(); delete files['a.json'].excluded_models; diff --git a/tests/operationFeedback.test.ts b/tests/operationFeedback.test.ts index 434ab7e..03b8fee 100644 --- a/tests/operationFeedback.test.ts +++ b/tests/operationFeedback.test.ts @@ -58,7 +58,7 @@ describe('控件自身反馈', () => { expect(attributes?.find((attribute) => attribute.name.getText(source) === 'className')?.initializer?.getText(source)) .toBe("{`${disabled ? 'primary-button' : 'secondary-button'} compact-button auth-card-toggle`}"); expect(attributes?.find((attribute) => attribute.name.getText(source) === 'disabled')?.initializer?.getText(source)) - .toBe('{busy}'); + .toBe('{busy || !isOAuthCredentialFile(file)}'); const styles = readFileSync(new URL('../src/styles.css', import.meta.url), 'utf8'); expect(styles).toMatch(/\.real-auth-file-row\.disabled\s*>\s*:not\(\.auth-file-actions\)\s*\{\s*opacity:\s*0\.68;/); expect(styles).not.toMatch(/\.real-auth-file-row\.disabled\s*\{[^}]*opacity:/);