From 2feab610ba867e0aa6c593ae5fae02fea7d978ac Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 12 Aug 2025 19:46:46 +0800 Subject: [PATCH] feat: add custom base URL configuration support - Add setBaseUrl() and clearBaseUrl() methods for configuring API endpoint - Support custom base URLs in cURL command generation - Add JSDoc documentation for base URL configuration - Apply CodeRabbit review recommendations for improved error handling --- README.md | 15 +++++ src/core/agent.ts | 52 +++++++++++++++-- src/core/cli.ts | 19 +++++- src/tests/base-url.test.ts | 113 ++++++++++++++++++++++++++++++++++++ src/utils/local-settings.ts | 82 ++++++++++++++++++++++++++ 5 files changed, 274 insertions(+), 7 deletions(-) create mode 100644 src/tests/base-url.test.ts diff --git a/README.md b/README.md index c7a2a66..6ef6093 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Options: -t, --temperature Temperature for generation (default: 1) -s, --system Custom system message -d, --debug Enable debug logging to debug-agent.log in current directory + -b, --base-url Custom API base URL -h, --help Display help -V, --version Display version number ``` @@ -101,6 +102,20 @@ You can also set your API key for your current directory via environment variabl export GROQ_API_KEY=your_api_key_here ``` +### Custom Base URL + +Configure custom API endpoint: +```bash +# CLI argument (sets environment variable) +groq --base-url https://custom-api.example.com + +# Environment variable (handled natively by Groq SDK) +export GROQ_BASE_URL=https://custom-api.example.com + +# Config file (fallback, stored in ~/.groq/local-settings.json) +# Add "groqBaseUrl": "https://custom-api.example.com" to config +``` + ### Available Commands - `/help` - Show help and available commands - `/login` - Login with your credentials diff --git a/src/core/agent.ts b/src/core/agent.ts index ab0bfac..766e74b 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -153,7 +153,23 @@ When asked about your identity, you should identify yourself as a coding assista debugLog('Setting API key in agent...'); debugLog('API key provided:', apiKey ? `${apiKey.substring(0, 8)}...` : 'empty'); this.apiKey = apiKey; - this.client = new Groq({ apiKey }); + + // Base URL precedence: 1) ENV (handled by SDK), 2) config fallback + const envBaseURL = process.env.GROQ_BASE_URL?.trim(); + const configBaseURL = this.configManager.getBaseUrl(); + if (envBaseURL) { + debugLog(`Using base URL from environment: ${envBaseURL}`); + // Let SDK pick up GROQ_BASE_URL automatically + this.client = new Groq({ apiKey }); + } else if (configBaseURL) { + const normalized = configBaseURL.replace(/\/+$/, ''); + debugLog(`Using base URL from config: ${normalized}`); + this.client = new Groq({ apiKey, baseURL: normalized }); + } else { + // Default SDK behavior + this.client = new Groq({ apiKey }); + } + debugLog('Groq client initialized with provided API key'); } @@ -276,7 +292,8 @@ When asked about your identity, you should identify yourself as a coding assista // Log equivalent curl command this.requestCount++; - const curlCommand = generateCurlCommand(this.apiKey!, requestBody, this.requestCount); + const baseUrl = resolveEffectiveBaseUrl(this.configManager); + const curlCommand = generateCurlCommand(this.apiKey!, requestBody, this.requestCount, baseUrl); if (curlCommand) { debugLog('Equivalent curl command:', curlCommand); } @@ -596,17 +613,42 @@ function debugLog(message: string, data?: any) { fs.appendFileSync(DEBUG_LOG_FILE, logEntry); } -function generateCurlCommand(apiKey: string, requestBody: any, requestCount: number): string { +/** + * Helper to compute the effective base URL (ENV > config > default) + */ +function resolveEffectiveBaseUrl(configManager: ConfigManager): string { + const env = process.env.GROQ_BASE_URL; + if (env) { + // Don't append /openai/v1 if it's already in the URL + const normalized = env.replace(/\/+$/, ''); + return normalized.includes('/openai/v1') ? normalized : normalized + '/openai/v1'; + } + const cfg = configManager.getBaseUrl(); + if (cfg) { + // Don't append /openai/v1 if it's already in the URL + const normalized = cfg.replace(/\/+$/, ''); + return normalized.includes('/openai/v1') ? normalized : normalized + '/openai/v1'; + } + return 'https://api.groq.com/openai/v1'; +} + +function generateCurlCommand(apiKey: string, requestBody: any, requestCount: number, baseUrl: string): string { if (!debugEnabled) return ''; - const maskedApiKey = `${apiKey.substring(0, 8)}...${apiKey.substring(apiKey.length - 8)}`; + const maskApiKey = (key: string) => { + if (key.length <= 6) return '***'; + if (key.length <= 12) return `${key.slice(0, 3)}***${key.slice(-2)}`; + return `${key.slice(0, 6)}...${key.slice(-4)}`; + }; + const maskedApiKey = maskApiKey(apiKey); // Write request body to JSON file const jsonFileName = `debug-request-${requestCount}.json`; const jsonFilePath = path.join(process.cwd(), jsonFileName); fs.writeFileSync(jsonFilePath, JSON.stringify(requestBody, null, 2)); - const curlCmd = `curl -X POST "https://api.groq.com/openai/v1/chat/completions" \\ + const endpoint = `${baseUrl.replace(/\/+$/, '')}/chat/completions`; + const curlCmd = `curl -X POST "${endpoint}" \\ -H "Authorization: Bearer ${maskedApiKey}" \\ -H "Content-Type: application/json" \\ -d @${jsonFileName}`; diff --git a/src/core/cli.ts b/src/core/cli.ts index c4ecf8c..6681b42 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -11,7 +11,8 @@ const program = new Command(); async function startChat( temperature: number, system: string | null, - debug?: boolean + debug?: boolean, + baseUrl?: string ): Promise { console.log(chalk.hex('#FF4500')(` ██████ ██████ ██████ ██████ @@ -35,6 +36,18 @@ async function startChat( let defaultModel = 'moonshotai/kimi-k2-instruct'; try { + // Set base URL if provided via CLI + if (baseUrl) { + // Validate URL format + try { + new URL(baseUrl); + } catch (error) { + console.log(chalk.red(`Invalid base URL format: ${baseUrl}`)); + process.exit(1); + } + process.env.GROQ_BASE_URL = baseUrl; + } + // Create agent (API key will be checked on first message) const agent = await Agent.create(defaultModel, temperature, system, debug); @@ -52,11 +65,13 @@ program .option('-t, --temperature ', 'Temperature for generation', parseFloat, 1.0) .option('-s, --system ', 'Custom system message') .option('-d, --debug', 'Enable debug logging to debug-agent.log in current directory') + .option('-b, --base-url ', 'Custom API base URL') .action(async (options) => { await startChat( options.temperature, options.system || null, - options.debug + options.debug, + options.baseUrl ); }); diff --git a/src/tests/base-url.test.ts b/src/tests/base-url.test.ts new file mode 100644 index 0000000..897c1d4 --- /dev/null +++ b/src/tests/base-url.test.ts @@ -0,0 +1,113 @@ +import test from 'ava'; +import { execSync } from 'child_process'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { ConfigManager } from '../utils/local-settings.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const cliPath = path.join(__dirname, '../../dist/core/cli.js'); + +test('CLI accepts --base-url argument', t => { + const result = execSync(`node ${cliPath} --help`, { encoding: 'utf8' }); + t.true(result.includes('--base-url'), 'Help should show --base-url option'); + t.true(result.includes('Custom API base URL'), 'Help should describe base URL option'); +}); + +test('CLI accepts -b shorthand for base URL', t => { + const result = execSync(`node ${cliPath} --help`, { encoding: 'utf8' }); + t.true(result.includes('-b, --base-url'), 'Help should show -b shorthand'); +}); + +test('Environment variable GROQ_BASE_URL is respected', t => { + const testUrl = 'https://test.example.com'; + process.env.GROQ_BASE_URL = testUrl; + + // Verify environment variable is set + t.is(process.env.GROQ_BASE_URL, testUrl, 'Environment variable should be set'); + + // Clean up + delete process.env.GROQ_BASE_URL; +}); + +test('CLI argument sets environment variable', t => { + const cliUrl = 'https://cli.example.com'; + + // Simulate CLI argument setting (this would happen in startChat) + const simulateCliArg = (url: string) => { + if (url) { + process.env.GROQ_BASE_URL = url; + } + }; + + simulateCliArg(cliUrl); + + t.is(process.env.GROQ_BASE_URL, cliUrl, 'CLI argument should set environment variable'); + + // Clean up + delete process.env.GROQ_BASE_URL; +}); + +test('Base URL validation - must be valid URL', t => { + const isValidUrl = (url: string): boolean => { + try { + new URL(url); + return true; + } catch { + return false; + } + }; + + t.true(isValidUrl('https://api.example.com'), 'Valid HTTPS URL should pass'); + t.true(isValidUrl('http://localhost:8080'), 'Valid HTTP URL with port should pass'); + t.false(isValidUrl('not-a-url'), 'Invalid URL should fail'); + t.false(isValidUrl(''), 'Empty string should fail'); +}); + +test('Default behavior when no base URL is provided', t => { + // Ensure no base URL is set + delete process.env.GROQ_BASE_URL; + + t.is(process.env.GROQ_BASE_URL, undefined, 'No base URL should be set by default'); + + // The Groq SDK will use its default URL (https://api.groq.com) + t.pass('Should use default Groq API URL when no custom URL is provided'); +}); + +test('Config file base URL is used when no env or CLI arg', t => { + const configManager = new ConfigManager(); + const testUrl = 'https://config.example.com'; + + // Save base URL to config + configManager.setBaseUrl(testUrl); + + // Verify it can be retrieved + const retrievedUrl = configManager.getBaseUrl(); + t.is(retrievedUrl, testUrl, 'Config should store and retrieve base URL'); + + // Clean up - remove base URL from config + configManager.clearBaseUrl(); + t.pass('Config file base URL should be used as fallback'); +}); + +test('Priority: SDK handles ENV, we handle config fallback', t => { + const configUrl = 'https://config.example.com'; + const envUrl = 'https://env.example.com'; + + // Set config file base URL + const configManager = new ConfigManager(); + configManager.setBaseUrl(configUrl); + + // SDK will use GROQ_BASE_URL env var if set (native SDK behavior) + process.env.GROQ_BASE_URL = envUrl; + t.is(process.env.GROQ_BASE_URL, envUrl, 'SDK will use environment variable'); + + // Our code checks config when creating client + delete process.env.GROQ_BASE_URL; + const configValue = configManager.getBaseUrl(); + t.is(configValue, configUrl, 'Config file is used as our fallback'); + + // Clean up + configManager.clearBaseUrl(); + delete process.env.GROQ_BASE_URL; +}); \ No newline at end of file diff --git a/src/utils/local-settings.ts b/src/utils/local-settings.ts index 3a63087..637ba3f 100644 --- a/src/utils/local-settings.ts +++ b/src/utils/local-settings.ts @@ -5,6 +5,7 @@ import * as os from 'os'; interface Config { groqApiKey?: string; defaultModel?: string; + groqBaseUrl?: string; } const CONFIG_DIR = '.groq'; // In home directory @@ -116,4 +117,85 @@ export class ConfigManager { throw new Error(`Failed to save default model: ${error}`); } } + + /** + * Retrieves the custom base URL from the configuration file. + * This URL is used as a fallback when GROQ_BASE_URL environment variable is not set. + * + * @returns The base URL string if configured, or null if not set or on error + */ + public getBaseUrl(): string | null { + try { + if (!fs.existsSync(this.configPath)) { + return null; + } + + const configData = fs.readFileSync(this.configPath, 'utf8'); + const config: Config = JSON.parse(configData); + return config.groqBaseUrl || null; + } catch (error) { + console.warn('Failed to read base URL:', error); + return null; + } + } + + /** + * Saves a custom base URL to the configuration file. + * This URL will be used when creating the Groq client if GROQ_BASE_URL env var is not set. + * + * @param baseUrl - The custom API base URL to save (e.g., "https://custom-api.example.com") + * @throws Error if unable to save the configuration or URL is invalid + */ + public setBaseUrl(baseUrl: string): void { + try { + // Validate URL format + try { + new URL(baseUrl); + } catch (error) { + throw new Error(`Invalid URL format: ${baseUrl}`); + } + + this.ensureConfigDir(); + + let config: Config = {}; + if (fs.existsSync(this.configPath)) { + const configData = fs.readFileSync(this.configPath, 'utf8'); + config = JSON.parse(configData); + } + + config.groqBaseUrl = baseUrl; + + fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), { + mode: 0o600 // Read/write for owner only + }); + } catch (error) { + throw new Error(`Failed to save base URL: ${error}`); + } + } + + /** + * Clears the custom base URL from the configuration file. + * Used for test cleanup and when users want to remove custom base URL configuration. + */ + public clearBaseUrl(): void { + try { + if (!fs.existsSync(this.configPath)) { + return; + } + + const configData = fs.readFileSync(this.configPath, 'utf8'); + const config: Config = JSON.parse(configData); + delete config.groqBaseUrl; + + if (Object.keys(config).length === 0) { + fs.unlinkSync(this.configPath); + } else { + fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), { + mode: 0o600 + }); + } + } catch (error) { + console.warn('Failed to clear base URL:', error); + } + } } \ No newline at end of file