feat: add per-API connection proxy URL settings
This commit is contained in:
1 parent
2432220677
commit
4d2aea3353
9 files changed
+196
-7
No files matched your search
@@ -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 件、ワイルドカード対応)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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': '排除模型(每行一个,支持通配符)',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user