Skip to content

Commit 2feab61

Browse files
committed
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
1 parent 81e258e commit 2feab61

File tree

5 files changed

+274
-7
lines changed

5 files changed

+274
-7
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Options:
7777
-t, --temperature <temp> Temperature for generation (default: 1)
7878
-s, --system <message> Custom system message
7979
-d, --debug Enable debug logging to debug-agent.log in current directory
80+
-b, --base-url <url> Custom API base URL
8081
-h, --help Display help
8182
-V, --version Display version number
8283
```
@@ -101,6 +102,20 @@ You can also set your API key for your current directory via environment variabl
101102
export GROQ_API_KEY=your_api_key_here
102103
```
103104

105+
### Custom Base URL
106+
107+
Configure custom API endpoint:
108+
```bash
109+
# CLI argument (sets environment variable)
110+
groq --base-url https://custom-api.example.com
111+
112+
# Environment variable (handled natively by Groq SDK)
113+
export GROQ_BASE_URL=https://custom-api.example.com
114+
115+
# Config file (fallback, stored in ~/.groq/local-settings.json)
116+
# Add "groqBaseUrl": "https://custom-api.example.com" to config
117+
```
118+
104119
### Available Commands
105120
- `/help` - Show help and available commands
106121
- `/login` - Login with your credentials

src/core/agent.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,23 @@ When asked about your identity, you should identify yourself as a coding assista
153153
debugLog('Setting API key in agent...');
154154
debugLog('API key provided:', apiKey ? `${apiKey.substring(0, 8)}...` : 'empty');
155155
this.apiKey = apiKey;
156-
this.client = new Groq({ apiKey });
156+
157+
// Base URL precedence: 1) ENV (handled by SDK), 2) config fallback
158+
const envBaseURL = process.env.GROQ_BASE_URL?.trim();
159+
const configBaseURL = this.configManager.getBaseUrl();
160+
if (envBaseURL) {
161+
debugLog(`Using base URL from environment: ${envBaseURL}`);
162+
// Let SDK pick up GROQ_BASE_URL automatically
163+
this.client = new Groq({ apiKey });
164+
} else if (configBaseURL) {
165+
const normalized = configBaseURL.replace(/\/+$/, '');
166+
debugLog(`Using base URL from config: ${normalized}`);
167+
this.client = new Groq({ apiKey, baseURL: normalized });
168+
} else {
169+
// Default SDK behavior
170+
this.client = new Groq({ apiKey });
171+
}
172+
157173
debugLog('Groq client initialized with provided API key');
158174
}
159175

@@ -276,7 +292,8 @@ When asked about your identity, you should identify yourself as a coding assista
276292

277293
// Log equivalent curl command
278294
this.requestCount++;
279-
const curlCommand = generateCurlCommand(this.apiKey!, requestBody, this.requestCount);
295+
const baseUrl = resolveEffectiveBaseUrl(this.configManager);
296+
const curlCommand = generateCurlCommand(this.apiKey!, requestBody, this.requestCount, baseUrl);
280297
if (curlCommand) {
281298
debugLog('Equivalent curl command:', curlCommand);
282299
}
@@ -596,17 +613,42 @@ function debugLog(message: string, data?: any) {
596613
fs.appendFileSync(DEBUG_LOG_FILE, logEntry);
597614
}
598615

