Skip to content

Commit 2316f16

Browse files
jimgqyuDeepSeek
andcommitted
feat: per-provider proxy support in settings, UI, and HTTP layer
- Add proxy field to ProviderConfig, ModelEntry, ModelOptionProvider types - Inject proxy via undici ProxyAgent for OpenAI-compatible providers - Inject proxy via httpAgent option for Anthropic SDK - Add proxy configuration to --model CLI flow and TUI modelPicker - Default null (no proxy), fully backward compatible Co-Authored-By: DeepSeek <noreply@deepseek.com>
1 parent 8970108 commit 2316f16

11 files changed

Lines changed: 82 additions & 14 deletions

File tree

configs/default-settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
{
66
"model": ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-chat", "deepseek-reasoner"],
77
"base_url": "https://api.deepseek.com/anthropic",
8+
"proxy": null,
89
"auth_token_env": "YOUR_DEEPSEEK_API_KEY",
910
"provider": "deepseek",
1011
"price": {

packages/cli/src/components/modelPicker.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
3232
const [customProviderSlug, setCustomProviderSlug] = useState('')
3333
const [customProviderUrl, setCustomProviderUrl] = useState('')
3434
const [customProviderKey, setCustomProviderKey] = useState('')
35+
const [customProviderProxy, setCustomProviderProxy] = useState('')
3536
const [customProviderField, setCustomProviderField] = useState(0)
3637
const [customSaving, setCustomSaving] = useState(false)
3738
const [customError, setCustomError] = useState('')
@@ -173,7 +174,7 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
173174
}
174175

175176
if (key.return) {
176-
if (customProviderField < 2) {
177+
if (customProviderField < 3) {
177178
setCustomProviderField(f => f + 1)
178179
} else {
179180
// Save on last field
@@ -189,6 +190,7 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
189190
name: slug,
190191
base_url: customProviderUrl.trim() || undefined,
191192
api_key: customProviderKey.trim() || undefined,
193+
proxy: customProviderProxy.trim() || null,
192194
...(sessionId ? { session_id: sessionId } : {})
193195
})
194196
.then(raw => {
@@ -205,6 +207,7 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
205207
setCustomProviderSlug('')
206208
setCustomProviderUrl('')
207209
setCustomProviderKey('')
210+
setCustomProviderProxy('')
208211
setCustomProviderField(0)
209212
setCustomSaving(false)
210213
setProviderIdx(providers.length)
@@ -221,21 +224,21 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
221224
}
222225

223226
if (key.backspace || key.delete) {
224-
const setters = [setCustomProviderSlug, setCustomProviderUrl, setCustomProviderKey]
227+
const setters = [setCustomProviderSlug, setCustomProviderUrl, setCustomProviderKey, setCustomProviderProxy]
225228
setters[customProviderField](v => v.slice(0, -1))
226229

227230
return
228231
}
229232

230233
if (ch === '') {
231-
const setters = [setCustomProviderSlug, setCustomProviderUrl, setCustomProviderKey]
234+
const setters = [setCustomProviderSlug, setCustomProviderUrl, setCustomProviderKey, setCustomProviderProxy]
232235
setters[customProviderField]('')
233236

234237
return
235238
}
236239

237240
if (ch && !key.ctrl && !key.meta) {
238-
const setters = [setCustomProviderSlug, setCustomProviderUrl, setCustomProviderKey]
241+
const setters = [setCustomProviderSlug, setCustomProviderUrl, setCustomProviderKey, setCustomProviderProxy]
239242
setters[customProviderField](v => v + ch)
240243
}
241244

@@ -502,6 +505,7 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
502505
setCustomProviderSlug('')
503506
setCustomProviderUrl('')
504507
setCustomProviderKey('')
508+
setCustomProviderProxy('')
505509
setCustomProviderField(0)
506510
setCustomSaving(false)
507511
setCustomError('')
@@ -743,9 +747,9 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
743747

744748
// ── Custom provider creation stage ────────────────────────────────────
745749
if (stage === 'custom_provider') {
746-
const fieldLabels = ['Provider slug', 'Base URL (optional)', 'API Key (optional)']
747-
const fieldValues = [customProviderSlug, customProviderUrl, customProviderKey]
748-
const fieldMasked = [false, false, true]
750+
const fieldLabels = ['Provider slug', 'Base URL (optional)', 'API Key (optional)', 'Proxy URL (optional)']
751+
const fieldValues = [customProviderSlug, customProviderUrl, customProviderKey, customProviderProxy]
752+
const fieldMasked = [false, false, true, false]
749753

750754
return (
751755
<Box flexDirection="column" width={width}>
@@ -804,7 +808,7 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
804808
)}
805809

806810
<OverlayHint t={t}>
807-
{customProviderField < 2
811+
{customProviderField < 3
808812
? 'Enter next field · Ctrl+U clear field · Esc back'
809813
: 'Enter save · Ctrl+U clear field · Esc back'}
810814
</OverlayHint>

packages/cli/src/entry.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ if (cliArgs.model || process.argv.includes('--model')) {
219219
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
220220
} catch {}
221221

222-
const modelList: Array<{model: string[]; base_url?: string; auth_token_env?: string; provider: string}> =
222+
const modelList: Array<{model: string[]; base_url?: string; auth_token_env?: string; provider: string; proxy?: string | null}> =
223223
settings.model_list ?? [];
224224

225225
if (modelList.length === 0) {
@@ -258,6 +258,7 @@ if (cliArgs.model || process.argv.includes('--model')) {
258258
settings.env = settings.env ?? {};
259259
settings.env.CODER_MODEL = modelName;
260260
if (providerEntry.base_url) settings.env.CODER_BASE_URL = providerEntry.base_url;
261+
if (providerEntry.proxy) settings.env.CODER_PROXY = providerEntry.proxy;
261262
if (providerEntry.auth_token_env) settings.env.CODER_AUTH_TOKEN = providerEntry.auth_token_env;
262263

263264
// Smart merge into model_list (match by provider)
@@ -386,12 +387,14 @@ if (cliArgs.model || process.argv.includes('--model')) {
386387
const name = await new Promise<string>(resolve => rl.question('Enter provider name (e.g. myprovider): ', resolve));
387388
const url = await new Promise<string>(resolve => rl.question('Enter base URL (e.g. https://api.example.com/v1): ', resolve));
388389
const key = await new Promise<string>(resolve => rl.question('Enter API key (or press Enter to skip): ', resolve));
390+
const proxy = await new Promise<string>(resolve => rl.question('Enter proxy URL (e.g. http://127.0.0.1:7890, or press Enter to skip): ', resolve));
389391
rl.close();
390392
selectedProvider = {
391393
provider: name.trim(),
392394
model: [],
393395
base_url: url.trim() || undefined,
394396
auth_token_env: key.trim() || `YOUR_${name.trim().toUpperCase()}_API_KEY`,
397+
proxy: proxy.trim() || null,
395398
price: { input: 0, output: 0, currency: 'USD', unit: '1M tokens' }
396399
};
397400
modelList.push(selectedProvider);
@@ -481,6 +484,25 @@ if (cliArgs.model || process.argv.includes('--model')) {
481484
}
482485
}
483486

487+
// Proxy
488+
{
489+
const currentProxy = selectedProvider.proxy ?? 'None (no proxy)';
490+
console.log(`Proxy: ${currentProxy}`);
491+
const readline = await import('node:readline');
492+
const rl = readline.createInterface({ input: stdin, output: stdout });
493+
const newProxy = await new Promise<string>(resolve => rl.question('Press Enter to keep, or type a proxy URL (or "none" to disable): ', resolve));
494+
rl.close();
495+
if (newProxy.trim().toLowerCase() === 'none') {
496+
selectedProvider.proxy = undefined;
497+
console.log(' Proxy disabled.\n');
498+
} else if (newProxy.trim()) {
499+
selectedProvider.proxy = newProxy.trim();
500+
console.log(' Proxy updated.\n');
501+
} else {
502+
console.log(' Keeping current proxy.\n');
503+
}
504+
}
505+
484506
// ── Step 2: Model selection ───────────────────────────────────────────────
485507
const currentDefaultModel = defaultModel.split('/')[1] ?? selectedProvider.model[0] ?? '';
486508
let selectedModel = '';

packages/cli/src/gateway/coder-client.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ interface ModelEntry {
4949
model: string[] // List of model IDs available for this provider
5050
base_url?: string // Provider endpoint URL
5151
auth_token_env?: string // API key / auth token
52+
proxy?: string // HTTP/HTTPS proxy URL for this provider (e.g. "http://127.0.0.1:7890")
5253
provider?: string // e.g. "anthropic", "deepseek", "openai"
5354
price?: {
5455
input: number
@@ -97,6 +98,7 @@ function resolveModelConfig(settings: ClaudeSettings, fallbackModel: string): {
9798
model: string
9899
baseUrl?: string
99100
apiKey?: string
101+
proxy?: string
100102
name: string
101103
provider: string
102104
} {
@@ -118,6 +120,7 @@ function resolveModelConfig(settings: ClaudeSettings, fallbackModel: string): {
118120
model: selectedModel,
119121
baseUrl: entry.base_url,
120122
apiKey: entry.auth_token_env,
123+
proxy: entry.proxy,
121124
name: selectedModel,
122125
provider: entry.provider ?? inferProvider(selectedModel),
123126
}
@@ -224,7 +227,7 @@ export class CoderGatewayClient extends EventEmitter implements IGatewayClient {
224227
private thinkingBudget: number
225228

226229
// ── Model config ────────────────────────────────────────────────────
227-
private modelConfig: { model: string; baseUrl?: string; apiKey?: string; name: string; provider: string } | null = null
230+
private modelConfig: { model: string; baseUrl?: string; apiKey?: string; proxy?: string; name: string; provider: string } | null = null
228231

229232
// ── Session fork config ─────────────────────────────────────────────
230233
private forkSessionId?: string
@@ -246,7 +249,7 @@ export class CoderGatewayClient extends EventEmitter implements IGatewayClient {
246249
// CODER_MODEL env var — highest-priority model override.
247250
// Check before resolveModelConfig so the env var wins over settings.json.
248251
const coderModel = process.env.CODER_MODEL
249-
let resolved: { model: string; baseUrl?: string; apiKey?: string; name: string; provider: string }
252+
let resolved: { model: string; baseUrl?: string; apiKey?: string; proxy?: string; name: string; provider: string }
250253
if (coderModel) {
251254
// Helper: resolve from a model_list entry
252255
const resolveEntry = (entry: ModelEntry, preferredModel?: string) => {
@@ -257,6 +260,7 @@ export class CoderGatewayClient extends EventEmitter implements IGatewayClient {
257260
model: selectedModel,
258261
baseUrl: entry.base_url,
259262
apiKey: entry.auth_token_env,
263+
proxy: entry.proxy,
260264
name: selectedModel,
261265
provider: entry.provider ?? inferProvider(selectedModel),
262266
}
@@ -829,6 +833,11 @@ export class CoderGatewayClient extends EventEmitter implements IGatewayClient {
829833
env.CODER_BASE_URL ??
830834
process.env.CODER_BASE_URL
831835

836+
const proxy =
837+
(modelCfg as any)?.proxy ??
838+
env.CODER_PROXY ??
839+
process.env.CODER_PROXY
840+
832841
// Check CODER_COORDINATOR_MODE env var (set by entry.tsx or manually)
833842
const coordinatorMode =
834843
this.coordinatorMode ||
@@ -845,6 +854,7 @@ export class CoderGatewayClient extends EventEmitter implements IGatewayClient {
845854
cwd: process.cwd(),
846855
apiKey,
847856
baseUrl: baseUrl || undefined,
857+
proxy: proxy || undefined,
848858
model: this.model,
849859
providerName: modelCfg?.provider,
850860
maxTurns: 100,

packages/cli/src/gateway/engine-factory.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export interface EngineFactoryOptions {
8383
apiKey?: string;
8484
/** Base URL override for the provider */
8585
baseUrl?: string;
86+
/** HTTP/HTTPS proxy URL for the provider */
87+
proxy?: string;
8688
/** Model identifier (default: from env or 'claude-sonnet-4-6') */
8789
model?: string;
8890
/** Provider name: "anthropic" | "openai" | "deepseek" | "auto" (default: "anthropic") */
@@ -240,6 +242,7 @@ export function createQueryEngine(
240242
const cwd = opts.cwd ?? process.cwd();
241243
const apiKey = opts.apiKey ?? process.env.CODER_API_KEY ?? '';
242244
const baseUrl = opts.baseUrl ?? process.env.CODER_BASE_URL;
245+
const proxy = opts.proxy ?? process.env.CODER_PROXY;
243246
const model = (opts.model && opts.model !== 'claude-sonnet-4-6') ? opts.model : process.env.CODER_MODEL ?? opts.model ?? 'claude-sonnet-4-6';
244247
const providerName = opts.providerName ?? (model.toLowerCase().includes('deepseek') ? 'deepseek' : process.env.CODER_PROVIDER ?? 'anthropic');
245248

@@ -258,6 +261,7 @@ export function createQueryEngine(
258261
const providerConfig: ProviderConfig = {
259262
apiKey,
260263
baseUrl: baseUrl || undefined,
264+
proxy: proxy || undefined,
261265
timeout: 300_000, // 5 minutes
262266
maxRetries: 3,
263267
};

packages/cli/src/gateway/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ export interface ModelOptionProvider {
357357
key_env?: string
358358
models?: string[]
359359
name: string
360+
proxy?: string | null
360361
slug: string
361362
total_models?: number
362363
warning?: string

packages/provider/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
},
2020
"dependencies": {
2121
"@anthropic-ai/sdk": "^0.99.0",
22-
"@coder/shared": "workspace:*"
22+
"@coder/shared": "workspace:*",
23+
"undici": "^8.3.0"
2324
},
2425
"devDependencies": {
2526
"typescript": "^5.8.0",

packages/provider/src/anthropic.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import Anthropic from '@anthropic-ai/sdk';
99
import type { MessageParam, ContentBlockParam, TextBlockParam, Tool } from '@anthropic-ai/sdk/resources/messages.mjs';
10+
import { ProxyAgent } from 'undici';
1011

1112
import type { Message } from '@coder/shared';
1213
import type { ToolDefinition } from '@coder/shared';
@@ -35,11 +36,17 @@ export class AnthropicProvider implements Provider {
3536

3637
constructor(config: ProviderConfig) {
3738
this.config = config;
39+
const fetchOptions: Record<string, unknown> = {};
40+
if (config.proxy) {
41+
const proxyAgent = new ProxyAgent({ uri: config.proxy });
42+
(fetchOptions as any).dispatcher = proxyAgent;
43+
}
3844
this.client = new Anthropic({
3945
apiKey: config.apiKey,
4046
baseURL: config.baseUrl,
4147
timeout: config.timeout ?? 300_000,
4248
maxRetries: 0, // We handle retries ourselves in withRetry()
49+
...(config.proxy ? { fetchOptions } : {}),
4350
});
4451
}
4552

packages/provider/src/base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export interface ProviderConfig {
1818
apiKey: string;
1919
/** Base URL override (for proxies / alternative endpoints) */
2020
baseUrl?: string;
21+
/** HTTP/HTTPS proxy URL (e.g. "http://127.0.0.1:7890") */
22+
proxy?: string;
2123
/** Request timeout in milliseconds (default: 300_000 = 5 min) */
2224
timeout?: number;
2325
/** Maximum retry attempts for retryable errors (default: 3) */

packages/provider/src/openai-compat.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import type { Message } from '@coder/shared';
1818
import type { ToolDefinition } from '@coder/shared';
19+
import { ProxyAgent } from 'undici';
1920

2021
import type {
2122
Provider,
@@ -115,6 +116,7 @@ export class OpenAICompatProvider implements Provider {
115116
protected readonly config: ProviderConfig;
116117
protected readonly defaultBaseUrl: string;
117118
protected readonly providerName: string;
119+
protected readonly proxyAgent: ProxyAgent | undefined;
118120

119121
constructor(
120122
config: ProviderConfig,
@@ -124,6 +126,7 @@ export class OpenAICompatProvider implements Provider {
124126
this.config = config;
125127
this.providerName = providerName;
126128
this.defaultBaseUrl = defaultBaseUrl;
129+
this.proxyAgent = config.proxy ? new ProxyAgent({ uri: config.proxy }) : undefined;
127130
}
128131

129132
// -----------------------------------------------------------------------
@@ -266,12 +269,16 @@ export class OpenAICompatProvider implements Provider {
266269
const reasoningState = { active: false }; // Shared mutable ref for processSSEChunk
267270

268271
try {
269-
const response = await fetch(url, {
272+
const fetchInit: Record<string, unknown> = {
270273
method: 'POST',
271274
headers: this.buildRequestHeaders(),
272275
body: JSON.stringify(requestBody),
273276
signal,
274-
});
277+
};
278+
if (this.proxyAgent) {
279+
fetchInit.dispatcher = this.proxyAgent;
280+
}
281+
const response = await fetch(url, fetchInit as RequestInit);
275282

276283
if (!response.ok) {
277284
const errorBody = await response.text().catch(() => '');

0 commit comments

Comments
 (0)