Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Options:
-t, --temperature <temp> Temperature for generation (default: 1)
-s, --system <message> Custom system message
-d, --debug Enable debug logging to debug-agent.log in current directory
-b, --base-url <url> Custom API base URL
-h, --help Display help
-V, --version Display version number
```
Expand All @@ -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
Expand Down
52 changes: 47 additions & 5 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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}`;
Expand Down
19 changes: 17 additions & 2 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ const program = new Command();
async function startChat(
temperature: number,
system: string | null,
debug?: boolean
debug?: boolean,
baseUrl?: string
): Promise<void> {
console.log(chalk.hex('#FF4500')(`
β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
Expand All @@ -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);

Expand All @@ -52,11 +65,13 @@ program
.option('-t, --temperature <temperature>', 'Temperature for generation', parseFloat, 1.0)
.option('-s, --system <message>', 'Custom system message')
.option('-d, --debug', 'Enable debug logging to debug-agent.log in current directory')
.option('-b, --base-url <url>', 'Custom API base URL')
.action(async (options) => {
await startChat(
options.temperature,
options.system || null,
options.debug
options.debug,
options.baseUrl
);
});

Expand Down
113 changes: 113 additions & 0 deletions src/tests/base-url.test.ts
Original file line number Diff line number Diff line change
@@ -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;
});
82 changes: 82 additions & 0 deletions src/utils/local-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as os from 'os';
interface Config {
groqApiKey?: string;
defaultModel?: string;
groqBaseUrl?: string;
}

const CONFIG_DIR = '.groq'; // In home directory
Expand Down Expand Up @@ -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);
}
}
}