feat(api-access): support native CPA v8 groups and fix alias source validation

This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent 6dc8650e68
commit 84fe2be8ac
22 files changed
+1136 -95

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useI18n } from '../i18n';
import type { MessageKey } from '../i18n/resources';
import { isRecord, maskSecret, readString } from '../services/managementApi';
import { effectiveProviderKey, providerKeyDraft, providerKeyOverrides, type ProviderKeyDraft } from '../services/providerGroups';
import '../styles/provider-groups.css';
type Props = {
keys: ProviderKeyDraft[];
shared: Record<string, unknown>;
section: string;
disabled: boolean;
onChange: (keys: ProviderKeyDraft[]) => void;
};
export function ProviderGroupKeysEditor({ keys, shared, section, disabled, onChange }: Props) {
const { t } = useI18n();
const [visible, setVisible] = useState<Record<string, boolean>>({});
const update = (id: string, patch: (draft: ProviderKeyDraft) => ProviderKeyDraft) =>
onChange(keys.map((draft) => draft.id === id ? patch(draft) : draft));
const set = (id: string, field: string, value: unknown) => update(id, (draft) => {
const next = { ...draft.value };
if (value === undefined) delete next[field];
else next[field] = value;
const text = { ...draft.text };
delete text[field];
return { ...draft, value: next, text };
});
const text = (id: string, field: string, value: string) => update(id, (draft) => ({ ...draft, text: { ...draft.text, [field]: value } }));
const fields: { field: string; label: MessageKey; kind: 'number' | 'text' | 'boolean' | 'lines'; initial: unknown }[] = [
{ field: 'priority', label: 'apiAccess.field.priority', kind: 'number', initial: 0 },
{ field: 'proxy-url', label: 'apiAccess.field.proxyUrl', kind: 'text', initial: '' },
{ field: 'prefix', label: 'apiAccess.field.prefix', kind: 'text', initial: '' },
{ field: 'request-retry', label: 'apiAccess.groups.retry', kind: 'number', initial: 0 },
{ field: 'disable-cooling', label: 'apiAccess.cooling.title', kind: 'boolean', initial: false },
{ field: 'headers', label: 'apiAccess.field.headers', kind: 'lines', initial: {} },
{ field: 'excluded-models', label: 'apiAccess.field.excludedModels', kind: 'lines', initial: [] },
];
return <section className="provider-group-keys" aria-label={t('apiAccess.groups.keys')}>
<div className="provider-group-heading"><div><strong>{t('apiAccess.groups.keys')}</strong><p>{t('apiAccess.groups.keysHint')}</p></div>
<button type="button" className="secondary-button compact-button" disabled={disabled}
onClick={() => onChange([...keys, providerKeyDraft({ 'api-key': '' })])}><Plus size={14} />{t('apiAccess.groups.addKey')}</button>
</div>
{!keys.length ? <p className="provider-group-hint">{t('apiAccess.groups.emptyKeys')}</p> : null}
{keys.map((draft, index) => {
const key = draft.value;
const overrideCount = providerKeyOverrides(key).length;
const effective = effectiveProviderKey(shared, key);
return <fieldset className="provider-group-key" key={draft.id} disabled={disabled}>
<legend>{t('apiAccess.groups.keyNumber', { number: index + 1 })}</legend>
<div className="provider-group-key-main">
<label><span>{t('apiAccess.field.key')}</span><input type={visible[draft.id] ? 'text' : 'password'} autoComplete="off" spellCheck={false}
aria-label={t('apiAccess.groups.keyNumber', { number: index + 1 })} value={readString(key, 'api-key')}
onChange={(event) => set(draft.id, 'api-key', event.currentTarget.value)} placeholder="sk-..." /></label>
<button type="button" className="secondary-button compact-button" aria-pressed={Boolean(visible[draft.id])}
onClick={() => setVisible((state) => ({ ...state, [draft.id]: !state[draft.id] }))}>{t('apiAccess.groups.showKey')}</button>
<button type="button" className="icon-button quiet danger" aria-label={t('apiAccess.groups.removeKey', { number: index + 1 })}
onClick={() => onChange(keys.filter((item) => item.id !== draft.id))}><Trash2 size={15} /></button>
</div>
<details className="provider-key-settings">
<summary>{t('apiAccess.groups.keySettings')} · {overrideCount ? t('apiAccess.groups.overrides', { count: overrideCount }) : t('apiAccess.groups.inherits')}</summary>
<label><span>{t('apiAccess.groups.weight')}</span><input type="number" min="0" max="1000000" step="1" value={key.weight == null ? '' : String(key.weight)}
placeholder="1" onChange={(event) => set(draft.id, 'weight', event.currentTarget.value === '' ? undefined : Number(event.currentTarget.value))} /></label>
<p className="provider-group-hint">{t('apiAccess.groups.overrideHint')}</p>
{fields.map(({ field, label, kind, initial }) => {
const overridden = key[field] != null;
const content = draft.text?.[field] ?? (field === 'headers' && isRecord(key[field])
? Object.entries(key[field]).map(([name, value]) => `${name}: ${value}`).join('\n')
: Array.isArray(key[field]) ? key[field].join('\n') : String(key[field] ?? ''));
return <div className="provider-key-override" key={field}>
<label className="provider-key-override-toggle"><input type="checkbox" checked={overridden}
onChange={(event) => set(draft.id, field, event.currentTarget.checked ? structuredClone(effective[field] ?? initial) : undefined)} />
<span>{t('apiAccess.groups.overrideField', { field: t(label) })}</span></label>
{overridden ? kind === 'lines' ? <textarea aria-label={t(label)} rows={3} value={content} onChange={(event) => text(draft.id, field, event.currentTarget.value)} />
: kind === 'boolean' ? <select aria-label={t(label)} value={String(key[field])} onChange={(event) => set(draft.id, field, event.currentTarget.value === 'true')}>
<option value="false">{t('apiAccess.cooling.enable')}</option><option value="true">{t('apiAccess.cooling.disable')}</option></select>
: <input aria-label={t(label)} type={kind} step={kind === 'number' ? '1' : undefined} value={String(key[field] ?? '')}
onChange={(event) => set(draft.id, field, kind === 'number' ? Number(event.currentTarget.value) : event.currentTarget.value)} /> : null}
</div>;
})}
<div className="provider-key-override">
<label className="provider-key-override-toggle"><input type="checkbox" checked={key.models != null}
onChange={(event) => set(draft.id, 'models', event.currentTarget.checked ? structuredClone(effective.models ?? []) : undefined)} />
<span>{t('apiAccess.groups.overrideModels')}</span></label>
{Array.isArray(key.models) ? <div className="model-config-list">
{key.models.map((raw, modelIndex) => {
const model = isRecord(raw) ? raw : { name: String(raw) };
const change = (patch: Record<string, string>) => set(draft.id, 'models', (key.models as unknown[]).map((item, i) => i === modelIndex ? { ...model, ...patch } : item));
return <div className="model-config-entry" key={modelIndex}>
<input aria-label={t('apiAccess.models.namePlaceholder')} value={readString(model, 'name')} onChange={(event) => change({ name: event.currentTarget.value })} />
<input aria-label={t('apiAccess.models.aliasPlaceholder')} value={readString(model, 'alias')} onChange={(event) => change({ alias: event.currentTarget.value })} />
<button type="button" className="icon-button quiet danger" aria-label={t('apiAccess.models.remove')} onClick={() => set(draft.id, 'models', (key.models as unknown[]).filter((_, i) => i !== modelIndex))}><Trash2 size={14} /></button>
</div>;
})}
<button type="button" className="secondary-button compact-button" onClick={() => set(draft.id, 'models', [...key.models as unknown[], { name: '', alias: '' }])}><Plus size={14} />{t('apiAccess.models.add')}</button>
</div> : null}
</div>
{section === 'codex-api-key' ? <label><span>WebSocket</span><select value={key.websockets == null ? '' : String(key.websockets)} onChange={(event) => set(draft.id, 'websockets', event.currentTarget.value === '' ? undefined : event.currentTarget.value === 'true')}>
<option value="">{t('apiAccess.option.inherit')}</option><option value="true">{t('common.enabled')}</option><option value="false">{t('common.disabled')}</option>
</select></label> : null}
{section === 'claude-api-key' ? <div className="provider-cloak-settings">
<label><span>{t('apiAccess.cloak.mode')}</span><select value={isRecord(key.cloak) ? readString(key.cloak, 'mode') : ''} onChange={(event) => {
const cloak = isRecord(key.cloak) ? { ...key.cloak } : {};
if (event.currentTarget.value) cloak.mode = event.currentTarget.value; else delete cloak.mode;
set(draft.id, 'cloak', Object.keys(cloak).length ? cloak : undefined);
}}><option value="">{t('apiAccess.cloak.default')}</option><option value="auto">{t('apiAccess.cloak.auto')}</option><option value="always">{t('apiAccess.cloak.always')}</option><option value="never">{t('apiAccess.cloak.never')}</option></select></label>
<label className="multiline-field"><span>{t('apiAccess.cloak.words')}</span>
<textarea rows={3} value={draft.text?.['cloak-words'] ?? (isRecord(key.cloak) && Array.isArray(key.cloak['sensitive-words']) ? key.cloak['sensitive-words'].join('\n') : '')}
onChange={(event) => text(draft.id, 'cloak-words', event.currentTarget.value)} /></label>
{(['strict-mode', 'cache-user-id'] as const).map((field) => <label key={field}>
<span>{t(field === 'strict-mode' ? 'apiAccess.cloak.strict' : 'apiAccess.cloak.cacheUser')}</span>
<select value={isRecord(key.cloak) && key.cloak[field] != null ? String(key.cloak[field]) : ''} onChange={(event) => {
const cloak = isRecord(key.cloak) ? { ...key.cloak } : {};
if (event.currentTarget.value === '') delete cloak[field]; else cloak[field] = event.currentTarget.value === 'true';
set(draft.id, 'cloak', Object.keys(cloak).length ? cloak : undefined);
}}><option value="">{t('apiAccess.option.inherit')}</option><option value="true">{t('common.enabled')}</option><option value="false">{t('common.disabled')}</option></select>
</label>)}
</div> : null}
</details>
<span className="provider-group-key-state">{maskSecret(readString(key, 'api-key'))}</span>
</fieldset>;
})}
</section>;
}
+29
View File
@@ -2,6 +2,35 @@ import type { MessageKey } from './locales/zh-CN';
import { easyModeJa } from './easyMode';
export const jaOverrides = {
"apiAccess.groups.summary": "上流グループ {count} 件",
"apiAccess.groups.add": "グループを追加",
"apiAccess.groups.edit": "グループを編集",
"apiAccess.groups.name": "グループ名",
"apiAccess.groups.namePlaceholder": "例:メイン、バックアップ",
"apiAccess.groups.nameRequired": "制御文字を含まない1〜80文字のグループ名を入力してください。",
"apiAccess.groups.description": "グループ内のキーは同じ上流URL、モデル、接続設定を共有します。",
"apiAccess.groups.keys": "グループのキー",
"apiAccess.groups.keysHint": "各キーは共有設定を継承するか、個別に上書きできます。",
"apiAccess.groups.addKey": "キーを追加",
"apiAccess.groups.emptyKeys": "このグループにキーはありません。",
"apiAccess.groups.keyNumber": "キー {number}",
"apiAccess.groups.removeKey": "キー {number} を削除",
"apiAccess.groups.showKey": "キーを表示",
"apiAccess.groups.keySettings": "キー設定",
"apiAccess.groups.overrides": "個別設定 {count} 件",
"apiAccess.groups.inherits": "共有設定を継承",
"apiAccess.groups.overrideHint": "チェックで個別設定、解除で継承に戻ります。リストとヘッダーは全体を置換し、空欄は消去します。",
"apiAccess.groups.overrideField": "個別設定:{field}",
"apiAccess.groups.overrideModels": "個別設定:モデルとエイリアス",
"apiAccess.groups.weight": "ラウンドロビン重み(既定1)",
"apiAccess.groups.retry": "再試行回数",
"apiAccess.groups.invalidKey": "キー設定が無効です:{field}。キーと整数の範囲を確認してください。",
"apiAccess.groups.discoveryKey": "モデル取得に使用するキー",
"apiAccess.groups.sharedHint": "以下のモデルと詳細設定は共有されます。キーの個別設定が優先されます。",
"apiAccess.groups.sharedModels": "共有モデル",
"apiAccess.groups.sharedToggleHint": "共有モデルの有効状態を切り替えます。キー固有の除外設定が優先されます。",
"apiAccess.groups.partial": "一部のキーが無効",
"apiAccess.groups.healthKey": "検査するキー",
...easyModeJa,
'authFiles.settings.noModels': '該当するモデルはありません。上の欄で除外ルールを入力できます。',
'authFiles.settings.button': "設定",
+29
View File
@@ -2,6 +2,35 @@ import type { MessageKey } from './zh-CN';
import { easyModeEn } from '../easyMode';
export const en: Record<MessageKey, string> = {
"apiAccess.groups.summary": "{count} upstream groups",
"apiAccess.groups.add": "Add Group",
"apiAccess.groups.edit": "Edit Group",
"apiAccess.groups.name": "Group Name",
"apiAccess.groups.namePlaceholder": "For example: Primary gateway",
"apiAccess.groups.nameRequired": "Enter a group name of 1–80 characters without control characters.",
"apiAccess.groups.description": "One group connects to one upstream endpoint. Its keys share models and connection settings.",
"apiAccess.groups.keys": "Group Keys",
"apiAccess.groups.keysHint": "Each key can inherit shared settings or override them individually.",
"apiAccess.groups.addKey": "Add Key",
"apiAccess.groups.emptyKeys": "This group has no keys.",
"apiAccess.groups.keyNumber": "Key {number}",
"apiAccess.groups.removeKey": "Remove Key {number}",
"apiAccess.groups.showKey": "Show Key",
"apiAccess.groups.keySettings": "Key Settings",
"apiAccess.groups.overrides": "{count} individual settings",
"apiAccess.groups.inherits": "Inherits shared settings",
"apiAccess.groups.overrideHint": "Check a setting to override it; uncheck to inherit. Lists and headers replace the shared value entirely; an empty override clears it.",
"apiAccess.groups.overrideField": "Override: {field}",
"apiAccess.groups.overrideModels": "Override: Models and Aliases",
"apiAccess.groups.weight": "Round-robin Weight (default 1)",
"apiAccess.groups.retry": "Retry Count",
"apiAccess.groups.invalidKey": "Invalid key setting: {field}. Enter a key and check integer limits.",
"apiAccess.groups.discoveryKey": "Key for Model Discovery",
"apiAccess.groups.sharedHint": "Models and advanced settings below are shared. Individual key overrides take precedence.",
"apiAccess.groups.sharedModels": "Shared Models",
"apiAccess.groups.sharedToggleHint": "Controls shared model availability. Individual key exclusion overrides still take precedence.",
"apiAccess.groups.partial": "Some keys disabled",
"apiAccess.groups.healthKey": "Key to Check",
...easyModeEn,
'authFiles.settings.noModels': 'No matching models. Enter exclusion rules above.',
'authFiles.settings.button': "Settings",
+29
View File
@@ -1,6 +1,35 @@
import { easyModeZhCN } from '../easyMode';
export const zhCN = {
"apiAccess.groups.summary": "{count} 个上游分组",
"apiAccess.groups.add": "新增分组",
"apiAccess.groups.edit": "编辑分组",
"apiAccess.groups.name": "分组名称",
"apiAccess.groups.namePlaceholder": "例如:主站、备用站",
"apiAccess.groups.nameRequired": "请填写 1–80 个字符的分组名称,不含控制字符。",
"apiAccess.groups.description": "一个分组连接一个上游地址,组内密钥共享模型和连接设置。",
"apiAccess.groups.keys": "组内密钥",
"apiAccess.groups.keysHint": "每个密钥可继承共享设置,也可单独覆盖。",
"apiAccess.groups.addKey": "添加密钥",
"apiAccess.groups.emptyKeys": "此分组没有密钥。",
"apiAccess.groups.keyNumber": "密钥 {number}",
"apiAccess.groups.removeKey": "移除密钥 {number}",
"apiAccess.groups.showKey": "显示密钥",
"apiAccess.groups.keySettings": "密钥设置",
"apiAccess.groups.overrides": "{count} 项独立设置",
"apiAccess.groups.inherits": "继承共享设置",
"apiAccess.groups.overrideHint": "勾选后单独设置;取消勾选恢复继承。列表和请求头会整体替换共享值,留空表示清空。",
"apiAccess.groups.overrideField": "单独设置:{field}",
"apiAccess.groups.overrideModels": "单独设置:模型与别名",
"apiAccess.groups.weight": "轮询权重(默认 1)",
"apiAccess.groups.retry": "重试次数",
"apiAccess.groups.invalidKey": "密钥配置无效:{field}。请填写密钥,并检查整数范围。",
"apiAccess.groups.discoveryKey": "用于获取模型的密钥",
"apiAccess.groups.sharedHint": "以下模型和高级设置由组内密钥共享;密钥已有的独立设置优先。",
"apiAccess.groups.sharedModels": "共享模型",
"apiAccess.groups.sharedToggleHint": "控制组内共享模型的启用状态。密钥的独立模型排除设置仍然优先。",
"apiAccess.groups.partial": "部分密钥已停用",
"apiAccess.groups.healthKey": "检测密钥",
...easyModeZhCN,
'authFiles.settings.noModels': '没有匹配的候选模型,可在上方手动填写排除规则。',
'authFiles.settings.button': "设置",
+175 -70
View File
@@ -53,7 +53,6 @@ import {
readBoolean,
readNumber,
readString,
responseList,
} from '../services/managementApi';
import {
DEEPSEEK_BASE_URL,
@@ -77,6 +76,12 @@ import {
} from '../services/providerHealthCheck';
import { modelMatchesRule } from '../services/oauthModels';
import { normalizeProviderProxyUrl } from '../services/providerProxy';
import { ProviderGroupKeysEditor } from '../components/ProviderGroupKeysEditor';
import {
cleanProviderGroup, effectiveProviderKey, providerGroupIdentity, providerGroupKeys, providerGroupStatus,
providerKeyDraft, serializeProviderKey, validateProviderGroupKeys, type ProviderKeyDraft,
} from '../services/providerGroups';
import { providerGroupsApi } from '../services/managementApi';
import { getCurrentLocale, translate, useI18n } from '../i18n';
import type { MessageKey } from '../i18n/resources';
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
@@ -207,6 +212,7 @@ const requestErrorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
export type ProviderDraft = {
groupKeys?: ProviderKeyDraft[];
name: string;
apiKey: string;
remark: string;
@@ -303,7 +309,8 @@ const rowFromRecord = (
record: Record<string, unknown>,
index: number,
): ProviderRow => {
const entries = definitionFor(section).openAi && Array.isArray(record['api-key-entries'])
const grouped = Array.isArray(record.keys);
const entries = grouped ? providerGroupKeys(record) : definitionFor(section).openAi && Array.isArray(record['api-key-entries'])
? record['api-key-entries'].filter(isRecord)
: [];
const entry = entries[0] ?? null;
@@ -318,14 +325,15 @@ const rowFromRecord = (
section,
index,
record,
name: definitionFor(section).openAi
name: grouped || definitionFor(section).openAi
? readString(record, 'name') || translate(getCurrentLocale(), 'apiAccess.compatibleName', { number: index + 1 })
: translate(getCurrentLocale(), definitionFor(section).labelKey),
apiKey: entry ? readString(entry, 'api-key', 'apiKey') : singleApiKey,
apiKeys: entry ? apiKeys : singleApiKey ? [singleApiKey] : [],
baseUrl: readString(record, 'base-url', 'baseUrl'),
models: modelsFromRecord(record.models),
disabled: definitionFor(section).openAi
models: grouped && Array.isArray(record.models)
? record.models.flatMap((model) => modelsFromRecord([model])) : modelsFromRecord(record.models),
disabled: grouped ? providerGroupStatus(record) === 'disabled' : definitionFor(section).openAi
? readBoolean(record, 'disabled')
: excludedModels.some((model) => model.trim() === '*'),
priority: readNumber(record, 'priority'),
@@ -384,6 +392,7 @@ const providerHeadersFromRecord = (record: Record<string, unknown>) =>
: {};
export const stripResponseFields = (record: Record<string, unknown>) => {
if (Array.isArray(record.keys)) return cleanProviderGroup(record);
const next = normalizeProviderModels(record);
delete next['test-model'];
delete next.testModel;
@@ -418,6 +427,7 @@ const normalizeProviderIdentity = (value: unknown): unknown => {
};
const providerConfigIdentity = (record: Record<string, unknown>) => {
if (Array.isArray(record.keys)) return providerGroupIdentity(record);
const config = stripResponseFields(record);
if (config.priority === 0) delete config.priority;
if (config.websockets === false) delete config.websockets;
@@ -427,7 +437,7 @@ const providerConfigIdentity = (record: Record<string, unknown>) => {
const providerRemarkConfigIdentity = (record: Record<string, unknown>) => {
const config = stripResponseFields(record);
for (const key of ['name', 'api-key', 'apiKey', 'api-key-entries', 'base-url', 'baseUrl', 'disabled']) {
for (const key of ['name', 'api-key', 'apiKey', 'api-key-entries', 'keys', 'base-url', 'baseUrl', 'disabled']) {
delete config[key];
}
if (Array.isArray(config['excluded-models'])) {
@@ -442,6 +452,7 @@ export const hasDuplicateProviderRecord = (
candidates: Record<string, unknown>[],
targetIndex = -1,
) => records.some((record, index) => index !== targetIndex && candidates.some((candidate) => {
if (Array.isArray(candidate.keys)) return readString(record, 'name') === readString(candidate, 'name');
if (definitionFor(section).openAi) return readString(record, 'name') === readString(candidate, 'name');
if (section === 'gemini-api-key') {
return readString(record, 'api-key', 'apiKey') === readString(candidate, 'api-key', 'apiKey')
@@ -614,6 +625,7 @@ export const providerProxyDraftFromRecord = (
section: ProviderSection,
record: Record<string, unknown>,
): Pick<ProviderDraft, 'proxyUrl' | 'proxyUrlEdited' | 'proxyUrlMixed'> => {
if (Array.isArray(record.keys)) return { proxyUrl: readString(record, 'proxy-url'), proxyUrlEdited: false, proxyUrlMixed: false };
const entries = definitionFor(section).openAi && Array.isArray(record['api-key-entries'])
? record['api-key-entries'].filter(isRecord)
: [];
@@ -632,9 +644,10 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => {
const definition = definitionFor(row.section);
const isDeepSeek = row.section === 'codex-api-key' && isDeepSeekRecord(row.record);
return {
name: isDeepSeek ? 'DeepSeek' : row.name,
...(Array.isArray(row.record.keys) ? { groupKeys: providerGroupKeys(row.record).map(providerKeyDraft) } : {}),
name: Array.isArray(row.record.keys) ? row.name : isDeepSeek ? 'DeepSeek' : row.name,
apiKey: definition.openAi ? row.apiKeys.join('\n') : row.apiKey,
remark: row.remark || (definition.openAi && !isDeepSeek ? row.name : ''),
remark: row.remark || (!Array.isArray(row.record.keys) && definition.openAi && !isDeepSeek ? row.name : ''),
baseUrl: row.baseUrl,
...providerProxyDraftFromRecord(row.section, row.record),
priority: row.priority === null ? '' : String(row.priority),
@@ -668,6 +681,9 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => {
};
};
export const providerDraftFromRecord = (section: ProviderSection, record: Record<string, unknown>) =>
draftFromRow(rowFromRecord(section, record, 0));
const emptyProviderDraft = (): ProviderDraft => ({
name: '',
apiKey: '',
@@ -703,7 +719,7 @@ export const createProviderDraft = (category: ProviderCategory): ProviderDraft =
export const applyProviderRemarkIdentity = (
category: ProviderCategory,
draft: ProviderDraft,
): ProviderDraft => category === 'deepseek'
): ProviderDraft => draft.groupKeys ? draft : category === 'deepseek'
? { ...draft, name: draft.name.trim() || 'DeepSeek' }
: definitionFor(category).openAi
? { ...draft, name: draft.remark.trim() }
@@ -894,10 +910,66 @@ export const buildProviderRecord = (
return applyAdvancedFields(next, section, draft);
};
export const buildProviderGroupRecord = (
section: ProviderSection, draft: ProviderDraft, current?: Record<string, unknown>,
): Record<string, unknown> => {
const sharedDraft = { ...draft, apiKey: '', proxyUrlEdited: true,
websockets: undefined, cloakMode: undefined, cloakStrictMode: undefined,
cloakSensitiveWordsText: undefined, cloakCacheUserId: undefined,
disabled: Array.isArray(current?.['excluded-models']) && current['excluded-models'].includes('*'),
};
const next = buildProviderRecord(section, sharedDraft, current);
// Native groups expose every mapping, including multiple aliases of the same
// upstream ID. Match each row independently so edits/removals are explicit.
const existing = Array.isArray(current?.models) ? current.models : [];
const used = new Set<number>();
next.models = draft.models.filter((model) => model.name.trim()).flatMap((model) => {
let index = existing.findIndex((item, index) => !used.has(index) && isRecord(item)
&& readString(item, 'name') === model.name && readString(item, 'alias') === (model.alias ?? ''));
if (index < 0) index = existing.findIndex((item, index) => !used.has(index) && isRecord(item) && readString(item, 'name') === model.name);
if (index >= 0) used.add(index);
return mergeModelRecords(index >= 0 ? [existing[index]] : [], [model]);
});
if (section === 'openai-compatibility' && draft.thinkingLevelsEdited) {
for (const model of next.models as Record<string, unknown>[]) {
const thinking = isRecord(model.thinking) ? { ...model.thinking } : {};
if (draft.thinkingLevels?.length) thinking.levels = draft.thinkingLevels;
else delete thinking.levels;
if (Object.keys(thinking).length) model.thinking = thinking; else delete model.thinking;
}
}
applyAdvancedFields(next, section, sharedDraft);
delete next['api-key'];
delete next['api-key-entries'];
next.name = draft.name.trim();
if (draft.proxyUrl?.trim()) next['proxy-url'] = draft.proxyUrl.trim();
else delete next['proxy-url'];
next.keys = (draft.groupKeys ?? []).map(serializeProviderKey);
// Preserve absent/null/empty fields when the corresponding shared setting was
// not edited. In v8 these values can have different inheritance semantics.
if (current) {
const baseline = draftFromRow(rowFromRecord(section, current, 0));
const fields: [keyof ProviderDraft, string][] = [
['baseUrl', 'base-url'], ['proxyUrl', 'proxy-url'], ['priority', 'priority'],
['prefix', 'prefix'], ['headersText', 'headers'], ['excludedModelsText', 'excluded-models'],
['disableCooling', 'disable-cooling'], ['models', 'models'],
];
for (const [field, configField] of fields) {
if (field === 'models' && draft.thinkingLevelsEdited) continue;
if (field === 'excludedModelsText' && draft.modelSelectionCatalog) continue;
if (JSON.stringify(draft[field]) !== JSON.stringify(baseline[field])) continue;
if (Object.prototype.hasOwnProperty.call(current, configField)) next[configField] = structuredClone(current[configField]);
else delete next[configField];
}
}
return next;
};
const providerIdentityMatches = (
row: ProviderRecordIdentity,
record: Record<string, unknown>,
) => {
if (Array.isArray(record.keys)) return readString(record, 'name') === row.name;
if (definitionFor(row.section).openAi) {
return readString(record, 'name') === row.name;
}
@@ -915,7 +987,7 @@ export type ProviderRecordIdentity = Pick<
const providerPrimaryIdentityMatches = (
row: ProviderRecordIdentity,
record: Record<string, unknown>,
) => definitionFor(row.section).openAi
) => Array.isArray(record.keys) || definitionFor(row.section).openAi
? readString(record, 'name') === row.name
: readString(record, 'api-key', 'apiKey') === row.apiKey;
@@ -1038,10 +1110,7 @@ export function ApiAccessPage() {
const responses = await Promise.allSettled(
providerLoadDefinitions.map(async (definition) => ({
section: definition.section,
records: responseList(
await managementApi.get(`/${definition.section}`),
definition.responseKey,
),
records: await providerGroupsApi.get(definition.section),
})),
);
const failures: string[] = [];
@@ -1104,9 +1173,6 @@ export function ApiAccessPage() {
.map((record, index) => rowFromRecord(activeSection, record, index))
.map((row) => ({
...row,
name: activeCategory === 'deepseek'
? t('apiAccess.provider.deepseek')
: row.name,
remark: apiAccessRemarks[
providerRemarkIdentity(row.section, apiAccessRemarkLocatorFromRow(row))
] ?? '',
@@ -1115,7 +1181,7 @@ export function ApiAccessPage() {
.filter((row) => {
const query = filter.trim().toLowerCase();
if (!query) return true;
return [row.remark || row.name, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')]
return [row.remark, row.name, ...row.apiKeys, row.baseUrl, row.models.map(modelSearchText).join(' ')]
.join(' ')
.toLowerCase()
.includes(query);
@@ -1127,7 +1193,7 @@ export function ApiAccessPage() {
feedback.clearNotice();
setError('');
setEditingRow(null);
setDialogDraft(createProviderDraft(activeCategory));
setDialogDraft({ ...createProviderDraft(activeCategory), groupKeys: [providerKeyDraft({ 'api-key': '' })] });
setDialogOpen(true);
};
@@ -1154,11 +1220,14 @@ export function ApiAccessPage() {
models: preparedDraft.models.filter((model) => model.name.trim()),
};
const baseUrlRequired = definition.openAi || definition.section === 'codex-api-key';
const remarkRequired = definition.openAi && activeCategory !== 'deepseek';
const parsedApiKeys = parseProviderApiKeys(preparedDraft.apiKey);
const parsedApiKeys = (preparedDraft.groupKeys ?? []).map(({ value }) => readString(value, 'api-key').trim());
if (!preparedDraft.name.trim() || preparedDraft.name.trim().length > 80 || /[\u0000-\u001f\u007f]/.test(preparedDraft.name)) {
return { saved: false, target: 'form', error: t('apiAccess.groups.nameRequired') };
}
const invalidKey = validateProviderGroupKeys(preparedDraft.groupKeys ?? []);
if (invalidKey) return { saved: false, target: 'form', error: t('apiAccess.groups.invalidKey', { field: invalidKey }) };
if (
parsedApiKeys.length === 0
|| (remarkRequired && !preparedDraft.remark.trim())
(parsedApiKeys.length === 0 && !editingRow && !definition.openAi)
|| (baseUrlRequired && !preparedDraft.baseUrl.trim())
) {
return {
@@ -1208,7 +1277,7 @@ export function ApiAccessPage() {
if (
definition.openAi
&& activeCategory !== 'deepseek'
&& draftToSave.models.length === 0
&& !editingRow && draftToSave.models.length === 0
) {
let fetchedModels: ModelOption[];
try {
@@ -1216,8 +1285,10 @@ export function ApiAccessPage() {
'openai',
baseUrl,
parsedApiKeys[0],
editingRow?.authIndex,
providerHeaders,
undefined,
preparedDraft.groupKeys?.[0]
? providerHeadersFromRecord(effectiveProviderKey({ headers: providerHeaders }, serializeProviderKey(preparedDraft.groupKeys[0])))
: providerHeaders,
);
if (fetchedModels.length === 0) {
throw new Error(t('apiAccess.error.noModels'));
@@ -1234,8 +1305,7 @@ export function ApiAccessPage() {
models: fetchedModels,
});
}
const latestConfig = await managementApi.get('/config');
const current = sectionRecordsFromConfig(latestConfig, activeSection);
const current = await providerGroupsApi.get(activeSection);
let nextList: Record<string, unknown>[];
let targetIndex = -1;
let currentRecord: Record<string, unknown> | undefined;
@@ -1248,13 +1318,7 @@ export function ApiAccessPage() {
currentRecord = current[targetIndex];
}
const recordsToSave = definition.openAi
? [buildProviderRecord(activeSection, draftToSave, currentRecord)]
: parsedApiKeys.map((apiKey) => buildProviderRecord(
activeSection,
{ ...draftToSave, apiKey },
currentRecord,
));
const recordsToSave = [buildProviderGroupRecord(activeSection, draftToSave, currentRecord)];
if (hasDuplicateProviderRecord(activeSection, current, recordsToSave, targetIndex)) {
throw new Error(t('apiAccess.error.duplicate'));
}
@@ -1266,7 +1330,7 @@ export function ApiAccessPage() {
]
: [...current, ...recordsToSave];
await managementApi.put(`/${activeSection}`, nextList.map(stripResponseFields));
await providerGroupsApi.put(activeSection, nextList.map(stripResponseFields));
await invoke('save_api_access_remark', {
update: {
providerSection: activeSection,
@@ -1296,12 +1360,11 @@ export function ApiAccessPage() {
setBusy(true);
setError('');
try {
const latestConfig = await managementApi.get('/config');
const current = sectionRecordsFromConfig(latestConfig, row.section);
const current = await providerGroupsApi.get(row.section);
const targetIndex = resolveProviderRecordIndex(current, row);
if (targetIndex < 0) throw new Error(t('apiAccess.error.stale'));
const remainingRecords = current.filter((_, index) => index !== targetIndex).map(stripResponseFields);
await managementApi.put(`/${row.section}`, remainingRecords);
await providerGroupsApi.put(row.section, remainingRecords);
await invoke('save_api_access_remark', {
update: {
providerSection: row.section,
@@ -1327,8 +1390,7 @@ export function ApiAccessPage() {
setError('');
setNotice('');
try {
const latestConfig = await managementApi.get('/config');
const latestRows = sectionRecordsFromConfig(latestConfig, row.section);
const latestRows = await providerGroupsApi.get(row.section);
const targetIndex = resolveProviderRecordIndex(latestRows, row);
if (targetIndex < 0) {
throw new Error(t('apiAccess.error.stale'));
@@ -1340,10 +1402,8 @@ export function ApiAccessPage() {
: Array.isArray(latestRecord['excluded-models'])
&& latestRecord['excluded-models'].some((model) => String(model).trim() === '*');
if (definition.openAi) {
await managementApi.patch('/openai-compatibility', {
index: targetIndex,
value: { disabled: !currentlyDisabled },
});
await providerGroupsApi.put(row.section, latestRows.map((record, index) =>
stripResponseFields(index === targetIndex ? { ...record, disabled: !currentlyDisabled } : record)));
} else {
const nextRecord = providerRecordWithDisabledState(
row.section,
@@ -1353,7 +1413,7 @@ export function ApiAccessPage() {
const nextRows = latestRows.map((record, index) =>
index === targetIndex ? nextRecord : stripResponseFields(record),
);
await managementApi.put(`/${row.section}`, nextRows);
await providerGroupsApi.put(row.section, nextRows);
}
await loadProviders(false);
} catch (requestError) {
@@ -1369,11 +1429,10 @@ export function ApiAccessPage() {
setError('');
setNotice('');
try {
const latestConfig = await managementApi.get('/config');
const latestRows = sectionRecordsFromConfig(latestConfig, source.section);
const latestRows = await providerGroupsApi.get(source.section);
const nextRows = reorderProviderRecords(latestRows, rows, source, target);
if (!nextRows) throw new Error(t('apiAccess.error.stale'));
await managementApi.put(`/${source.section}`, nextRows);
await providerGroupsApi.put(source.section, nextRows);
await loadProviders(false);
} catch (requestError) {
await loadProviders(false);
@@ -1435,7 +1494,7 @@ export function ApiAccessPage() {
<div className="management-panel-heading">
<div>
<h2 title={t(activeDefinition.labelKey)}>{t(activeDefinition.labelKey)}</h2>
<span>{t('apiAccess.matches', { count: rows.length })}</span>
<span>{t('apiAccess.groups.summary', { count: rows.length })}</span>
</div>
<div className="api-access-heading-tools">
<button type="button" className="secondary-button compact-button" onClick={() => void loadProviders()} disabled={loading || busy}>
@@ -1444,7 +1503,7 @@ export function ApiAccessPage() {
</button>
<button type="button" className="primary-button compact-button" onClick={openCreate} disabled={loading || busy}>
<Plus size={16} aria-hidden="true" />
{t('apiAccess.add')}
{t('apiAccess.groups.add')}
</button>
<div className="management-toolbar compact-toolbar">
<Search size={16} aria-hidden="true" />
@@ -1485,10 +1544,11 @@ export function ApiAccessPage() {
>
<div className="provider-row-main">
<div className="provider-row-title">
<strong title={row.remark || row.name}>{row.remark || row.name}</strong>
<strong title={row.name}>{row.name}</strong>
{row.remark ? <span>{row.remark}</span> : null}
</div>
<code title={definitionFor(row.section).openAi ? t('apiAccess.keys.count', { count: row.apiKeys.length }) : undefined}>
{definitionFor(row.section).openAi && row.apiKeys.length > 1
<code title={t('apiAccess.keys.count', { count: row.apiKeys.length })}>
{row.apiKeys.length > 1
? t('apiAccess.keys.summary', { key: maskSecret(row.apiKey), count: row.apiKeys.length })
: maskSecret(row.apiKey)}
</code>
@@ -1509,12 +1569,12 @@ export function ApiAccessPage() {
>
{t('apiAccess.health.action')}
</button>
<label className="provider-enabled-control" title={row.disabled ? t('apiAccess.enable') : t('apiAccess.disable')}>
<span>{row.disabled ? t('apiAccess.status.disabled') : t('apiAccess.status.enabled')}</span>
<label className="provider-enabled-control" title={t('apiAccess.groups.sharedToggleHint')}>
<span>{t(definitionFor(row.section).openAi ? row.record.disabled ? 'apiAccess.status.disabled' : 'apiAccess.status.enabled' : 'apiAccess.groups.sharedModels')}</span>
<span className="switch-control">
<input
type="checkbox"
checked={!row.disabled}
checked={definitionFor(row.section).openAi ? !row.record.disabled : !(Array.isArray(row.record['excluded-models']) && row.record['excluded-models'].includes('*'))}
onChange={() => void toggleProvider(row)}
disabled={busy}
aria-label={t('apiAccess.toggleAria', { remark: row.remark || row.name, action: row.disabled ? t('common.enable') : t('common.disable') })}
@@ -1522,6 +1582,7 @@ export function ApiAccessPage() {
<span className="switch-track" />
</span>
</label>
{providerGroupStatus(row.record) === 'partial' ? <span className="state-pill">{t('apiAccess.groups.partial')}</span> : null}
<button type="button" className="icon-button quiet" onClick={() => openEdit(row)} disabled={busy} title={t('common.edit')} aria-label={t('common.edit')}>
<Edit3 size={16} aria-hidden="true" />
</button>
@@ -1549,7 +1610,7 @@ export function ApiAccessPage() {
/>
) : null}
{healthDialogRow ? (
<ProviderHealthDialog
<ProviderGroupHealthDialog
key={providerHealthIdentity(healthDialogRow)}
row={healthDialogRow}
onClose={() => setHealthDialogRow(null)}
@@ -1563,11 +1624,30 @@ export function ApiAccessPage() {
type ProviderHealthDialogProps = {
row: ProviderRow;
onClose: () => void;
keySelector?: ReactNode;
};
function ProviderGroupHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
const { t } = useI18n();
const keys = providerGroupKeys(row.record);
const [selected, setSelected] = useState(0);
const key = keys[selected];
const effectiveRow = useMemo(() => {
if (!key) return row;
const record = effectiveProviderKey(row.record, key);
return { ...row, record, apiKey: readString(key, 'api-key'), apiKeys: [readString(key, 'api-key')],
authIndex: readString(key, 'auth-index', 'authIndex'), models: modelsFromRecord(record.models) };
}, [row, key]);
return <ProviderHealthDialog key={selected} row={effectiveRow} onClose={onClose} keySelector={keys.length ?
<label className="provider-health-key-selector"><span>{t('apiAccess.groups.healthKey')}</span>
<select value={selected} onChange={(event) => setSelected(Number(event.currentTarget.value))}>
{keys.map((entry, index) => <option key={index} value={index}>{t('apiAccess.groups.keyNumber', { number: index + 1 })} · {maskSecret(readString(entry, 'api-key'))}</option>)}
</select></label> : undefined} />;
}
type ProviderModelHealthState = { status: 'checking' } | ProviderModelHealthResult;
function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
function ProviderHealthDialog({ row, onClose, keySelector }: ProviderHealthDialogProps) {
const { t } = useI18n();
const configuredModels = useMemo(
() => mergeProviderHealthModels([], row.models),
@@ -1684,6 +1764,7 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
</button>
</div>
{keySelector}
<div className="model-discovery-search">
<Search size={16} aria-hidden="true" />
<input value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder={t('apiAccess.health.search')} />
@@ -1798,6 +1879,8 @@ export function ApiProviderDialog({
const definition = definitionFor(activeCategory);
const activeSection = definition.section;
const [draft, setDraft] = useState<ProviderDraft>(initialDraft);
const grouped = Boolean(draft.groupKeys);
const [discoveryKeyId, setDiscoveryKeyId] = useState(initialDraft.groupKeys?.[0]?.id ?? '');
const [modelLoading, setModelLoading] = useState(false);
const [modelError, setModelError] = useState('');
const [formError, setFormError] = useState('');
@@ -1849,7 +1932,7 @@ export function ApiProviderDialog({
), [modelOptions, selectedModelNames]);
const updateTextField = (
field: 'apiKey' | 'remark' | 'baseUrl' | 'proxyUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'cloakMode' | 'cloakSensitiveWordsText',
field: 'name' | 'apiKey' | 'remark' | 'baseUrl' | 'proxyUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'cloakMode' | 'cloakSensitiveWordsText',
value: string,
) => {
setFormError('');
@@ -1950,13 +2033,15 @@ export function ApiProviderDialog({
: definition.section === 'codex-api-key'
? 'codex'
: 'openai';
const modelApiKey = draft.apiKey.split(/\r?\n/).map((value) => value.trim()).find(Boolean) ?? '';
const selectedKey = draft.groupKeys?.find((key) => key.id === discoveryKeyId) ?? draft.groupKeys?.[0];
const connection = selectedKey ? effectiveProviderKey({ headers: parseProviderHeaders(draft.headersText ?? '') }, serializeProviderKey(selectedKey)) : null;
const modelApiKey = connection ? readString(connection, 'api-key') : draft.apiKey.split(/\r?\n/).map((value) => value.trim()).find(Boolean) ?? '';
const fetchedModels = await fetchModels(
provider,
draft.baseUrl,
modelApiKey,
editingRow?.authIndex,
parseProviderHeaders(draft.headersText ?? ''),
connection ? readString(selectedKey!.value, 'auth-index', 'authIndex') || undefined : editingRow?.authIndex,
connection ? providerHeadersFromRecord(connection) : parseProviderHeaders(draft.headersText ?? ''),
);
if (requestId !== discoveryRequestRef.current) return;
const models = applyProviderPreset(
@@ -2009,7 +2094,10 @@ export function ApiProviderDialog({
if (modelLoading || selectedModels.length === 0) return;
setDraft((current) => ({
...current,
models: selectedModels,
models: grouped ? selectedModels.flatMap((selected) => {
const existing = current.models.filter((model) => model.name.toLowerCase() === selected.name.toLowerCase());
return existing.length ? existing : [selected];
}) : selectedModels,
modelSelectionCatalog: activeSection === 'openai-compatibility' ? undefined : discoveredModels,
excludedModelsText: activeSection === 'openai-compatibility'
? current.excludedModelsText
@@ -2071,14 +2159,16 @@ export function ApiProviderDialog({
<div className="config-dialog-heading">
<div>
<Plus size={19} aria-hidden="true" />
<h2 id="api-provider-dialog-title">{editingRow ? t('apiAccess.dialog.edit') : t('apiAccess.dialog.add')}</h2>
<h2 id="api-provider-dialog-title">{grouped ? t(editingRow ? 'apiAccess.groups.edit' : 'apiAccess.groups.add') : editingRow ? t('apiAccess.dialog.edit') : t('apiAccess.dialog.add')}</h2>
</div>
<button type="button" className="icon-button quiet" onClick={onClose} disabled={busy} title={t('common.close')} aria-label={t('common.close')}>
<X size={18} aria-hidden="true" />
</button>
</div>
<label><span>{t('apiAccess.field.remark')}</span><input autoFocus={definition.openAi} value={draft.remark} maxLength={80} onChange={(event) => updateTextField('remark', event.currentTarget.value)} placeholder={t('apiAccess.remarkPlaceholder')} /></label>
<label className="multiline-field">
{grouped ? <><p className="provider-group-hint">{t('apiAccess.groups.description')}</p>
<label><span>{t('apiAccess.groups.name')}</span><input autoFocus value={draft.name} maxLength={80} onChange={(event) => updateTextField('name', event.currentTarget.value)} placeholder={t('apiAccess.groups.namePlaceholder')} /></label></> : null}
<label><span>{t('apiAccess.field.remark')}</span><input autoFocus={!grouped && definition.openAi} value={draft.remark} maxLength={80} onChange={(event) => updateTextField('remark', event.currentTarget.value)} placeholder={t('apiAccess.remarkPlaceholder')} /></label>
{!grouped ? <label className="multiline-field">
<span>{t('apiAccess.field.keysMany')}</span>
<textarea
autoFocus={!definition.openAi}
@@ -2087,10 +2177,25 @@ export function ApiProviderDialog({
placeholder={'sk-...\nsk-...'}
rows={3}
/>
</label>
</label> : null}
<label><span>{t('apiAccess.field.baseUrl')}</span><input value={draft.baseUrl} onChange={(event) => updateTextField('baseUrl', event.currentTarget.value)} placeholder={activeSection === 'codex-api-key' || activeSection === 'openai-compatibility' ? t('apiAccess.baseRequiredPlaceholder') : t('apiAccess.baseOptionalPlaceholder')} /></label>
<label><span>{t('apiAccess.field.proxyUrl')}</span><input value={draft.proxyUrl ?? ''} onChange={(event) => updateTextField('proxyUrl', event.currentTarget.value)} placeholder={draft.proxyUrlMixed ? t('apiAccess.proxyMixed') : 'socks5://127.0.0.1:1080'} /></label>
{draft.proxyUrlMixed ? <button type="button" className="secondary-button compact-button api-provider-proxy-clear" onClick={() => updateTextField('proxyUrl', '')}>{t('apiAccess.proxyClear')}</button> : null}
{draft.groupKeys ? <ProviderGroupKeysEditor keys={draft.groupKeys} section={activeSection} disabled={busy}
shared={{ models: draft.models, priority: draft.priority ? Number(draft.priority) : undefined, prefix: draft.prefix,
'proxy-url': draft.proxyUrl, 'disable-cooling': draft.disableCooling,
'excluded-models': draft.excludedModelsText?.split(/[\n,]/).map((item) => item.trim()).filter(Boolean),
headers: (() => { try { return parseProviderHeaders(draft.headersText ?? ''); } catch { return {}; } })(),
}} onChange={(groupKeys) => {
discoveryRequestRef.current += 1; setModelLoading(false); setModelError(''); setFormError('');
if (activeCategory === 'deepseek') setModelDiscoveryReady(false);
setDraft((current) => ({ ...current, groupKeys }));
}} /> : null}
{draft.groupKeys?.length ? <label><span>{t('apiAccess.groups.discoveryKey')}</span><select value={draft.groupKeys.some((key) => key.id === discoveryKeyId) ? discoveryKeyId : draft.groupKeys[0].id}
onChange={(event) => { discoveryRequestRef.current += 1; setModelLoading(false); setDiscoveryKeyId(event.currentTarget.value); }}>
{draft.groupKeys.map((key, index) => <option value={key.id} key={key.id}>{t('apiAccess.groups.keyNumber', { number: index + 1 })} · {maskSecret(readString(key.value, 'api-key'))}</option>)}
</select></label> : null}
{grouped ? <p className="provider-group-hint">{t('apiAccess.groups.sharedHint')}</p> : null}
{activeCategory === 'openai-compatibility' ? (
<div className="thinking-level-config">
<div className="thinking-level-heading">
@@ -2188,7 +2293,7 @@ export function ApiProviderDialog({
<textarea value={draft.excludedModelsText ?? ''} onChange={(event) => updateTextField('excludedModelsText', event.currentTarget.value)} rows={3} placeholder={'model-old-*\nmodel-preview'} />
</label>
) : null}
{activeSection === 'claude-api-key' ? (
{!grouped && activeSection === 'claude-api-key' ? (
<div className="provider-cloak-settings">
<label>
<span>{t('apiAccess.cloak.mode')}</span>
@@ -2217,7 +2322,7 @@ export function ApiProviderDialog({
</div>
</div>
) : null}
{activeSection === 'codex-api-key' ? (
{!grouped && activeSection === 'codex-api-key' ? (
<div className="provider-advanced-toggle">
<div><strong>WebSocket</strong><span>{t('apiAccess.websocket.description')}</span></div>
<label className="switch-control" title={t('apiAccess.websocket.enable')}><input type="checkbox" checked={Boolean(draft.websockets)} onChange={(event) => updateBooleanField('websockets', event.currentTarget.checked)} /><span className="switch-track" /></label>
+11
View File
@@ -37,6 +37,17 @@ const SHARED_PROVIDER_FIELDS = new Set([
const providerDefinition = (path: string) => PROVIDER_PATHS[path as ProviderPath];
// Native v8 groups: callers keep keys and overrides intact through every edit.
export const providerGroupsApi = {
get: async (section: string): Promise<Record<string, unknown>[]> => {
const value = await optionalConfigValue(`/config/api-keys/${section.replace(/-api-key$/, '')}`, [], {});
if (!Array.isArray(value) || !value.every(isRecord)) throw new Error('Invalid provider group response');
return value;
},
put: (section: string, groups: Record<string, unknown>[]) =>
request('PUT', `/config/api-keys/${section.replace(/-api-key$/, '')}`, { body: groups }),
};
async function optionalConfigValue(
path: string, fallback: ManagementJson, options: ManagementRequestOptions,
): Promise<unknown> {
+81
View File
@@ -0,0 +1,81 @@
import { isRecord, readString } from './managementApi';
import { normalizeProviderModels } from './providerModels';
export type ProviderKeyDraft = { id: string; value: Record<string, unknown>; text?: Record<string, string> };
export const providerGroupKeys = (group: Record<string, unknown>): Record<string, unknown>[] =>
Array.isArray(group.keys) ? group.keys.filter(isRecord) : [];
export function providerKeyDraft(value: Record<string, unknown> = {}): ProviderKeyDraft {
return { id: crypto.randomUUID(), value: structuredClone(value) };
}
export function cleanProviderGroup(group: Record<string, unknown>): Record<string, unknown> {
const clean = normalizeProviderModels(group);
for (const field of ['auth-index', 'authIndex', 'auth_index', 'test-model', 'testModel']) delete clean[field];
if (Array.isArray(clean.keys)) clean.keys = clean.keys.filter(isRecord).map(cleanProviderGroup);
return clean;
}
export function providerGroupIdentity(group: Record<string, unknown>): string {
const sort = (value: unknown): unknown => Array.isArray(value) ? value.map(sort)
: isRecord(value) ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, sort(value[key])])) : value;
return JSON.stringify(sort(cleanProviderGroup(group)));
}
// Lists and maps replace the group setting; null and absent fields inherit it.
export function effectiveProviderKey(group: Record<string, unknown>, key: Record<string, unknown>) {
const { keys: _keys, ...shared } = group;
return { ...shared, ...Object.fromEntries(Object.entries(key).filter(([, value]) => value !== null)) };
}
export const providerKeyIsDisabled = (group: Record<string, unknown>, key: Record<string, unknown>) => {
const effective = effectiveProviderKey(group, key);
return group.disabled === true || effective.disabled === true
|| (Array.isArray(effective['excluded-models']) && effective['excluded-models'].some((item) => String(item).trim() === '*'));
};
export function providerGroupStatus(group: Record<string, unknown>): 'enabled' | 'partial' | 'disabled' {
const keys = providerGroupKeys(group);
if (!keys.length) return group.disabled === true ? 'disabled' : 'enabled';
const active = keys.filter((key) => !providerKeyIsDisabled(group, key)).length;
return active === 0 ? 'disabled' : active === keys.length ? 'enabled' : 'partial';
}
export function providerKeyOverrides(key: Record<string, unknown>): string[] {
return Object.keys(key).filter((field) => !['api-key', 'auth-index', 'authIndex', 'auth_index', 'weight'].includes(field)
&& key[field] != null);
}
export function validateProviderGroupKeys(keys: ProviderKeyDraft[]): string | null {
for (const { value } of keys) {
if (!readString(value, 'api-key').trim()) return 'key';
if (Array.isArray(value.models) && value.models.some((model) => !isRecord(model) || !readString(model, 'name').trim())) return 'models';
for (const field of ['priority', 'weight', 'request-retry']) {
const valueField = value[field];
if (valueField == null) continue;
if (typeof valueField !== 'number' || !Number.isSafeInteger(valueField)) return field;
if (field === 'weight' && (valueField < 0 || valueField > 1_000_000)) return field;
}
}
return null;
}
export function serializeProviderKey(draft: ProviderKeyDraft): Record<string, unknown> {
const value = cleanProviderGroup(draft.value);
for (const [field, text] of Object.entries(draft.text ?? {})) {
if (field === 'cloak-words') value.cloak = { ...(isRecord(value.cloak) ? value.cloak : {}),
'sensitive-words': text.split(/[\n,]/).map((line) => line.trim()).filter(Boolean) };
if (field === 'excluded-models') value[field] = text.split(/[\n,]/).map((line) => line.trim()).filter(Boolean);
if (field === 'headers') {
const headers: Record<string, string> = {};
for (const line of text.split('\n').filter((line) => line.trim())) {
const colon = line.indexOf(':');
if (colon <= 0 || !/^[!#$%&'*+.^_`|~\w-]+$/.test(line.slice(0, colon).trim())) throw new Error('Invalid key header');
headers[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
}
value[field] = headers;
}
}
value['api-key'] = readString(value, 'api-key').trim();
return value;
}
+45
View File
@@ -0,0 +1,45 @@
.provider-group-hint,
.provider-group-heading p {
margin: 0;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.65;
}
.provider-group-keys { display: grid; gap: 12px; min-width: 0; }
.provider-group-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.provider-group-heading strong { font-size: 13px; }
.api-provider-dialog .provider-group-heading button { flex-shrink: 0; width: auto; }
.provider-group-heading > div { min-width: 0; }
.provider-group-key {
min-width: 0; margin: 0; padding: 12px;
border: 1px solid var(--theme-e8e4dc); border-radius: 10px;
background: var(--bg-tertiary);
}
.provider-group-key legend { padding: 0 6px; font-size: 12px; font-weight: 600; }
.provider-group-key-main { display: flex; align-items: end; gap: 6px; }
.provider-group-key-main > label { flex: 1; min-width: 0; }
.provider-group-key-main input { width: 100%; min-width: 0; }
.api-provider-dialog .provider-group-key-main button { flex-shrink: 0; margin-bottom: 2px; width: auto; }
.provider-key-settings { margin-top: 12px; }
.provider-key-settings summary { font-size: 12px; color: var(--text-secondary); cursor: pointer; padding: 4px 0; }
.provider-key-settings[open] { display: grid; gap: 12px; }
.provider-key-override { display: grid; min-width: 0; gap: 6px; }
.api-provider-dialog .provider-key-override-toggle { display: flex; align-items: center; gap: 8px; grid-template-rows: none; }
.api-provider-dialog .provider-key-override-toggle input { width: 16px; height: 16px; flex-shrink: 0; }
.provider-key-override > textarea { width: 100%; box-sizing: border-box; }
.provider-group-key-state { display: none; }
.provider-health-key-selector { display: flex; align-items: center; gap: 10px; padding: 0 20px 12px; }
.provider-health-key-selector select { min-width: 0; flex: 1; }
.provider-group-key .model-config-entry { min-width: 0; }
.provider-group-key select, .provider-health-key-selector select {
min-height: 36px; border: 1px solid var(--theme-e8e4dc); border-radius: 7px;
background: var(--bg-primary); color: var(--text-primary); padding: 5px 8px; font: inherit;
}
.api-provider-dialog .provider-group-key-main .icon-button { width: 32px; min-width: 32px; height: 32px; }
.provider-row-title > span { color: var(--text-secondary); font-size: 12px; overflow-wrap: anywhere; }
@media (max-width: 480px) {
.provider-group-heading { align-items: flex-start; }
.provider-group-key { padding: 8px; }
.provider-group-key-main { flex-wrap: wrap; }
.provider-group-key-main > label { flex-basis: 100%; }
}