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;
+69
View File
@@ -13,6 +13,7 @@ import {
parseProviderApiKeys,
providerCategoryMatchesRecord,
providerDragId,
providerProxyDraftFromRecord,
providerRecordWithDisabledState,
providerRemarkIdentity,
providerSectionOrder,
@@ -438,6 +439,74 @@ describe('API 接入配置合并', () => {
expect(result['proxy-url']).toBeUndefined();
});
it('为单个 API 接入保存、更新和清除代理 URL', () => {
const draft = {
...createProviderDraft('codex-api-key'),
apiKey: 'codex-key',
baseUrl: 'https://api.example.com',
proxyUrl: 'socks5://127.0.0.1:1080',
};
const saved = buildProviderRecord('codex-api-key', draft);
expect(saved['proxy-url']).toBe('socks5://127.0.0.1:1080');
expect(buildProviderRecord('codex-api-key', { ...draft, proxyUrl: 'direct' }, saved)['proxy-url']).toBe('direct');
expect(buildProviderRecord('codex-api-key', { ...draft, proxyUrl: '' }, saved)['proxy-url']).toBeUndefined();
expect(buildProviderRecord('codex-api-key', { ...draft, proxyUrl: '', proxyUrlEdited: false }, saved)['proxy-url'])
.toBe('socks5://127.0.0.1:1080');
});
it('每条 API 接入记录独立设置代理', () => {
const current = {
name: 'openrouter',
'api-key-entries': [
{ 'api-key': 'first', 'proxy-url': 'direct' },
{ 'api-key': 'second', 'proxy-url': 'socks5://localhost:1080' },
],
};
const draft = {
...createProviderDraft('openai-compatibility'),
...providerProxyDraftFromRecord('openai-compatibility', current),
name: 'openrouter',
apiKey: 'first\nsecond',
baseUrl: 'https://openrouter.ai/api/v1',
};
expect(draft.proxyUrl).toBe('');
expect(draft.proxyUrlEdited).toBe(false);
expect(draft.proxyUrlMixed).toBe(true);
const preserved = buildProviderRecord('openai-compatibility', draft, current);
expect(preserved['api-key-entries']).toEqual(current['api-key-entries']);
const cleared = buildProviderRecord('openai-compatibility', {
...draft,
proxyUrl: '',
proxyUrlEdited: true,
proxyUrlMixed: false,
}, current);
expect(cleared['api-key-entries']).toEqual([
{ 'api-key': 'first' },
{ 'api-key': 'second' },
]);
const updated = buildProviderRecord('openai-compatibility', {
...draft,
proxyUrl: 'http://127.0.0.1:7890',
proxyUrlEdited: true,
}, current);
expect(updated['api-key-entries']).toEqual([
{ 'api-key': 'first', 'proxy-url': 'http://127.0.0.1:7890' },
{ 'api-key': 'second', 'proxy-url': 'http://127.0.0.1:7890' },
]);
const other = buildProviderRecord('openai-compatibility', {
...createProviderDraft('openai-compatibility'),
name: 'another-entry',
apiKey: 'third',
baseUrl: 'https://other.example/v1',
proxyUrl: 'socks5://127.0.0.1:1080',
});
expect(other['api-key-entries']).toEqual([
{ 'api-key': 'third', 'proxy-url': 'socks5://127.0.0.1:1080' },
]);
});
it('编辑已停用的普通提供商时保留停用规则', () => {
const result = buildProviderRecord('claude-api-key', {
name: '',
+17
View File
@@ -0,0 +1,17 @@
import { normalizeProviderProxyUrl } from '../src/services/providerProxy';
describe('provider proxy URL', () => {
it('accepts supported proxies and explicit direct connections', () => {
expect(normalizeProviderProxyUrl(' socks5://user:pass@127.0.0.1:1080 ')).toBe('socks5://user:pass@127.0.0.1:1080');
expect(normalizeProviderProxyUrl('HTTP://127.0.0.1:7890')).toBe('HTTP://127.0.0.1:7890');
expect(normalizeProviderProxyUrl(' DIRECT ')).toBe('direct');
expect(normalizeProviderProxyUrl('none')).toBe('none');
expect(normalizeProviderProxyUrl('')).toBe('');
});
it('rejects PAC URLs, paths, and missing SOCKS ports', () => {
for (const url of ['https://proxy.example/proxy.pac', 'http://proxy.example/path', 'socks5://proxy.example', 'ftp://proxy.example:21', 'not a url']) {
expect(() => normalizeProviderProxyUrl(url)).toThrow();
}
});
});