feat(cli): add explicit proxy option in cli (#2526)

Co-authored-by: Dcatfly <dcatfly@gmail.com>
This commit is contained in:
warjiang 2025-07-18 02:57:37 +08:00 committed by GitHub
parent 4ca471bac6
commit 606a7702de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 126 additions and 12 deletions

View File

@ -370,6 +370,9 @@ Arguments passed directly when running the CLI can override other configurations
- Example: `gemini -e my-extension -e my-other-extension` - Example: `gemini -e my-extension -e my-other-extension`
- **`--list-extensions`** (**`-l`**): - **`--list-extensions`** (**`-l`**):
- Lists all available extensions and exits. - Lists all available extensions and exits.
- **`--proxy`**:
- Sets the proxy for the CLI.
- Example: `--proxy http://localhost:7890`.
- **`--version`**: - **`--version`**:
- Displays the version of the CLI. - Displays the version of the CLI.

1
package-lock.json generated
View File

@ -11940,6 +11940,7 @@
"glob": "^10.4.5", "glob": "^10.4.5",
"google-auth-library": "^9.11.0", "google-auth-library": "^9.11.0",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0", "ignore": "^7.0.0",
"micromatch": "^4.0.8", "micromatch": "^4.0.8",
"open": "^10.1.2", "open": "^10.1.2",

View File

@ -187,6 +187,73 @@ describe('loadCliConfig', () => {
const config = await loadCliConfig(settings, [], 'test-session', argv); const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getShowMemoryUsage()).toBe(true); expect(config.getShowMemoryUsage()).toBe(true);
}); });
it(`should leave proxy to empty by default`, async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getProxy()).toBeFalsy();
});
const proxy_url = 'http://localhost:7890';
const testCases = [
{
input: {
env_name: 'https_proxy',
proxy_url,
},
expected: proxy_url,
},
{
input: {
env_name: 'http_proxy',
proxy_url,
},
expected: proxy_url,
},
{
input: {
env_name: 'HTTPS_PROXY',
proxy_url,
},
expected: proxy_url,
},
{
input: {
env_name: 'HTTP_PROXY',
proxy_url,
},
expected: proxy_url,
},
];
testCases.forEach(({ input, expected }) => {
it(`should set proxy to ${expected} according to environment variable [${input.env_name}]`, async () => {
process.env[input.env_name] = input.proxy_url;
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getProxy()).toBe(expected);
});
});
it('should set proxy when --proxy flag is present', async () => {
process.argv = ['node', 'script.js', '--proxy', 'http://localhost:7890'];
const argv = await parseArguments();
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getProxy()).toBe('http://localhost:7890');
});
it('should prioritize CLI flag over environment variable for proxy (CLI http://localhost:7890, environment variable http://localhost:7891)', async () => {
process.env['http_proxy'] = 'http://localhost:7891';
process.argv = ['node', 'script.js', '--proxy', 'http://localhost:7890'];
const argv = await parseArguments();
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getProxy()).toBe('http://localhost:7890');
});
}); });
describe('loadCliConfig telemetry', () => { describe('loadCliConfig telemetry', () => {

View File

@ -57,6 +57,7 @@ export interface CliArgs {
extensions: string[] | undefined; extensions: string[] | undefined;
listExtensions: boolean | undefined; listExtensions: boolean | undefined;
ideMode: boolean | undefined; ideMode: boolean | undefined;
proxy: string | undefined;
} }
export async function parseArguments(): Promise<CliArgs> { export async function parseArguments(): Promise<CliArgs> {
@ -182,7 +183,11 @@ export async function parseArguments(): Promise<CliArgs> {
type: 'boolean', type: 'boolean',
description: 'Run in IDE mode?', description: 'Run in IDE mode?',
}) })
.option('proxy', {
type: 'string',
description:
'Proxy for gemini client, like schema://user:password@host:port',
})
.version(await getCliVersion()) // This will enable the --version flag based on package.json .version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version') .alias('v', 'version')
.help() .help()
@ -380,6 +385,7 @@ export async function loadCliConfig(
}, },
checkpointing: argv.checkpointing || settings.checkpointing?.enabled, checkpointing: argv.checkpointing || settings.checkpointing?.enabled,
proxy: proxy:
argv.proxy ||
process.env.HTTPS_PROXY || process.env.HTTPS_PROXY ||
process.env.https_proxy || process.env.https_proxy ||
process.env.HTTP_PROXY || process.env.HTTP_PROXY ||

View File

@ -37,6 +37,7 @@
"glob": "^10.4.5", "glob": "^10.4.5",
"google-auth-library": "^9.11.0", "google-auth-library": "^9.11.0",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0", "ignore": "^7.0.0",
"micromatch": "^4.0.8", "micromatch": "^4.0.8",
"open": "^10.1.2", "open": "^10.1.2",

View File

@ -34,6 +34,7 @@ vi.mock('node:readline');
const mockConfig = { const mockConfig = {
getNoBrowser: () => false, getNoBrowser: () => false,
getProxy: () => 'http://test.proxy.com:8080',
} as unknown as Config; } as unknown as Config;
// Mock fetch globally // Mock fetch globally
@ -175,6 +176,7 @@ describe('oauth2', () => {
it('should perform login with user code', async () => { it('should perform login with user code', async () => {
const mockConfigWithNoBrowser = { const mockConfigWithNoBrowser = {
getNoBrowser: () => true, getNoBrowser: () => true,
getProxy: () => 'http://test.proxy.com:8080',
} as unknown as Config; } as unknown as Config;
const mockCodeVerifier = { const mockCodeVerifier = {

View File

@ -73,6 +73,9 @@ export async function getOauthClient(
const client = new OAuth2Client({ const client = new OAuth2Client({
clientId: OAUTH_CLIENT_ID, clientId: OAUTH_CLIENT_ID,
clientSecret: OAUTH_CLIENT_SECRET, clientSecret: OAUTH_CLIENT_SECRET,
transporterOptions: {
proxy: config.getProxy(),
},
}); });
client.on('tokens', async (tokens: Credentials) => { client.on('tokens', async (tokens: Credentials) => {

View File

@ -68,6 +68,7 @@ describe('createContentGeneratorConfig', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'), getModel: vi.fn().mockReturnValue('gemini-pro'),
setModel: vi.fn(), setModel: vi.fn(),
flashFallbackHandler: vi.fn(), flashFallbackHandler: vi.fn(),
getProxy: vi.fn(),
} as unknown as Config; } as unknown as Config;
beforeEach(() => { beforeEach(() => {

View File

@ -50,6 +50,7 @@ export type ContentGeneratorConfig = {
apiKey?: string; apiKey?: string;
vertexai?: boolean; vertexai?: boolean;
authType?: AuthType | undefined; authType?: AuthType | undefined;
proxy?: string | undefined;
}; };
export function createContentGeneratorConfig( export function createContentGeneratorConfig(
@ -67,6 +68,7 @@ export function createContentGeneratorConfig(
const contentGeneratorConfig: ContentGeneratorConfig = { const contentGeneratorConfig: ContentGeneratorConfig = {
model: effectiveModel, model: effectiveModel,
authType, authType,
proxy: config?.getProxy(),
}; };
// If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now // If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now
@ -83,11 +85,8 @@ export function createContentGeneratorConfig(
getEffectiveModel( getEffectiveModel(
contentGeneratorConfig.apiKey, contentGeneratorConfig.apiKey,
contentGeneratorConfig.model, contentGeneratorConfig.model,
).then((newModel) => { contentGeneratorConfig.proxy,
if (newModel !== contentGeneratorConfig.model) { );
config.flashFallbackHandler?.(contentGeneratorConfig.model, newModel);
}
});
return contentGeneratorConfig; return contentGeneratorConfig;
} }

View File

@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { setGlobalDispatcher, ProxyAgent } from 'undici';
import { import {
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_MODEL,
@ -20,6 +21,7 @@ import {
export async function getEffectiveModel( export async function getEffectiveModel(
apiKey: string, apiKey: string,
currentConfiguredModel: string, currentConfiguredModel: string,
proxy?: string,
): Promise<string> { ): Promise<string> {
if (currentConfiguredModel !== DEFAULT_GEMINI_MODEL) { if (currentConfiguredModel !== DEFAULT_GEMINI_MODEL) {
// Only check if the user is trying to use the specific pro model we want to fallback from. // Only check if the user is trying to use the specific pro model we want to fallback from.
@ -43,6 +45,9 @@ export async function getEffectiveModel(
const timeoutId = setTimeout(() => controller.abort(), 2000); // 500ms timeout for the request const timeoutId = setTimeout(() => controller.abort(), 2000); // 500ms timeout for the request
try { try {
if (proxy) {
setGlobalDispatcher(new ProxyAgent(proxy));
}
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {

View File

@ -6,6 +6,8 @@
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import * as https from 'https'; import * as https from 'https';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { import {
StartSessionEvent, StartSessionEvent,
EndSessionEvent, EndSessionEvent,
@ -132,12 +134,18 @@ export class ClearcutLogger {
headers: { 'Content-Length': Buffer.byteLength(body) }, headers: { 'Content-Length': Buffer.byteLength(body) },
}; };
const bufs: Buffer[] = []; const bufs: Buffer[] = [];
const req = https.request(options, (res) => { const req = https.request(
res.on('data', (buf) => bufs.push(buf)); {
res.on('end', () => { ...options,
resolve(Buffer.concat(bufs)); agent: this.getProxyAgent(),
}); },
}); (res) => {
res.on('data', (buf) => bufs.push(buf));
res.on('end', () => {
resolve(Buffer.concat(bufs));
});
},
);
req.on('error', (e) => { req.on('error', (e) => {
if (this.config?.getDebugMode()) { if (this.config?.getDebugMode()) {
console.log('Clearcut POST request error: ', e); console.log('Clearcut POST request error: ', e);
@ -499,6 +507,18 @@ export class ClearcutLogger {
}); });
} }
getProxyAgent() {
const proxyUrl = this.config?.getProxy();
if (!proxyUrl) return undefined;
// undici which is widely used in the repo can only support http & https proxy protocol,
// https://github.com/nodejs/undici/issues/2224
if (proxyUrl.startsWith('http')) {
return new HttpsProxyAgent(proxyUrl);
} else {
throw new Error('Unsupported proxy type');
}
}
shutdown() { shutdown() {
const event = new EndSessionEvent(this.config); const event = new EndSessionEvent(this.config);
this.logEndSessionEvent(event); this.logEndSessionEvent(event);

View File

@ -13,6 +13,7 @@ describe('WebFetchTool', () => {
const mockConfig = { const mockConfig = {
getApprovalMode: vi.fn(), getApprovalMode: vi.fn(),
setApprovalMode: vi.fn(), setApprovalMode: vi.fn(),
getProxy: vi.fn(),
} as unknown as Config; } as unknown as Config;
describe('shouldConfirmExecute', () => { describe('shouldConfirmExecute', () => {

View File

@ -17,6 +17,7 @@ import { Config, ApprovalMode } from '../config/config.js';
import { getResponseText } from '../utils/generateContentResponseUtilities.js'; import { getResponseText } from '../utils/generateContentResponseUtilities.js';
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js'; import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
import { convert } from 'html-to-text'; import { convert } from 'html-to-text';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
const URL_FETCH_TIMEOUT_MS = 10000; const URL_FETCH_TIMEOUT_MS = 10000;
const MAX_CONTENT_LENGTH = 100000; const MAX_CONTENT_LENGTH = 100000;
@ -81,6 +82,10 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
type: Type.OBJECT, type: Type.OBJECT,
}, },
); );
const proxy = config.getProxy();
if (proxy) {
setGlobalDispatcher(new ProxyAgent(proxy as string));
}
} }
private async executeFallback( private async executeFallback(