diff --git a/README.md b/README.md index 4c4396ec0cf..518000f0645 100644 --- a/README.md +++ b/README.md @@ -210,20 +210,43 @@ Use API keys for OpenAI or other compatible providers: 1. **Environment Variables** + **For Standard OpenAI:** + ```bash export OPENAI_API_KEY="your_api_key_here" export OPENAI_BASE_URL="your_api_endpoint" export OPENAI_MODEL="your_model_choice" ``` + **For Azure OpenAI:** + + ```bash + export AZURE_OPENAI_ENDPOINT="https://your-resource-name.openai.azure.com" + export AZURE_OPENAI_DEPLOYMENT="your-deployment-name" + export AZURE_OPENAI_API_KEY="your-azure-api-key" + export AZURE_OPENAI_API_VERSION="2024-05-01-preview" + ``` + 2. **Project `.env` File** Create a `.env` file in your project root: + + **For Standard OpenAI:** + ```env OPENAI_API_KEY=your_api_key_here OPENAI_BASE_URL=your_api_endpoint OPENAI_MODEL=your_model_choice ``` + **For Azure OpenAI:** + + ```env + AZURE_OPENAI_ENDPOINT=https://your-resource-name.openai.azure.com + AZURE_OPENAI_DEPLOYMENT=your-deployment-name + AZURE_OPENAI_API_KEY=your-azure-api-key + AZURE_OPENAI_API_VERSION=2024-05-01-preview + ``` + **API Provider Options** > ⚠️ **Regional Notice:** diff --git a/docs/cli/authentication.md b/docs/cli/authentication.md index 1ad751c9622..c296c329b2f 100644 --- a/docs/cli/authentication.md +++ b/docs/cli/authentication.md @@ -41,28 +41,49 @@ Qwen Code supports two main authentication methods to access AI models. Choose t a) **Environment Variables:** + **For Standard OpenAI:** + ```bash export OPENAI_API_KEY="your_api_key_here" export OPENAI_BASE_URL="your_api_endpoint" # Optional export OPENAI_MODEL="your_model_choice" # Optional ``` + **For Azure OpenAI:** + + ```bash + export AZURE_OPENAI_ENDPOINT="https://your-resource-name.openai.azure.com" + export AZURE_OPENAI_DEPLOYMENT="your-deployment-name" + export AZURE_OPENAI_API_KEY="your-azure-api-key" + export AZURE_OPENAI_API_VERSION="2024-05-01-preview" # Optional + ``` + b) **Project `.env` File:** Create a `.env` file in your project root: + **For Standard OpenAI:** + ```env OPENAI_API_KEY=your_api_key_here OPENAI_BASE_URL=your_api_endpoint OPENAI_MODEL=your_model_choice ``` + **For Azure OpenAI:** + + ```env + AZURE_OPENAI_ENDPOINT=https://your-resource-name.openai.azure.com + AZURE_OPENAI_DEPLOYMENT=your-deployment-name + AZURE_OPENAI_API_KEY=your-azure-api-key + AZURE_OPENAI_API_VERSION=2024-05-01-preview + ``` + **Supported Providers:** - OpenAI (https://platform.openai.com/api-keys) + - Azure OpenAI - Alibaba Cloud Bailian - ModelScope - OpenRouter - - Azure OpenAI - - Any OpenAI-compatible API ## Switching Authentication Methods diff --git a/docs/cli/openai-auth.md b/docs/cli/openai-auth.md index 9dd8c0caa2c..3a34c7e3e5d 100644 --- a/docs/cli/openai-auth.md +++ b/docs/cli/openai-auth.md @@ -6,12 +6,21 @@ Qwen Code CLI supports OpenAI authentication for users who want to use OpenAI mo ### 1. Interactive Authentication (Recommended) -When you first run the CLI and select OpenAI as your authentication method, you'll be prompted to enter: +When you first run the CLI and select OpenAI as your authentication method, you'll be prompted to enter either: + +**Standard OpenAI:** - **API Key**: Your OpenAI API key from [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys) - **Base URL**: The base URL for OpenAI API (defaults to `https://api.openai.com/v1`) - **Model**: The OpenAI model to use (defaults to `gpt-4o`) +**OR Azure OpenAI:** + +- **Endpoint**: Your Azure OpenAI endpoint (e.g., `https://your-resource-name.openai.azure.com`) +- **Deployment**: Your Azure OpenAI deployment name +- **API Key**: Your Azure OpenAI API key +- **API Version**: The Azure OpenAI API version (defaults to `2024-05-01-preview`) + The CLI will guide you through each field: 1. Enter your API key and press Enter @@ -33,18 +42,35 @@ qwen-code --openai-api-key "your-api-key-here" --openai-base-url "https://your-c # With custom model qwen-code --openai-api-key "your-api-key-here" --model "gpt-4-turbo" + +# Azure OpenAI usage +qwen-code --azure-openai-endpoint "https://your-resource-name.openai.azure.com" \ + --azure-openai-deployment "your-deployment-name" \ + --azure-openai-api-key "your-azure-api-key" \ + --azure-openai-api-version "2024-05-01-preview" ``` ### 3. Environment Variables Set the following environment variables in your shell or `.env` file: +**For Standard OpenAI:** + ```bash export OPENAI_API_KEY="your-api-key-here" export OPENAI_BASE_URL="https://api.openai.com/v1" # Optional, defaults to this value export OPENAI_MODEL="gpt-4o" # Optional, defaults to gpt-4o ``` +**For Azure OpenAI:** + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-resource-name.openai.azure.com" +export AZURE_OPENAI_DEPLOYMENT="your-deployment-name" +export AZURE_OPENAI_API_KEY="your-azure-api-key" +export AZURE_OPENAI_API_VERSION="2024-05-01-preview" # Optional, defaults to 2024-05-01-preview +``` + ## Supported Models The CLI supports all OpenAI models that are available through the OpenAI API, including: diff --git a/package-lock.json b/package-lock.json index 62353a65245..b91ed09c9e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -357,6 +357,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -380,6 +381,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1593,6 +1595,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.15.1.tgz", "integrity": "sha512-W/XlN9c528yYn+9MQkVjxiTPgPxoxt+oczfjHBDsJx0+59+O7B75Zhsp0B16Xbwbz8ANISDajh6+V7nIcPMc5w==", "license": "MIT", + "peer": true, "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", @@ -1915,6 +1918,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2902,8 +2906,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/body-parser": { "version": "1.19.6", @@ -3193,6 +3196,7 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3203,6 +3207,7 @@ "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -3370,6 +3375,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -3814,6 +3820,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5368,8 +5375,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -5837,6 +5843,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -7510,6 +7517,7 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz", "integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -8828,7 +8836,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10487,6 +10494,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10497,6 +10505,7 @@ "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -10530,6 +10539,7 @@ "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -12032,6 +12042,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12205,7 +12216,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -12373,6 +12385,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12713,6 +12726,7 @@ "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -12826,6 +12840,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12839,6 +12854,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -13411,6 +13427,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -13542,7 +13559,6 @@ "version": "10.4.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -13563,7 +13579,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -13605,7 +13620,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -13616,7 +13630,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -13628,7 +13641,6 @@ "version": "5.3.0", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -13642,8 +13654,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "packages/cli/node_modules/string-width": { "version": "7.2.0", @@ -13779,6 +13790,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/config/auth.ts b/packages/cli/src/config/auth.ts index 216066f36ad..2b818501e77 100644 --- a/packages/cli/src/config/auth.ts +++ b/packages/cli/src/config/auth.ts @@ -39,9 +39,43 @@ export const validateAuthMethod = (authMethod: string): string | null => { return null; } + if (authMethod === AuthType.AZURE_OPENAI) { + const hasEndpoint = !!process.env.AZURE_OPENAI_ENDPOINT; + const hasDeployment = !!process.env.AZURE_OPENAI_DEPLOYMENT; + const hasApiKey = !!process.env.AZURE_OPENAI_API_KEY; + const hasBearerToken = !!process.env.AZURE_OPENAI_BEARER_TOKEN; + const hasCred = hasApiKey || hasBearerToken; + + const missingVars = []; + if (!hasEndpoint) missingVars.push('AZURE_OPENAI_ENDPOINT'); + if (!hasDeployment) missingVars.push('AZURE_OPENAI_DEPLOYMENT'); + if (!hasCred) + missingVars.push('AZURE_OPENAI_API_KEY or AZURE_OPENAI_BEARER_TOKEN'); + + if (!hasEndpoint || !hasDeployment || !hasCred) { + return ( + 'Azure OpenAI configuration incomplete. Missing:\n' + + '• ' + + missingVars.join('\n• ') + + '\n' + + 'You can enter these interactively or add them to your .env file.' + ); + } + return null; + } + if (authMethod === AuthType.USE_OPENAI) { - if (!process.env['OPENAI_API_KEY']) { - return 'OPENAI_API_KEY environment variable not found. You can enter it interactively or add it to your .env file.'; + const isOpenAIKeySet = !!process.env.OPENAI_API_KEY; + + // For USE_OPENAI auth type, only validate OpenAI API key + // Azure configuration should use AZURE_OPENAI auth type instead + if (!isOpenAIKeySet) { + return ( + 'OpenAI configuration not found. You must set:\n' + + '• OPENAI_API_KEY environment variable for standard OpenAI\n' + + 'You can enter this interactively or add it to your .env file.\n' + + '\nNote: For Azure OpenAI, please select "Azure OpenAI" authentication instead.' + ); } return null; } @@ -59,6 +93,20 @@ export const setOpenAIApiKey = (apiKey: string): void => { process.env['OPENAI_API_KEY'] = apiKey; }; +export const setAzureOpenAIConfig = ( + endpoint: string, + deployment: string, + apiKey: string, + apiVersion?: string, +): void => { + process.env.AZURE_OPENAI_ENDPOINT = endpoint; + process.env.AZURE_OPENAI_DEPLOYMENT = deployment; + process.env.AZURE_OPENAI_API_KEY = apiKey; + if (apiVersion) { + process.env.AZURE_OPENAI_API_VERSION = apiVersion; + } +}; + export const setOpenAIBaseUrl = (baseUrl: string): void => { process.env['OPENAI_BASE_URL'] = baseUrl; }; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index da06e35c81e..79d997eaad0 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -111,6 +111,10 @@ export interface CliArgs { openaiLogging: boolean | undefined; openaiApiKey: string | undefined; openaiBaseUrl: string | undefined; + azureOpenAIEndpoint?: string | undefined; + azureOpenAIDeployment?: string | undefined; + azureOpenAIApiKey?: string | undefined; + azureOpenAIApiVersion?: string | undefined; proxy: string | undefined; includeDirectories: string[] | undefined; tavilyApiKey: string | undefined; @@ -290,6 +294,22 @@ export async function parseArguments(settings: Settings): Promise { 'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.', default: process.env['VLM_SWITCH_MODE'], }) + .option('azure-openai-endpoint', { + type: 'string', + description: 'Azure OpenAI endpoint (for Azure OpenAI deployments)', + }) + .option('azure-openai-deployment', { + type: 'string', + description: 'Azure OpenAI deployment name', + }) + .option('azure-openai-api-key', { + type: 'string', + description: 'Azure OpenAI API key', + }) + .option('azure-openai-api-version', { + type: 'string', + description: 'Azure OpenAI API version (default: 2024-05-01-preview)', + }) .check((argv) => { if (argv.prompt && argv['promptInteractive']) { throw new Error( @@ -420,6 +440,20 @@ export async function loadCliConfig( process.env['OPENAI_BASE_URL'] = argv.openaiBaseUrl; } + // Handle Azure OpenAI configuration from command line + if (argv.azureOpenAIEndpoint) { + process.env.AZURE_OPENAI_ENDPOINT = argv.azureOpenAIEndpoint; + } + if (argv.azureOpenAIDeployment) { + process.env.AZURE_OPENAI_DEPLOYMENT = argv.azureOpenAIDeployment; + } + if (argv.azureOpenAIApiKey) { + process.env.AZURE_OPENAI_API_KEY = argv.azureOpenAIApiKey; + } + if (argv.azureOpenAIApiVersion) { + process.env.AZURE_OPENAI_API_VERSION = argv.azureOpenAIApiVersion; + } + // Handle Tavily API key from command line if (argv.tavilyApiKey) { process.env['TAVILY_API_KEY'] = argv.tavilyApiKey; diff --git a/packages/cli/src/ui/components/AuthDialog.tsx b/packages/cli/src/ui/components/AuthDialog.tsx index 93c6a6b952b..d022576c492 100644 --- a/packages/cli/src/ui/components/AuthDialog.tsx +++ b/packages/cli/src/ui/components/AuthDialog.tsx @@ -47,9 +47,15 @@ export function AuthDialog({ initialErrorMessage || null, ); const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false); + // Track which prompt variant to render to avoid showing both sets of fields at once + const [promptVariant, setPromptVariant] = useState<'openai' | 'azure'>( + 'openai', + ); + const items = [ { label: 'Qwen OAuth', value: AuthType.QWEN_OAUTH }, { label: 'OpenAI', value: AuthType.USE_OPENAI }, + { label: 'Azure OpenAI', value: AuthType.AZURE_OPENAI }, ]; const initialAuthIndex = Math.max( @@ -77,15 +83,43 @@ export function AuthDialog({ const handleAuthSelect = (authMethod: AuthType) => { const error = validateAuthMethod(authMethod); if (error) { - if ( - authMethod === AuthType.USE_OPENAI && - !process.env['OPENAI_API_KEY'] - ) { - setShowOpenAIKeyPrompt(true); - setErrorMessage(null); - } else { - setErrorMessage(error); + // Decide which prompt variant is needed based on missing configuration + if (authMethod === AuthType.USE_OPENAI) { + // For OpenAI, check if we have a standard OpenAI API key + const hasOpenAIKey = !!process.env.OPENAI_API_KEY; + // Also check if user might be trying to use Azure config with OpenAI auth type + const hasAzureConfig = !!( + process.env.AZURE_OPENAI_ENDPOINT && + process.env.AZURE_OPENAI_DEPLOYMENT && + (process.env.AZURE_OPENAI_API_KEY || + process.env.AZURE_OPENAI_BEARER_TOKEN) + ); + + if (!hasOpenAIKey && !hasAzureConfig) { + setPromptVariant('openai'); + setShowOpenAIKeyPrompt(true); + setErrorMessage(null); + return; + } + } else if (authMethod === AuthType.AZURE_OPENAI) { + // For Azure OpenAI, specifically check for Azure configuration + const missingAzure = + !( + process.env.AZURE_OPENAI_API_KEY || + process.env.AZURE_OPENAI_BEARER_TOKEN + ) || + !process.env.AZURE_OPENAI_ENDPOINT || + !process.env.AZURE_OPENAI_DEPLOYMENT; + + if (missingAzure) { + setPromptVariant('azure'); + setShowOpenAIKeyPrompt(true); + setErrorMessage(null); + return; + } } + // If we get here, show the validation error + setErrorMessage(error); } else { setErrorMessage(null); onSelect(authMethod, SettingScope.User); @@ -97,11 +131,21 @@ export function AuthDialog({ baseUrl: string, model: string, ) => { - setOpenAIApiKey(apiKey); - setOpenAIBaseUrl(baseUrl); - setOpenAIModel(model); + // Don't set OpenAI environment variables if we're in Azure mode + if (promptVariant === 'openai') { + setOpenAIApiKey(apiKey); + setOpenAIBaseUrl(baseUrl); + setOpenAIModel(model); + } + // Azure environment variables are already set in the prompt component setShowOpenAIKeyPrompt(false); - onSelect(AuthType.USE_OPENAI, SettingScope.User); + + // Decide which auth type is appropriate based on the prompt variant + // This ensures we don't accidentally detect Azure config when user intended OpenAI + const selectedAuthType = + promptVariant === 'azure' ? AuthType.AZURE_OPENAI : AuthType.USE_OPENAI; + + onSelect(selectedAuthType, SettingScope.User); }; const handleOpenAIKeyCancel = () => { @@ -139,6 +183,7 @@ export function AuthDialog({ ); } diff --git a/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx b/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx index 119e50303c2..ce835ba3c6a 100644 --- a/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx +++ b/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx @@ -9,67 +9,121 @@ import { useState } from 'react'; import { Box, Text, useInput } from 'ink'; import { Colors } from '../colors.js'; +type PromptVariant = 'openai' | 'azure'; + interface OpenAIKeyPromptProps { onSubmit: (apiKey: string, baseUrl: string, model: string) => void; onCancel: () => void; + variant?: PromptVariant; // default to 'openai' when not provided } export function OpenAIKeyPrompt({ onSubmit, onCancel, + variant = 'openai', }: OpenAIKeyPromptProps): React.JSX.Element { const [apiKey, setApiKey] = useState(''); const [baseUrl, setBaseUrl] = useState(''); const [model, setModel] = useState(''); + const [azureEndpoint, setAzureEndpoint] = useState(''); + const [azureDeployment, setAzureDeployment] = useState(''); + const [azureApiKey, setAzureApiKey] = useState(''); + const [azureApiVersion, setAzureApiVersion] = useState(''); const [currentField, setCurrentField] = useState< - 'apiKey' | 'baseUrl' | 'model' - >('apiKey'); + | 'apiKey' + | 'baseUrl' + | 'model' + | 'azureEndpoint' + | 'azureDeployment' + | 'azureApiKey' + | 'azureApiVersion' + >(variant === 'azure' ? 'azureEndpoint' : 'apiKey'); useInput((input, key) => { - // 过滤粘贴相关的控制序列 + // Robustly strip terminal focus and ANSI control sequences to avoid spurious '0'/'I' + // Remove CSI sequences like ESC[... let cleanInput = (input || '') - // 过滤 ESC 开头的控制序列(如 \u001b[200~、\u001b[201~ 等) - .replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // eslint-disable-line no-control-regex - // 过滤粘贴开始标记 [200~ - .replace(/\[200~/g, '') - // 过滤粘贴结束标记 [201~ - .replace(/\[201~/g, '') - // 过滤单独的 [ 和 ~ 字符(可能是粘贴标记的残留) + .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '') // eslint-disable-line no-control-regex + // Remove common bracketed paste markers + .replace(/\[200~|\[201~|\[0I|\[I|\[O/g, '') // handle possible leftover without ESC + // Remove leading/trailing stray '[' or '~' .replace(/^\[|~$/g, ''); - // 再过滤所有不可见字符(ASCII < 32,除了回车换行) + // Remove any residual "0I" or "I" that sometimes leak from focus events + if (/^(0?I|O)$/.test(cleanInput)) { + cleanInput = ''; + } + + // Filter non-printable ASCII except CR/LF which we process separately cleanInput = cleanInput .split('') .filter((ch) => ch.charCodeAt(0) >= 32) .join(''); if (cleanInput.length > 0) { - if (currentField === 'apiKey') { - setApiKey((prev) => prev + cleanInput); - } else if (currentField === 'baseUrl') { - setBaseUrl((prev) => prev + cleanInput); - } else if (currentField === 'model') { - setModel((prev) => prev + cleanInput); + if (variant === 'openai') { + if (currentField === 'apiKey') { + setApiKey((prev) => prev + cleanInput); + } else if (currentField === 'baseUrl') { + setBaseUrl((prev) => prev + cleanInput); + } else if (currentField === 'model') { + setModel((prev) => prev + cleanInput); + } + } else { + if (currentField === 'azureEndpoint') { + setAzureEndpoint((prev) => prev + cleanInput); + } else if (currentField === 'azureDeployment') { + setAzureDeployment((prev) => prev + cleanInput); + } else if (currentField === 'azureApiKey') { + setAzureApiKey((prev) => prev + cleanInput); + } else if (currentField === 'azureApiVersion') { + setAzureApiVersion((prev) => prev + cleanInput); + } } return; } - // 检查是否是 Enter 键(通过检查输入是否包含换行符) + // Enter handling (treat CR/LF as submission or advance) if (input.includes('\n') || input.includes('\r')) { - if (currentField === 'apiKey') { - // 允许空 API key 跳转到下一个字段,让用户稍后可以返回修改 - setCurrentField('baseUrl'); - return; - } else if (currentField === 'baseUrl') { - setCurrentField('model'); - return; - } else if (currentField === 'model') { - // 只有在提交时才检查 API key 是否为空 - if (apiKey.trim()) { - onSubmit(apiKey.trim(), baseUrl.trim(), model.trim()); - } else { - // 如果 API key 为空,回到 API key 字段 - setCurrentField('apiKey'); + if (variant === 'openai') { + if (currentField === 'apiKey') { + setCurrentField('baseUrl'); + return; + } else if (currentField === 'baseUrl') { + setCurrentField('model'); + return; + } else if (currentField === 'model') { + if (apiKey.trim()) { + onSubmit(apiKey.trim(), baseUrl.trim(), model.trim()); + } else { + setCurrentField('apiKey'); + } + return; + } + } else { + if (currentField === 'azureEndpoint') { + setCurrentField('azureDeployment'); + return; + } else if (currentField === 'azureDeployment') { + setCurrentField('azureApiKey'); + return; + } else if (currentField === 'azureApiKey') { + setCurrentField('azureApiVersion'); + return; + } else if (currentField === 'azureApiVersion') { + if (azureEndpoint && azureDeployment && azureApiKey) { + process.env.AZURE_OPENAI_ENDPOINT = azureEndpoint; + process.env.AZURE_OPENAI_DEPLOYMENT = azureDeployment; + process.env.AZURE_OPENAI_API_KEY = azureApiKey; + if (azureApiVersion) { + process.env.AZURE_OPENAI_API_VERSION = azureApiVersion; + } + // For Azure, use the deployment name as the model + onSubmit(azureApiKey, '', azureDeployment); + } else { + setCurrentField('azureEndpoint'); + } + return; } } return; @@ -80,45 +134,73 @@ export function OpenAIKeyPrompt({ return; } - // Handle Tab key for field navigation + // Handle Tab key for field navigation (cycle within current variant only) if (key.tab) { - if (currentField === 'apiKey') { - setCurrentField('baseUrl'); - } else if (currentField === 'baseUrl') { - setCurrentField('model'); - } else if (currentField === 'model') { - setCurrentField('apiKey'); + if (variant === 'openai') { + if (currentField === 'apiKey') setCurrentField('baseUrl'); + else if (currentField === 'baseUrl') setCurrentField('model'); + else if (currentField === 'model') setCurrentField('apiKey'); + } else { + if (currentField === 'azureEndpoint') + setCurrentField('azureDeployment'); + else if (currentField === 'azureDeployment') + setCurrentField('azureApiKey'); + else if (currentField === 'azureApiKey') + setCurrentField('azureApiVersion'); + else if (currentField === 'azureApiVersion') + setCurrentField('azureEndpoint'); } return; } - // Handle arrow keys for field navigation + // Handle arrow keys for field navigation (within variant) if (key.upArrow) { - if (currentField === 'baseUrl') { - setCurrentField('apiKey'); - } else if (currentField === 'model') { - setCurrentField('baseUrl'); + if (variant === 'openai') { + if (currentField === 'baseUrl') setCurrentField('apiKey'); + else if (currentField === 'model') setCurrentField('baseUrl'); + } else { + if (currentField === 'azureDeployment') + setCurrentField('azureEndpoint'); + else if (currentField === 'azureApiKey') + setCurrentField('azureDeployment'); + else if (currentField === 'azureApiVersion') + setCurrentField('azureApiKey'); } return; } if (key.downArrow) { - if (currentField === 'apiKey') { - setCurrentField('baseUrl'); - } else if (currentField === 'baseUrl') { - setCurrentField('model'); + if (variant === 'openai') { + if (currentField === 'apiKey') setCurrentField('baseUrl'); + else if (currentField === 'baseUrl') setCurrentField('model'); + } else { + if (currentField === 'azureEndpoint') + setCurrentField('azureDeployment'); + else if (currentField === 'azureDeployment') + setCurrentField('azureApiKey'); + else if (currentField === 'azureApiKey') + setCurrentField('azureApiVersion'); } return; } // Handle backspace - check both key.backspace and delete key if (key.backspace || key.delete) { - if (currentField === 'apiKey') { - setApiKey((prev) => prev.slice(0, -1)); - } else if (currentField === 'baseUrl') { - setBaseUrl((prev) => prev.slice(0, -1)); - } else if (currentField === 'model') { - setModel((prev) => prev.slice(0, -1)); + if (variant === 'openai') { + if (currentField === 'apiKey') setApiKey((prev) => prev.slice(0, -1)); + else if (currentField === 'baseUrl') + setBaseUrl((prev) => prev.slice(0, -1)); + else if (currentField === 'model') + setModel((prev) => prev.slice(0, -1)); + } else { + if (currentField === 'azureEndpoint') + setAzureEndpoint((prev) => prev.slice(0, -1)); + else if (currentField === 'azureDeployment') + setAzureDeployment((prev) => prev.slice(0, -1)); + else if (currentField === 'azureApiKey') + setAzureApiKey((prev) => prev.slice(0, -1)); + else if (currentField === 'azureApiVersion') + setAzureApiVersion((prev) => prev.slice(0, -1)); } return; } @@ -133,61 +215,160 @@ export function OpenAIKeyPrompt({ width="100%" > - OpenAI Configuration Required + {variant === 'azure' + ? 'Azure OpenAI Configuration Required' + : 'OpenAI Configuration Required'} - - - Please enter your OpenAI configuration. You can get an API key from{' '} - - https://bailian.console.aliyun.com/?tab=model#/api-key - - - - - - - API Key: - - - - - {currentField === 'apiKey' ? '> ' : ' '} - {apiKey || ' '} - - - - - - - Base URL: - - - - - {currentField === 'baseUrl' ? '> ' : ' '} - {baseUrl} - - - - - - - Model: - - - - - {currentField === 'model' ? '> ' : ' '} - {model} - - - + + {variant === 'openai' ? ( + <> + + + Please enter your OpenAI configuration. You can get an API key + from{' '} + + https://platform.openai.com/api-keys + + + + + + + + API Key: + + + + + {currentField === 'apiKey' ? '> ' : ' '} + {apiKey || ' '} + + + + + + + + Base URL: + + + + + {currentField === 'baseUrl' ? '> ' : ' '} + {baseUrl} + + + + + + + + Model: + + + + + {currentField === 'model' ? '> ' : ' '} + {model} + + + + + ) : ( + <> + + + + Endpoint: + + + + + {currentField === 'azureEndpoint' ? '> ' : ' '} + {azureEndpoint} + + + + + + + + Deployment: + + + + + {currentField === 'azureDeployment' ? '> ' : ' '} + {azureDeployment} + + + + + + + + API Key: + + + + + {currentField === 'azureApiKey' ? '> ' : ' '} + {azureApiKey} + + + + + + + + API Version: + + + + + {currentField === 'azureApiVersion' ? '> ' : ' '} + {azureApiVersion || '2024-05-01-preview'} + + + + + )} Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 2d160a78a75..eadab53350e 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -820,8 +820,308 @@ export async function start_sandbox( `Sandbox process exited with code: ${code}, signal: ${signal}`, ); } +<<<<<<< HEAD + // check that from path exists on host + if (!fs.existsSync(from)) { + console.error( + `ERROR: missing mount path '${from}' listed in SANDBOX_MOUNTS`, + ); + process.exit(1); + } + console.error(`SANDBOX_MOUNTS: ${from} -> ${to} (${opts})`); + args.push('--volume', mount); + } + } + } + + // expose env-specified ports on the sandbox + ports().forEach((p) => args.push('--publish', `${p}:${p}`)); + + // if DEBUG is set, expose debugging port + if (process.env.DEBUG) { + const debugPort = process.env.DEBUG_PORT || '9229'; + args.push(`--publish`, `${debugPort}:${debugPort}`); + } + + // copy proxy environment variables, replacing localhost with SANDBOX_PROXY_NAME + // copy as both upper-case and lower-case as is required by some utilities + // GEMINI_SANDBOX_PROXY_COMMAND implies HTTPS_PROXY unless HTTP_PROXY is set + const proxyCommand = process.env.GEMINI_SANDBOX_PROXY_COMMAND; + + if (proxyCommand) { + let proxy = + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy || + 'http://localhost:8877'; + proxy = proxy.replace('localhost', SANDBOX_PROXY_NAME); + if (proxy) { + args.push('--env', `HTTPS_PROXY=${proxy}`); + args.push('--env', `https_proxy=${proxy}`); // lower-case can be required, e.g. for curl + args.push('--env', `HTTP_PROXY=${proxy}`); + args.push('--env', `http_proxy=${proxy}`); + } + const noProxy = process.env.NO_PROXY || process.env.no_proxy; + if (noProxy) { + args.push('--env', `NO_PROXY=${noProxy}`); + args.push('--env', `no_proxy=${noProxy}`); + } + + // if using proxy, switch to internal networking through proxy + if (proxy) { + execSync( + `${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`, + ); + args.push('--network', SANDBOX_NETWORK_NAME); + // if proxy command is set, create a separate network w/ host access (i.e. non-internal) + // we will run proxy in its own container connected to both host network and internal network + // this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation + if (proxyCommand) { + execSync( + `${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`, + ); + } + } + } + + // name container after image, plus numeric suffix to avoid conflicts + const imageName = parseImageName(image); + let index = 0; + const containerNameCheck = execSync( + `${config.command} ps -a --format "{{.Names}}"`, + ) + .toString() + .trim(); + while (containerNameCheck.includes(`${imageName}-${index}`)) { + index++; + } + const containerName = `${imageName}-${index}`; + args.push('--name', containerName, '--hostname', containerName); + + // copy GEMINI_API_KEY(s) + if (process.env.GEMINI_API_KEY) { + args.push('--env', `GEMINI_API_KEY=${process.env.GEMINI_API_KEY}`); + } + if (process.env.GOOGLE_API_KEY) { + args.push('--env', `GOOGLE_API_KEY=${process.env.GOOGLE_API_KEY}`); + } + + // copy OPENAI_API_KEY and related env vars for Qwen + if (process.env.OPENAI_API_KEY) { + args.push('--env', `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`); + } + // copy TAVILY_API_KEY for web search tool + if (process.env.TAVILY_API_KEY) { + args.push('--env', `TAVILY_API_KEY=${process.env.TAVILY_API_KEY}`); + } + if (process.env.OPENAI_BASE_URL) { + args.push('--env', `OPENAI_BASE_URL=${process.env.OPENAI_BASE_URL}`); + } + if (process.env.OPENAI_MODEL) { + args.push('--env', `OPENAI_MODEL=${process.env.OPENAI_MODEL}`); + } + + // Copy Azure OpenAI environment variables + if (process.env.AZURE_OPENAI_ENDPOINT) { + args.push( + '--env', + `AZURE_OPENAI_ENDPOINT=${process.env.AZURE_OPENAI_ENDPOINT}`, + ); + } + if (process.env.AZURE_OPENAI_DEPLOYMENT) { + args.push( + '--env', + `AZURE_OPENAI_DEPLOYMENT=${process.env.AZURE_OPENAI_DEPLOYMENT}`, + ); + } + if (process.env.AZURE_OPENAI_API_KEY) { + args.push( + '--env', + `AZURE_OPENAI_API_KEY=${process.env.AZURE_OPENAI_API_KEY}`, + ); + } + if (process.env.AZURE_OPENAI_API_VERSION) { + args.push( + '--env', + `AZURE_OPENAI_API_VERSION=${process.env.AZURE_OPENAI_API_VERSION}`, + ); + } + + // copy GOOGLE_GENAI_USE_VERTEXAI + if (process.env.GOOGLE_GENAI_USE_VERTEXAI) { + args.push( + '--env', + `GOOGLE_GENAI_USE_VERTEXAI=${process.env.GOOGLE_GENAI_USE_VERTEXAI}`, + ); + } + + // copy GOOGLE_GENAI_USE_GCA + if (process.env.GOOGLE_GENAI_USE_GCA) { + args.push( + '--env', + `GOOGLE_GENAI_USE_GCA=${process.env.GOOGLE_GENAI_USE_GCA}`, + ); + } + + // copy GOOGLE_CLOUD_PROJECT + if (process.env.GOOGLE_CLOUD_PROJECT) { + args.push( + '--env', + `GOOGLE_CLOUD_PROJECT=${process.env.GOOGLE_CLOUD_PROJECT}`, + ); + } + + // copy GOOGLE_CLOUD_LOCATION + if (process.env.GOOGLE_CLOUD_LOCATION) { + args.push( + '--env', + `GOOGLE_CLOUD_LOCATION=${process.env.GOOGLE_CLOUD_LOCATION}`, + ); + } + + // copy GEMINI_MODEL + if (process.env.GEMINI_MODEL) { + args.push('--env', `GEMINI_MODEL=${process.env.GEMINI_MODEL}`); + } + + // copy TERM and COLORTERM to try to maintain terminal setup + if (process.env.TERM) { + args.push('--env', `TERM=${process.env.TERM}`); + } + if (process.env.COLORTERM) { + args.push('--env', `COLORTERM=${process.env.COLORTERM}`); + } + + // copy VIRTUAL_ENV if under working directory + // also mount-replace VIRTUAL_ENV directory with /sandbox.venv + // sandbox can then set up this new VIRTUAL_ENV directory using sandbox.bashrc (see below) + // directory will be empty if not set up, which is still preferable to having host binaries + if ( + process.env.VIRTUAL_ENV?.toLowerCase().startsWith(workdir.toLowerCase()) + ) { + const sandboxVenvPath = path.resolve( + SETTINGS_DIRECTORY_NAME, + 'sandbox.venv', + ); + if (!fs.existsSync(sandboxVenvPath)) { + fs.mkdirSync(sandboxVenvPath, { recursive: true }); + } + args.push( + '--volume', + `${sandboxVenvPath}:${getContainerPath(process.env.VIRTUAL_ENV)}`, + ); + args.push( + '--env', + `VIRTUAL_ENV=${getContainerPath(process.env.VIRTUAL_ENV)}`, + ); + } + + // copy additional environment variables from SANDBOX_ENV + if (process.env.SANDBOX_ENV) { + for (let env of process.env.SANDBOX_ENV.split(',')) { + if ((env = env.trim())) { + if (env.includes('=')) { + console.error(`SANDBOX_ENV: ${env}`); + args.push('--env', env); + } else { + console.error( + 'ERROR: SANDBOX_ENV must be a comma-separated list of key=value pairs', + ); + process.exit(1); + } + } + } + } + + // copy NODE_OPTIONS + const existingNodeOptions = process.env.NODE_OPTIONS || ''; + const allNodeOptions = [ + ...(existingNodeOptions ? [existingNodeOptions] : []), + ...nodeArgs, + ].join(' '); + + if (allNodeOptions.length > 0) { + args.push('--env', `NODE_OPTIONS="${allNodeOptions}"`); + } + + // set SANDBOX as container name + args.push('--env', `SANDBOX=${containerName}`); + + // for podman only, use empty --authfile to skip unnecessary auth refresh overhead + if (config.command === 'podman') { + const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json'); + fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8'); + args.push('--authfile', emptyAuthFilePath); + } + + // Determine if the current user's UID/GID should be passed to the sandbox. + // See shouldUseCurrentUserInSandbox for more details. + let userFlag = ''; + const finalEntrypoint = entrypoint(workdir); + + if (process.env.GEMINI_CLI_INTEGRATION_TEST === 'true') { + args.push('--user', 'root'); + userFlag = '--user root'; + } else if (await shouldUseCurrentUserInSandbox()) { + // For the user-creation logic to work, the container must start as root. + // The entrypoint script then handles dropping privileges to the correct user. + args.push('--user', 'root'); + + const uid = execSync('id -u').toString().trim(); + const gid = execSync('id -g').toString().trim(); + + // Instead of passing --user to the main sandbox container, we let it + // start as root, then create a user with the host's UID/GID, and + // finally switch to that user to run the gemini process. This is + // necessary on Linux to ensure the user exists within the + // container's /etc/passwd file, which is required by os.userInfo(). + const username = 'gemini'; + const homeDir = getContainerPath(os.homedir()); + + const setupUserCommands = [ + // Use -f with groupadd to avoid errors if the group already exists. + `groupadd -f -g ${gid} ${username}`, + // Create user only if it doesn't exist. Use -o for non-unique UID. + `id -u ${username} &>/dev/null || useradd -o -u ${uid} -g ${gid} -d ${homeDir} -s /bin/bash ${username}`, + ].join(' && '); + + const originalCommand = finalEntrypoint[2]; + const escapedOriginalCommand = originalCommand.replace(/'/g, "'\\''"); + + // Use `su -p` to preserve the environment. + const suCommand = `su -p ${username} -c '${escapedOriginalCommand}'`; + + // The entrypoint is always `['bash', '-c', '']`, so we modify the command part. + finalEntrypoint[2] = `${setupUserCommands} && ${suCommand}`; + + // We still need userFlag for the simpler proxy container, which does not have this issue. + userFlag = `--user ${uid}:${gid}`; + // When forcing a UID in the sandbox, $HOME can be reset to '/', so we copy $HOME as well. + args.push('--env', `HOME=${os.homedir()}`); + } + + // push container image name + args.push(image); + + // push container entrypoint (including args) + args.push(...finalEntrypoint); + + // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set + let proxyProcess: ChildProcess | undefined = undefined; + let sandboxProcess: ChildProcess | undefined = undefined; + + if (proxyCommand) { + // run proxyCommand in its own container + const proxyContainerCommand = `${config.command} run --rm --init ${userFlag} --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`; + proxyProcess = spawn(proxyContainerCommand, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + detached: true, +======= resolve(); }); +>>>>>>> main }); } finally { patcher.cleanup(); diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index 06aa1f2430f..a277f462da8 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -18,6 +18,13 @@ function getAuthTypeFromEnv(): AuthType | undefined { if (process.env['GEMINI_API_KEY']) { return AuthType.USE_GEMINI; } + if ( + process.env.AZURE_OPENAI_ENDPOINT && + process.env.AZURE_OPENAI_DEPLOYMENT && + (process.env.AZURE_OPENAI_API_KEY || process.env.AZURE_OPENAI_BEARER_TOKEN) + ) { + return AuthType.AZURE_OPENAI; + } if (process.env['OPENAI_API_KEY']) { return AuthType.USE_OPENAI; } diff --git a/packages/core/src/core/azureOpenAIContentGenerator.ts b/packages/core/src/core/azureOpenAIContentGenerator.ts new file mode 100644 index 00000000000..b4a843b7e82 --- /dev/null +++ b/packages/core/src/core/azureOpenAIContentGenerator.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + * + * Azure-specific OpenAI content generator wrapper. + * Delegates to OpenAIContentGenerator but configures the client with + * Azure endpoint / deployment / api-version headers and token or key. + */ + +import { Config } from '../config/config.js'; +import { OpenAIContentGenerator } from './openaiContentGenerator.js'; +import OpenAI from 'openai'; + +/** + * Separate wrapper so core OpenAI implementation stays provider-agnostic. + * Token provider support: if AZURE_OPENAI_BEARER_TOKEN is provided we use it + * via defaultHeaders.Authorization, otherwise we fall back to API key flow. + */ +export class AzureOpenAIContentGenerator extends OpenAIContentGenerator { + constructor(apiKey: string, model: string, config: Config) { + // Resolve Azure envs – validated upstream. + const endpoint = process.env.AZURE_OPENAI_ENDPOINT as string; + const deployment = process.env.AZURE_OPENAI_DEPLOYMENT as string; + const apiVersion = + process.env.AZURE_OPENAI_API_VERSION ?? '2024-05-01-preview'; + + // If bearer token provided we pass empty apiKey to parent and populate + // Authorization header later. Passing empty string keeps parent happy. + const bearer = process.env.AZURE_OPENAI_BEARER_TOKEN; + super(bearer ? '' : apiKey, model || deployment, config); + + // Override the underlying OpenAI client that super() created. + // Re-instantiate with Azure-specific URL / headers. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore access protected + const version = config.getCliVersion() || 'unknown'; + const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore access protected + this.client = new OpenAI({ + apiKey: bearer ? undefined : apiKey, + baseURL: `${endpoint}/openai/deployments/${deployment}`, + defaultQuery: { 'api-version': apiVersion }, + defaultHeaders: { + 'User-Agent': userAgent, + ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), + }, + timeout: config.getContentGeneratorTimeout() ?? 120_000, + maxRetries: config.getContentGeneratorMaxRetries() ?? 3, + }); + + // Ensure model equals deployment to satisfy OpenAI SDK requirement. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore model is private in parent but we need correct value + this.model = deployment; + } +} diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 5baeb1a9568..93851ce2bd5 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -48,6 +48,7 @@ export enum AuthType { USE_VERTEX_AI = 'vertex-ai', CLOUD_SHELL = 'cloud-shell', USE_OPENAI = 'openai', + AZURE_OPENAI = 'azure-openai', QWEN_OAUTH = 'qwen-oauth', } @@ -130,6 +131,21 @@ export function createContentGeneratorConfig( return contentGeneratorConfig; } + if (authType === AuthType.AZURE_OPENAI) { + // Azure OpenAI requires endpoint & deployment + const azureEndpoint = process.env.AZURE_OPENAI_ENDPOINT; + const azureDeployment = process.env.AZURE_OPENAI_DEPLOYMENT; + const azureApiKey = process.env.AZURE_OPENAI_API_KEY; + const azureBearer = process.env.AZURE_OPENAI_BEARER_TOKEN; + if (azureEndpoint && azureDeployment && (azureApiKey || azureBearer)) { + contentGeneratorConfig.apiKey = azureApiKey ?? ''; + // For Azure OpenAI, always use the deployment name as the model + // The Azure OpenAI API requires the deployment name, not a model name + contentGeneratorConfig.model = azureDeployment; + return contentGeneratorConfig; + } + } + if (authType === AuthType.USE_OPENAI && openaiApiKey) { contentGeneratorConfig.apiKey = openaiApiKey; contentGeneratorConfig.baseUrl = openaiBaseUrl; @@ -203,6 +219,20 @@ export async function createContentGenerator( return new LoggingContentGenerator(googleGenAI.models, gcConfig); } + if (config.authType === AuthType.AZURE_OPENAI) { + // Import Azure generator dynamically + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore TypeScript may not resolve the .js suffix in TS source; runtime path is correct after build. + const { AzureOpenAIContentGenerator } = await import( + './azureOpenAIContentGenerator.js' + ); + return new AzureOpenAIContentGenerator( + config.apiKey ?? '', + config.model, + gcConfig, + ); + } + if (config.authType === AuthType.USE_OPENAI) { if (!config.apiKey) { throw new Error('OpenAI API key is required'); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 9f54160165b..abd983fe664 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -188,6 +188,91 @@ export class GeminiChat { validateHistory(history); } +<<<<<<< HEAD + private _getRequestTextFromContents(contents: Content[]): string { + return JSON.stringify(contents); + } + + private async _logApiRequest( + contents: Content[], + model: string, + prompt_id: string, + ): Promise { + const requestText = this._getRequestTextFromContents(contents); + logApiRequest( + this.config, + new ApiRequestEvent(model, prompt_id, requestText), + ); + } + + private async _logApiResponse( + durationMs: number, + prompt_id: string, + usageMetadata?: GenerateContentResponseUsageMetadata, + responseText?: string, + responseId?: string, + ): Promise { + const authType = this.config.getContentGeneratorConfig()?.authType; + + // Don't log API responses for OpenAI-compatible providers + if ( + authType === AuthType.QWEN_OAUTH || + authType === AuthType.USE_OPENAI || + authType === AuthType.AZURE_OPENAI + ) { + return; + } + + logApiResponse( + this.config, + new ApiResponseEvent( + responseId || `gemini-${Date.now()}`, + this.config.getModel(), + durationMs, + prompt_id, + authType, + usageMetadata, + responseText, + ), + ); + } + + private _logApiError( + durationMs: number, + error: unknown, + prompt_id: string, + responseId?: string, + ): void { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorType = error instanceof Error ? error.name : 'unknown'; + + const authType = this.config.getContentGeneratorConfig()?.authType; + + // Don't log API errors for OpenAI-compatible providers + if ( + authType === AuthType.QWEN_OAUTH || + authType === AuthType.USE_OPENAI || + authType === AuthType.AZURE_OPENAI + ) { + return; + } + + logApiError( + this.config, + new ApiErrorEvent( + responseId, + this.config.getModel(), + errorMessage, + durationMs, + prompt_id, + authType, + errorType, + ), + ); + } + +======= +>>>>>>> main /** * Handles falling back to Flash model when persistent 429 errors occur for OAuth users. * Uses a fallback handler if provided by the config; otherwise, returns null.