599-
function generateCurlCommand(apiKey: string, requestBody: any, requestCount: number): string {
616+
/**
617+
* Helper to compute the effective base URL (ENV > config > default)
618+
*/
619+
function resolveEffectiveBaseUrl(configManager: ConfigManager): string {
620+
const env = process.env.GROQ_BASE_URL;
621+
if (env) {
622+
// Don't append /openai/v1 if it's already in the URL
623+
const normalized = env.replace(/\/+$/, '');
624+
return normalized.includes('/openai/v1') ? normalized : normalized + '/openai/v1';
625+
}
626+
const cfg = configManager.getBaseUrl();
627+
if (cfg) {
628+
// Don't append /openai/v1 if it's already in the URL
629+
const normalized = cfg.replace(/\/+$/, '');
630+
return normalized.includes('/openai/v1') ? normalized : normalized + '/openai/v1';
631+
}
632+
return 'https://api.groq.com/openai/v1';
633+
}
634+
635+
function generateCurlCommand(apiKey: string, requestBody: any, requestCount: number, baseUrl: string): string {
600636
if (!debugEnabled) return '';
601637

602-
const maskedApiKey = `${apiKey.substring(0, 8)}...${apiKey.substring(apiKey.length - 8)}`;
638+
const maskApiKey = (key: string) => {
639+
if (key.length <= 6) return '***';
640+
if (key.length <= 12) return `${key.slice(0, 3)}***${key.slice(-2)}`;
641+
return `${key.slice(0, 6)}...${key.slice(-4)}`;
642+
};
643+
const maskedApiKey = maskApiKey(apiKey);
603644

604645
// Write request body to JSON file
605646
const jsonFileName = `debug-request-${requestCount}.json`;
606647
const jsonFilePath = path.join(process.cwd(), jsonFileName);
607648
fs.writeFileSync(jsonFilePath, JSON.stringify(requestBody, null, 2));
608649

609-
const curlCmd = `curl -X POST "https://api.groq.com/openai/v1/chat/completions" \\
650+
const endpoint = `${baseUrl.replace(/\/+$/, '')}/chat/completions`;
651+
const curlCmd = `curl -X POST "${endpoint}" \\
610652
-H "Authorization: Bearer ${maskedApiKey}" \\
611653
-H "Content-Type: application/json" \\
612654
-d @${jsonFileName}`;

src/core/cli.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ const program = new Command();
1111
async function startChat(
1212
temperature: number,
1313
system: string | null,
14-
debug?: boolean
14+
debug?: boolean,
15+
baseUrl?: string
1516
): Promise<void> {
1617
console.log(chalk.hex('#FF4500')(`
1718
██████ ██████ ██████ ██████
@@ -35,6 +36,18 @@ async function startChat(
3536

3637
let defaultModel = 'moonshotai/kimi-k2-instruct';
3738
try {
39+
// Set base URL if provided via CLI
40+
if (baseUrl) {
41+
// Validate URL format
42+
try {
43+
new URL(baseUrl);
44+
} catch (error) {
45+
console.log(chalk.red(`Invalid base URL format: ${baseUrl}`));
46+
process.exit(1);
47+
}
48+
process.env.GROQ_BASE_URL = baseUrl;
49+
}
50+
3851
// Create agent (API key will be checked on first message)
3952
const agent = await Agent.create(defaultModel, temperature, system, debug);
4053

@@ -52,11 +65,13 @@ program
5265
.option('-t, --temperature <temperature>', 'Temperature for generation', parseFloat, 1.0)
5366
.option('-s, --system <message>', 'Custom system message')
5467
.option('-d, --debug', 'Enable debug logging to debug-agent.log in current directory')
68+
.option('-b, --base-url <url>', 'Custom API base URL')
5569
.action(async (options) => {
5670
await startChat(
5771
options.temperature,
5872
options.system || null,
59-
options.debug
73+
options.debug,
74+
options.baseUrl
6075
);
6176
});
6277

src/tests/base-url.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import test from 'ava';
2+
import { execSync } from 'child_process';
3+
import path from 'path';
4+
import { fileURLToPath } from 'url';
5+
import { ConfigManager } from '../utils/local-settings.js';
6+
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
const cliPath = path.join(__dirname, '../../dist/core/cli.js');
10+
11+
test('CLI accepts --base-url argument', t => {
12+
const result = execSync(`node ${cliPath} --help`, { encoding: 'utf8' });
13+
t.true(result.includes('--base-url'), 'Help should show --base-url option');
14+
t.true(result.includes('Custom API base URL'), 'Help should describe base URL option');
15+
});
16+
17+
test('CLI accepts -b shorthand for base URL', t => {
18+
const result = execSync(`node ${cliPath} --help`, { encoding: 'utf8' });
19+
t.true(result.includes('-b, --base-url'), 'Help should show -b shorthand');
20+
});
21+
22+
test('Environment variable GROQ_BASE_URL is respected', t => {
23+
const testUrl = 'https://test.example.com';
24+
process.env.GROQ_BASE_URL = testUrl;
25+
26+
// Verify environment variable is set
27+
t.is(process.env.GROQ_BASE_URL, testUrl, 'Environment variable should be set');
28+
29+
// Clean up
30+
delete process.env.GROQ_BASE_URL;
31+
});
32+
33+
test('CLI argument sets environment variable', t => {
34+
const cliUrl = 'https://cli.example.com';
35+
36+
// Simulate CLI argument setting (this would happen in startChat)
37+
const simulateCliArg = (url: string) => {
38+
if (url) {
39+
process.env.GROQ_BASE_URL = url;
40+
}
41+
};
42+
43+
simulateCliArg(cliUrl);
44+
45+
t.is(process.env.GROQ_BASE_URL, cliUrl, 'CLI argument should set environment variable');
46+
47+
// Clean up
48+
delete process.env.GROQ_BASE_URL;
49+
});
50+
51+
test('Base URL validation - must be valid URL', t => {
52+
const isValidUrl = (url: string): boolean => {
53+
try {
54+
new URL(url);
55+
return true;
56+
} catch {
57+
return false;
58+
}
59+
};
60+
61+
t.true(isValidUrl('https://api.example.com'), 'Valid HTTPS URL should pass');
62+
t.true(isValidUrl('http://localhost:8080'), 'Valid HTTP URL with port should pass');
63+
t.false(isValidUrl('not-a-url'), 'Invalid URL should fail');
64+
t.false(isValidUrl(''), 'Empty string should fail');
65+
});
66+
67+
test('Default behavior when no base URL is provided', t => {
68+
// Ensure no base URL is set
69+
delete process.env.GROQ_BASE_URL;
70+
71+
t.is(process.env.GROQ_BASE_URL, undefined, 'No base URL should be set by default');
72+
73+
// The Groq SDK will use its default URL (https://api.groq.com)
74+
t.pass('Should use default Groq API URL when no custom URL is provided');
75+
});
76+
77+
test('Config file base URL is used when no env or CLI arg', t => {
78+
const configManager = new ConfigManager();
79+
const testUrl = 'https://config.example.com';
80+
81+
// Save base URL to config
82+
configManager.setBaseUrl(testUrl);
83+
84+
// Verify it can be retrieved
85+
const retrievedUrl = configManager.getBaseUrl();
86+
t.is(retrievedUrl, testUrl, 'Config should store and retrieve base URL');
87+
88+
// Clean up - remove base URL from config
89+
configManager.clearBaseUrl();
90+
t.pass('Config file base URL should be used as fallback');
91+
});
92+
93+
test('Priority: SDK handles ENV, we handle config fallback', t => {
94+
const configUrl = 'https://config.example.com';
95+
const envUrl = 'https://env.example.com';
96+
97+
// Set config file base URL
98+
const configManager = new ConfigManager();
99+
configManager.setBaseUrl(configUrl);
100+
101+
// SDK will use GROQ_BASE_URL env var if set (native SDK behavior)
102+
process.env.GROQ_BASE_URL = envUrl;
103+
t.is(process.env.GROQ_BASE_URL, envUrl, 'SDK will use environment variable');
104+
105+
// Our code checks config when creating client
106+
delete process.env.GROQ_BASE_URL;
107+
const configValue = configManager.getBaseUrl();
108+
t.is(configValue, configUrl, 'Config file is used as our fallback');
109+
110+
// Clean up
111+
configManager.clearBaseUrl();
112+
delete process.env.GROQ_BASE_URL;
113+
});

src/utils/local-settings.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as os from 'os';
55
interface Config {
66
groqApiKey?: string;
77
defaultModel?: string;
8+
groqBaseUrl?: string;
89
}
910

1011
const CONFIG_DIR = '.groq'; // In home directory
@@ -116,4 +117,85 @@ export class ConfigManager {
116117
throw new Error(`Failed to save default model: ${error}`);
117118
}
118119
}
120+
121+
/**
122+
* Retrieves the custom base URL from the configuration file.
123+
* This URL is used as a fallback when GROQ_BASE_URL environment variable is not set.
124+
*
125+
* @returns The base URL string if configured, or null if not set or on error
126+
*/
127+
public getBaseUrl(): string | null {
128+
try {
129+
if (!fs.existsSync(this.configPath)) {
130+
return null;
131+
}
132+
133+
const configData = fs.readFileSync(this.configPath, 'utf8');
134+
const config: Config = JSON.parse(configData);
135+
return config.groqBaseUrl || null;
136+
} catch (error) {
137+
console.warn('Failed to read base URL:', error);
138+
return null;
139+
}
140+
}
141+
142+
/**
143+
* Saves a custom base URL to the configuration file.
144+
* This URL will be used when creating the Groq client if GROQ_BASE_URL env var is not set.
145+
*
146+
* @param baseUrl - The custom API base URL to save (e.g., "https://custom-api.example.com")
147+
* @throws Error if unable to save the configuration or URL is invalid
148+
*/
149+
public setBaseUrl(baseUrl: string): void {
150+
try {
151+
// Validate URL format
152+
try {
153+
new URL(baseUrl);
154+
} catch (error) {
155+
throw new Error(`Invalid URL format: ${baseUrl}`);
156+
}
157+
158+
this.ensureConfigDir();
159+
160+
let config: Config = {};
161+
if (fs.existsSync(this.configPath)) {
162+
const configData = fs.readFileSync(this.configPath, 'utf8');
163+
config = JSON.parse(configData);
164+
}
165+
166+
config.groqBaseUrl = baseUrl;
167+
168+
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), {
169+
mode: 0o600 // Read/write for owner only
170+
});
171+
} catch (error) {
172+
throw new Error(`Failed to save base URL: ${error}`);
173+
}
174+
}
175+
176+
/**
177+
* Clears the custom base URL from the configuration file.
178+
* Used for test cleanup and when users want to remove custom base URL configuration.
179+
*/
180+
public clearBaseUrl(): void {
181+
try {
182+
if (!fs.existsSync(this.configPath)) {
183+
return;
184+
}
185+
186+
const configData = fs.readFileSync(this.configPath, 'utf8');
187+
const config: Config = JSON.parse(configData);
188+
delete config.groqBaseUrl;
189+
190+
if (Object.keys(config).length === 0) {
191+
fs.unlinkSync(this.configPath);
192+
} else {
193+
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), {
194+
mode: 0o600
195+
});
196+
}
197+
} catch (error) {
198+
console.warn('Failed to clear base URL:', error);
199+
}
200+
}
119201
}

0 commit comments

Comments
 (0)