feat: add per-API connection proxy URL settings

This commit is contained in:
lzt404 committed 2026-09-25 14:51:56 +08:00
1 parent 2432220677
commit 4d2aea3353
9 files changed
+196 -7

No files matched your search

+4
View File
@@ -1400,6 +1400,10 @@ export const jaOverrides = {
'common.optional': '任意',
'apiAccess.advanced': '詳細設定',
'apiAccess.field.prefix': 'モデル接頭辞',
'apiAccess.field.proxyUrl': 'プロキシ URL',
'apiAccess.proxyMixed': '異なるプロキシが設定されています',
'apiAccess.proxyClear': 'プロキシを解除',
'apiAccess.error.proxyUrlInvalid': 'プロキシ URL が無効です。http://、https://、socks5://、direct、none を使用してください',
'apiAccess.prefixPlaceholder': '任意、例:team-a',
'apiAccess.field.headers': 'カスタムリクエストヘッダー(1 行に Name: Value)',
'apiAccess.field.excludedModels': '除外モデル(1 行に 1 件、ワイルドカード対応)',
+4
View File
@@ -1312,6 +1312,7 @@ export const en: Record<MessageKey, string> = {
'apiAccess.error.stale': 'The connection configuration changed elsewhere. Refresh and try again.',
'apiAccess.error.duplicate': 'An identical connection already exists',
'apiAccess.error.remarkInvalid': 'The remark cannot exceed 80 characters or contain line breaks or control characters',
'apiAccess.error.proxyUrlInvalid': 'Invalid proxy URL. Use an http://, https://, or socks5:// address, or enter direct or none',
'apiAccess.notice.updated': 'Connection updated',
'apiAccess.notice.added': 'Connection added',
'apiAccess.deleteConfirm': 'Delete “{remark}”?',
@@ -1400,6 +1401,9 @@ export const en: Record<MessageKey, string> = {
'common.optional': 'Optional',
'apiAccess.advanced': 'Advanced Settings',
'apiAccess.field.prefix': 'Model Prefix',
'apiAccess.field.proxyUrl': 'Proxy URL',
'apiAccess.proxyMixed': 'Different proxies are configured',
'apiAccess.proxyClear': 'Clear proxy',
'apiAccess.prefixPlaceholder': 'Optional, for example team-a',
'apiAccess.field.headers': 'Custom Headers (one Name: Value per line)',
'apiAccess.field.excludedModels': 'Excluded Models (one per line; wildcards supported)',
+4
View File
@@ -1311,6 +1311,7 @@ export const zhCN = {
'apiAccess.error.stale': '接入配置已被其他操作修改,请刷新后重试',
'apiAccess.error.duplicate': '相同的接入配置已经存在',
'apiAccess.error.remarkInvalid': '备注不能超过 80 个字符,且不能包含换行或控制字符',
'apiAccess.error.proxyUrlInvalid': '代理 URL 无效,请使用 http://、https:// 或 socks5:// 地址,也可填写 direct 或 none',
'apiAccess.notice.updated': '接入已更新',
'apiAccess.notice.added': '接入已新增',
'apiAccess.deleteConfirm': '确定删除“{remark}”吗?',
@@ -1399,6 +1400,9 @@ export const zhCN = {
'common.optional': '可选',
'apiAccess.advanced': '高级设置',
'apiAccess.field.prefix': '模型前缀',
'apiAccess.field.proxyUrl': '代理 URL',
'apiAccess.proxyMixed': '当前代理设置不一致',
'apiAccess.proxyClear': '清除代理',
'apiAccess.prefixPlaceholder': '可选,例如 team-a',
'apiAccess.field.headers': '自定义请求头(每行 Name: Value)',
'apiAccess.field.excludedModels': '排除模型(每行一个,支持通配符)',
+51 -6
View File
@@ -73,6 +73,7 @@ import {
type ProviderModelHealthResult,
} from '../services/providerHealthCheck';
import { modelMatchesRule } from '../services/oauthModels';
import { normalizeProviderProxyUrl } from '../services/providerProxy';
import { getCurrentLocale, translate, useI18n } from '../i18n';
import type { MessageKey } from '../i18n/resources';
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
@@ -207,6 +208,9 @@ export type ProviderDraft = {
apiKey: string;
remark: string;
baseUrl: string;
proxyUrl?: string;
proxyUrlEdited?: boolean;
proxyUrlMixed?: boolean;
priority: string;
models: ModelOption[];
modelSelectionCatalog?: ModelOption[];
@@ -600,6 +604,24 @@ const thinkingLevelsFromModels = (models: ModelOption[]): string[] => {
return levels;
};
export const providerProxyDraftFromRecord = (
section: ProviderSection,
record: Record<string, unknown>,
): Pick<ProviderDraft, 'proxyUrl' | 'proxyUrlEdited' | 'proxyUrlMixed'> => {
const entries = definitionFor(section).openAi && Array.isArray(record['api-key-entries'])
? record['api-key-entries'].filter(isRecord)
: [];
const proxyUrlMixed = entries.some((entry) =>
readString(entry, 'proxy-url', 'proxyUrl') !== readString(entries[0], 'proxy-url', 'proxyUrl'));
return {
proxyUrl: definitionFor(section).openAi
? proxyUrlMixed ? '' : readString(entries[0], 'proxy-url', 'proxyUrl')
: readString(record, 'proxy-url', 'proxyUrl'),
proxyUrlEdited: false,
proxyUrlMixed,
};
};
const draftFromRow = (row: ProviderRow): ProviderDraft => {
const definition = definitionFor(row.section);
const isDeepSeek = row.section === 'codex-api-key' && isDeepSeekRecord(row.record);
@@ -608,6 +630,7 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => {
apiKey: definition.openAi ? row.apiKeys.join('\n') : row.apiKey,
remark: row.remark || (definition.openAi && !isDeepSeek ? row.name : ''),
baseUrl: row.baseUrl,
...providerProxyDraftFromRecord(row.section, row.record),
priority: row.priority === null ? '' : String(row.priority),
models: row.models,
prefix: readString(row.record, 'prefix'),
@@ -645,6 +668,7 @@ const emptyProviderDraft = (): ProviderDraft => ({
apiKey: '',
remark: '',
baseUrl: '',
proxyUrl: '',
priority: '',
models: [],
prefix: '',
@@ -812,14 +836,19 @@ export const buildProviderRecord = (
const priority = priorityText ? Number(priorityText) : null;
const models = mergeModelRecords(record.models, draft.models);
if (definitionFor(section).openAi) {
const entries = mergeOpenAiApiKeyEntries(record['api-key-entries'], draft.apiKey.trim());
if (draft.proxyUrlEdited !== false && draft.proxyUrl !== undefined) {
const proxyUrl = draft.proxyUrl.trim();
entries.forEach((entry) => {
if (proxyUrl) entry['proxy-url'] = proxyUrl;
else delete entry['proxy-url'];
});
}
const next: Record<string, unknown> = {
...record,
name: draft.name.trim(),
'base-url': draft.baseUrl.trim(),
'api-key-entries': mergeOpenAiApiKeyEntries(
record['api-key-entries'],
draft.apiKey.trim(),
),
'api-key-entries': entries,
models,
};
if (priority !== null && Number.isFinite(priority)) next.priority = priority;
@@ -837,6 +866,11 @@ export const buildProviderRecord = (
}
if (draft.baseUrl.trim()) next['base-url'] = draft.baseUrl.trim();
else delete next['base-url'];
if (draft.proxyUrl !== undefined && draft.proxyUrlEdited !== false) {
const proxyUrl = draft.proxyUrl.trim();
if (proxyUrl) next['proxy-url'] = proxyUrl;
else delete next['proxy-url'];
}
if (priority !== null && Number.isFinite(priority)) next.priority = priority;
else delete next.priority;
return applyAdvancedFields(next, section, draft);
@@ -1133,9 +1167,17 @@ export function ApiAccessPage() {
return { saved: false, target: 'form', error: t('apiAccess.error.remarkInvalid') };
}
let baseUrl = preparedDraft.baseUrl.trim();
let proxyUrl = preparedDraft.proxyUrl ?? '';
let providerHeaders: Record<string, string> = {};
try {
if (baseUrl) baseUrl = normalizeBaseUrl(baseUrl);
if (preparedDraft.proxyUrlEdited !== false) {
try {
proxyUrl = normalizeProviderProxyUrl(proxyUrl);
} catch {
throw new Error(t('apiAccess.error.proxyUrlInvalid'));
}
}
if (baseUrlRequired && !baseUrl) throw new Error(t('apiAccess.error.baseRequired', { provider: t(definition.labelKey) }));
providerHeaders = parseProviderHeaders(preparedDraft.headersText ?? '');
} catch (requestError) {
@@ -1144,7 +1186,7 @@ export function ApiAccessPage() {
setBusy(true);
setError('');
try {
let draftToSave = { ...preparedDraftForSave, baseUrl };
let draftToSave: ProviderDraft = { ...preparedDraftForSave, baseUrl, proxyUrl };
if (
definition.openAi
&& activeCategory !== 'deepseek'
@@ -1820,7 +1862,7 @@ export function ApiProviderDialog({
), [modelOptions, selectedModelNames]);
const updateTextField = (
field: 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText',
field: 'apiKey' | 'remark' | 'baseUrl' | 'proxyUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText',
value: string,
) => {
setFormError('');
@@ -1833,6 +1875,7 @@ export function ApiProviderDialog({
setDraft((current) => ({
...current,
[field]: value,
...(field === 'proxyUrl' ? { proxyUrlEdited: true, proxyUrlMixed: false } : {}),
...(field === 'excludedModelsText' ? { modelSelectionCatalog: undefined } : {}),
}));
};
@@ -2052,6 +2095,8 @@ export function ApiProviderDialog({
/>
</label>
<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}
{activeCategory === 'openai-compatibility' ? (
<div className="thinking-level-config">
<div className="thinking-level-heading">
+22 -1
View File
@@ -37,6 +37,7 @@ import {
type ModelOption,
type ModelProvider,
} from "../services/modelService";
import { normalizeProviderProxyUrl } from "../services/providerProxy";
import { type ThemePreference } from "../theme";
import { AgentsPage } from "./AgentsPage";
@@ -132,6 +133,7 @@ export function EasyModePage({
const [selectedApiSection, setSelectedApiSection] = useState<ApiSection>("openai-compatibility");
const [apiBaseUrl, setApiBaseUrl] = useState("");
const [apiProxyUrl, setApiProxyUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [apiRemark, setApiRemark] = useState("");
const [apiTesting, setApiTesting] = useState(false);
@@ -409,6 +411,13 @@ export function EasyModePage({
setApiTestError({ key: "easyMode.api.fetchListFirst" });
return;
}
let proxyUrl: string;
try {
proxyUrl = normalizeProviderProxyUrl(apiProxyUrl);
} catch {
setApiTestError({ key: "apiAccess.error.proxyUrlInvalid" });
return;
}
setApiSaving(true);
clearApiNotice();
setApiTestError("");
@@ -426,13 +435,14 @@ export function EasyModePage({
name: apiRemark.trim() || `${selectedApiSection} (${list.length + 1})`,
"base-url": normalizeBaseUrl(apiBaseUrl.trim()),
"api-key-entries": [
{ "api-key": apiKey.trim() },
{ "api-key": apiKey.trim(), ...(proxyUrl ? { "proxy-url": proxyUrl } : {}) },
],
models,
}
: {
...(selectedApiSection === "deepseek" ? { name: "DeepSeek" } : {}),
"api-key": apiKey.trim(),
...(proxyUrl ? { "proxy-url": proxyUrl } : {}),
"base-url": normalizeBaseUrl(apiBaseUrl.trim()),
models,
};
@@ -947,6 +957,17 @@ export function EasyModePage({
</div>
</div>
<div className="simple-mode-field">
<label>{t("apiAccess.field.proxyUrl")}</label>
<input
type="text"
className="text-input"
value={apiProxyUrl}
onChange={(event) => { setApiProxyUrl(event.target.value); setGuideApiSaved(false); }}
placeholder="socks5://127.0.0.1:1080"
/>
</div>
<div className="simple-mode-api-model-card">
{apiTestedModels.length === 0 ? (
<div className="simple-mode-api-model-fetch">
+21
View File
@@ -0,0 +1,21 @@
export function normalizeProviderProxyUrl(value: string): string {
const proxyUrl = value.trim();
if (!proxyUrl) return '';
if (/^(direct|none)$/i.test(proxyUrl)) return proxyUrl.toLowerCase();
try {
if (/\s/.test(proxyUrl)) throw new Error('invalid');
const url = new URL(proxyUrl);
if (!['http:', 'https:', 'socks5:', 'socks5h:'].includes(url.protocol)
|| !url.hostname
|| (!url.port && !['http:', 'https:'].includes(url.protocol))
|| !['', '/'].includes(url.pathname)
|| url.search
|| url.hash) {
throw new Error('invalid');
}
} catch {
throw new Error('invalid');
}
return proxyUrl;
}
+4
View File
@@ -5153,6 +5153,10 @@ textarea:focus-visible {
height: 36px;
}
.api-provider-dialog .api-provider-proxy-clear {
justify-self: start;
}
.api-provider-dialog textarea {
min-height: 64px;
padding-block: 7px;