diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index de99d98..b1f95e6 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -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 件、ワイルドカード対応)', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 718fd7c..e93bbc6 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1312,6 +1312,7 @@ export const en: Record = { '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 = { '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)', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 79c3b51..92b6f87 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -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': '排除模型(每行一个,支持通配符)', diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 223fdb7..97d044a 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -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, +): Pick => { + 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 = { ...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 = {}; 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({ /> + + {draft.proxyUrlMixed ? : null} {activeCategory === 'openai-compatibility' ? (
diff --git a/src/pages/EasyModePage.tsx b/src/pages/EasyModePage.tsx index a8684b0..2ed9ec3 100644 --- a/src/pages/EasyModePage.tsx +++ b/src/pages/EasyModePage.tsx @@ -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("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({
+
+ + { setApiProxyUrl(event.target.value); setGuideApiSaved(false); }} + placeholder="socks5://127.0.0.1:1080" + /> +
+
{apiTestedModels.length === 0 ? (
diff --git a/src/services/providerProxy.ts b/src/services/providerProxy.ts new file mode 100644 index 0000000..5184fd2 --- /dev/null +++ b/src/services/providerProxy.ts @@ -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; +} diff --git a/src/styles.css b/src/styles.css index 42c6449..87a584a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; diff --git a/tests/providerConfig.test.ts b/tests/providerConfig.test.ts index a49ad56..bde9bb6 100644 --- a/tests/providerConfig.test.ts +++ b/tests/providerConfig.test.ts @@ -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: '', diff --git a/tests/providerProxy.test.ts b/tests/providerProxy.test.ts new file mode 100644 index 0000000..6b64056 --- /dev/null +++ b/tests/providerProxy.test.ts @@ -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(); + } + }); +});