feat(api-access): support native CPA v8 groups and fix alias source validation
This commit is contained in:
1 parent
6dc8650e68
commit
84fe2be8ac
22 files changed
+1136
-95
No files matched your search
@@ -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>;
|
||||
}
|
||||
@@ -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': "設定",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%; }
|
||||
}
|
||||
Reference in new issue
Block a user