Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Free API keys (ending with `:fx`) now auto-detect the correct endpoint (`api-free.deepl.com`), matching the behavior of official DeepL SDKs

### Security
- Updated `minimatch` from `^9.0.5` to `^10.2.1` to fix ReDoS vulnerability (GHSA-3ppc-4f35-3m26)

Expand Down
8 changes: 6 additions & 2 deletions src/api/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export interface ProxyConfig {
}

export interface DeepLClientOptions {
usePro?: boolean;
timeout?: number;
maxRetries?: number;
baseUrl?: string;
Expand All @@ -42,6 +41,10 @@ export function sanitizeUrl(url: string): string {

const FREE_API_URL = 'https://api-free.deepl.com';
const PRO_API_URL = 'https://api.deepl.com';

export function isFreeApiKey(apiKey: string): boolean {
return apiKey.endsWith(':fx');
}
const DEFAULT_TIMEOUT = 30000;
const DEFAULT_MAX_RETRIES = 3;
const MAX_SOCKETS = 10;
Expand Down Expand Up @@ -99,7 +102,8 @@ export class HttpClient {
throw new AuthError('API key is required');
}

const baseURL = options.baseUrl ?? (options.usePro ? PRO_API_URL : FREE_API_URL);
const autoUrl = isFreeApiKey(apiKey) ? FREE_API_URL : PRO_API_URL;
const baseURL = options.baseUrl ?? autoUrl;

this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;

Expand Down
7 changes: 2 additions & 5 deletions src/cli/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,8 @@ export class AuthCommand {
// Note: No format validation - let the API determine if the key is valid
// This supports production keys (:fx suffix), free keys, and test keys
try {
// Use configured API endpoint for validation
const baseUrl = this.config.getValue<string>('api.baseUrl');
const usePro = this.config.getValue<boolean>('api.usePro');

const client = new DeepLClient(apiKey, { baseUrl, usePro });
// Let the client auto-detect the endpoint from the key suffix
const client = new DeepLClient(apiKey);
await client.getUsage(); // Test API key validity
} catch (error) {
if (error instanceof Error) {
Expand Down
1 change: 0 additions & 1 deletion src/cli/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import { ConfigService } from '../../storage/config.js';

const BOOLEAN_KEYS = [
'api.usePro',
'cache.enabled',
'output.verbose',
'output.color',
Expand Down
4 changes: 1 addition & 3 deletions src/cli/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@ export class InitCommand {
Logger.output('\nValidating API key...');

const { DeepLClient } = await import('../../api/deepl-client.js');
const baseUrl = this.config.getValue<string>('api.baseUrl');
const usePro = this.config.getValue<boolean>('api.usePro');
const client = new DeepLClient(apiKey.trim(), { baseUrl, usePro });
const client = new DeepLClient(apiKey.trim());
await client.getUsage();

this.config.set('auth.apiKey', apiKey.trim());
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/register-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function registerConfig(
.description('Manage configuration')
.addHelpText('after', `
Examples:
$ deepl config set api.usePro true
$ deepl config set api.baseUrl https://api.deepl.com
$ deepl config get auth.apiKey
$ deepl config list
$ deepl config reset
Expand Down
19 changes: 4 additions & 15 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ import { registerCompletion } from './commands/register-completion.js';
import { registerVoice } from './commands/register-voice.js';
import { registerInit } from './commands/register-init.js';
import { registerDetect } from './commands/register-detect.js';
import { validateApiUrl } from '../utils/validate-url.js';

// Get __dirname equivalent in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -99,16 +97,13 @@ async function createDeepLClient(overrideBaseUrl?: string): Promise<DeepLClient>
process.exit(ExitCode.AuthError);
}

const baseUrl = overrideBaseUrl ?? configService.getValue<string>('api.baseUrl');
const usePro = configService.getValue<boolean>('api.usePro');

if (baseUrl) {
if (overrideBaseUrl) {
const { validateApiUrl } = await import('../utils/validate-url.js');
validateApiUrl(baseUrl);
validateApiUrl(overrideBaseUrl);
}

const { DeepLClient: Client } = await import('../api/deepl-client.js');
return new Client(key, { baseUrl, usePro });
return new Client(key, { baseUrl: overrideBaseUrl });
}

// Create program
Expand Down Expand Up @@ -194,13 +189,7 @@ function getApiKeyAndOptions(): { apiKey: string; options: import('../api/http-c
process.exit(ExitCode.AuthError);
}

const baseUrl = configService.getValue<string>('api.baseUrl');
if (baseUrl) {
validateApiUrl(baseUrl);
}
const usePro = configService.getValue<boolean>('api.usePro');

return { apiKey: key, options: { baseUrl, usePro } };
return { apiKey: key, options: {} };
}

// Shared dependencies passed to register functions
Expand Down
4 changes: 1 addition & 3 deletions src/storage/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ const VALID_OUTPUT_FORMATS: readonly OutputFormat[] = [
] as const;

const BOOLEAN_CONFIG_PATHS = [
'api.usePro',
'cache.enabled',
'output.verbose',
'output.color',
Expand Down Expand Up @@ -169,8 +168,7 @@ export class ConfigService {
apiKey: undefined,
},
api: {
baseUrl: 'https://api.deepl.com',
usePro: true,
baseUrl: undefined,
},
defaults: {
sourceLang: undefined,
Expand Down
3 changes: 1 addition & 2 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ export interface DeepLConfig {
apiKey?: string;
};
api: {
baseUrl: string;
usePro: boolean;
baseUrl?: string;
};
defaults: {
sourceLang?: Language;
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/cli-success-paths.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe('CLI Success Paths E2E', () => {
function writeConfig(configDir: string, apiUrl: string): void {
const config = {
auth: { apiKey: 'mock-api-key-for-testing:fx' },
api: { baseUrl: apiUrl, usePro: false },
api: { baseUrl: apiUrl },
defaults: {
targetLangs: [],
formality: 'default',
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/cli-watch.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ describe('Watch Command E2E', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-watch-e2e-'));
const configWithDefaults = {
auth: { apiKey: 'test-key:fx' },
api: { baseUrl: 'https://api-free.deepl.com', usePro: false },
api: { baseUrl: 'https://api-free.deepl.com' },
defaults: { targetLangs: ['es', 'fr'], formality: 'default', preserveFormatting: true },
cache: { enabled: false, maxSize: 1048576, ttl: 2592000 },
output: { format: 'text', verbose: false, color: false },
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/batch-translation.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('Batch Translation Service Integration', () => {
mockConfig = createMockConfigService({
get: jest.fn(() => ({
auth: {},
api: { baseUrl: '', usePro: false },
api: { baseUrl: '' },
defaults: { targetLangs: [], formality: 'default', preserveFormatting: false },
cache: { enabled: true },
output: { format: 'text', color: true },
Expand Down
12 changes: 6 additions & 6 deletions tests/integration/cli-config-file.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('CLI --config flag integration', () => {
// Set up default config with a test API key
const defaultConfig = {
auth: { apiKey: 'default-test-key' },
api: { baseUrl: 'https://api.deepl.com', usePro: true },
api: { baseUrl: 'https://api.deepl.com' },
defaults: { sourceLang: undefined, targetLangs: [], formality: 'default', preserveFormatting: true },
cache: { enabled: true, maxSize: 1073741824, ttl: 2592000 },
output: { format: 'text', verbose: false, color: true },
Expand All @@ -50,7 +50,7 @@ describe('CLI --config flag integration', () => {
// Set up custom config with different API key
const customConfig = {
auth: { apiKey: 'custom-test-key' },
api: { baseUrl: 'https://api-free.deepl.com', usePro: false },
api: { baseUrl: 'https://api-free.deepl.com' },
defaults: { sourceLang: 'en', targetLangs: ['es', 'fr'], formality: 'more', preserveFormatting: false },
cache: { enabled: false, maxSize: 104857600, ttl: 86400 },
output: { format: 'json', verbose: true, color: false },
Expand Down Expand Up @@ -79,7 +79,7 @@ describe('CLI --config flag integration', () => {
});

it('should read nested config values from custom config', () => {
const output = runCLI(`deepl --config "${customConfigPath}" config get api.usePro`);
const output = runCLI(`deepl --config "${customConfigPath}" config get cache.enabled`);
expect(output.trim()).toBe('false');
});

Expand Down Expand Up @@ -116,15 +116,15 @@ describe('CLI --config flag integration', () => {
const config = JSON.parse(output.trim());
// API keys are masked in list output
expect(config.auth.apiKey).toBe('defa...-key');
expect(config.api.usePro).toBe(true);
expect(config.api.baseUrl).toBe('https://api.deepl.com');
});

it('should list custom config when --config is specified', () => {
const output = runCLI(`deepl --config "${customConfigPath}" config list`);
const config = JSON.parse(output.trim());
// API keys are masked in list output
expect(config.auth.apiKey).toBe('cust...-key');
expect(config.api.usePro).toBe(false);
expect(config.api.baseUrl).toBe('https://api-free.deepl.com');
expect(config.defaults.formality).toBe('more');
});
});
Expand Down Expand Up @@ -223,7 +223,7 @@ describe('CLI --config flag integration', () => {
const upperCasePath = path.join(testDir, 'config.JSON');
fs.writeFileSync(upperCasePath, JSON.stringify({
auth: { apiKey: 'upper-case-key' },
api: { baseUrl: 'https://api.deepl.com', usePro: true },
api: { baseUrl: 'https://api.deepl.com' },
defaults: { targetLangs: [], formality: 'default', preserveFormatting: true },
cache: { enabled: true, maxSize: 1073741824, ttl: 2592000 },
output: { format: 'text', verbose: false, color: true },
Expand Down
1 change: 0 additions & 1 deletion tests/integration/cli-config.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ describe('Config CLI Integration', () => {
auth: { apiKey: undefined },
api: {
baseUrl: 'https://api-free.deepl.com/v2',
usePro: false,
},
defaults: {
sourceLang: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('Structured File Translation CLI Integration', () => {
path.join(testConfig.path, 'config.json'),
JSON.stringify({
auth: { apiKey: 'test-api-key-123' },
api: { baseUrl: 'https://api-free.deepl.com/v2', usePro: false },
api: { baseUrl: 'https://api-free.deepl.com/v2' },
defaults: { sourceLang: undefined, targetLangs: [], formality: 'default', preserveFormatting: true },
cache: { enabled: false, maxSize: 1073741824, ttl: 2592000 },
})
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/cli-style-rules.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,8 +499,8 @@ describe('Style Rules API Integration', () => {
expect(receivedBody).toBe('');
});

it('should use Pro API URL when configured', async () => {
const proClient = new DeepLClient(API_KEY, { usePro: true });
it('should use Pro API URL for pro keys', async () => {
const proClient = new DeepLClient('test-pro-api-key');
const proCommand = new StyleRulesCommand(new StyleRulesService(proClient));

const scope = nock('https://api.deepl.com')
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/cli-translate.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe('Translate CLI Integration', () => {
const configPath = path.join(testConfigDir, 'config.json');
const config = {
auth: {},
api: { baseUrl: 'https://api.deepl.com', usePro: true },
api: {},
defaults: {
targetLangs: ['es'],
formality: 'default',
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/cli-voice.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ describe('Voice CLI Integration', () => {
const configPath = path.join(testConfig.path, 'config.json');
fs.writeFileSync(configPath, JSON.stringify({
auth: { apiKey: 'test-key-for-url-validation' },
api: { baseUrl: 'http://evil-server.example.com/v2', usePro: false },
api: { baseUrl: 'http://evil-server.example.com/v2' },
}));

expect.assertions(1);
Expand Down
28 changes: 22 additions & 6 deletions tests/integration/deepl-client.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ describe('DeepLClient Integration', () => {
expect(() => new DeepLClient('')).toThrow('API key is required');
});

it('should use free API URL by default', async () => {
const client = new DeepLClient(API_KEY);
it('should auto-detect free API URL from :fx key suffix', async () => {
const client = new DeepLClient(API_KEY); // API_KEY ends with :fx
clients.push(client);

const scope = nock(FREE_API_URL)
Expand All @@ -45,8 +45,9 @@ describe('DeepLClient Integration', () => {
expect(scope.isDone()).toBe(true);
});

it('should use pro API URL when usePro is true', async () => {
const client = new DeepLClient(API_KEY, { usePro: true });
it('should auto-detect pro API URL for keys without :fx suffix', async () => {
const proKey = 'test-pro-api-key';
const client = new DeepLClient(proKey);
clients.push(client);

const scope = nock(PRO_API_URL)
Expand All @@ -69,6 +70,20 @@ describe('DeepLClient Integration', () => {
await client.getUsage();
expect(scope.isDone()).toBe(true);
});

it('should use explicit baseUrl even for :fx keys', async () => {
const freeKey = 'my-free-api-key:fx';
const customUrl = 'https://custom-deepl.example.com';
const client = new DeepLClient(freeKey, { baseUrl: customUrl });
clients.push(client);

const scope = nock(customUrl)
.get('/v2/usage')
.reply(200, { character_count: 0, character_limit: 500000 });

await client.getUsage();
expect(scope.isDone()).toBe(true);
});
});

describe('translate()', () => {
Expand Down Expand Up @@ -639,8 +654,9 @@ describe('DeepLClient Integration', () => {
expect(scope.isDone()).toBe(true);
});

it('should use pro API URL when usePro is true', async () => {
const client = new DeepLClient(API_KEY, { usePro: true });
it('should auto-detect pro API URL for pro key', async () => {
const proKey = 'test-pro-api-key';
const client = new DeepLClient(proKey);
clients.push(client);

const scope = nock(PRO_API_URL)
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/auth-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ describe('AuthCommand', () => {
expect(mockGetUsage).toHaveBeenCalled();
});

it('should create client without config baseUrl to allow auto-detection', async () => {
const mockGetUsage = jest.fn().mockResolvedValue({ character: { count: 0, limit: 500000 } });
(DeepLClient as jest.MockedClass<typeof DeepLClient>).mockImplementation(() => ({
getUsage: mockGetUsage,
} as any));

await authCommand.setKey('test-key:fx');

// Client should be created with just the key, no baseUrl from config
expect(DeepLClient).toHaveBeenCalledWith('test-key:fx');
});

it('should throw error for empty API key', async () => {
await expect(authCommand.setKey('')).rejects.toThrow('API key cannot be empty');
});
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/cli-translate-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('Translation Workflow Integration', () => {
mockConfig = createMockConfigService({
get: jest.fn(() => ({
auth: {},
api: { baseUrl: '', usePro: false },
api: { baseUrl: '' },
defaults: { targetLangs: [], formality: 'default', preserveFormatting: false },
cache: { enabled: true },
output: { format: 'text', color: true },
Expand Down Expand Up @@ -87,7 +87,7 @@ describe('Translation Workflow Integration', () => {
mockConfig = createMockConfigService({
get: jest.fn(() => ({
auth: {},
api: { baseUrl: '', usePro: false },
api: { baseUrl: '' },
defaults: {
targetLangs: [],
sourceLang: 'en',
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe('CLI Entry Point', () => {
const configPath = path.join(testConfigDir, 'config.json');
fs.writeFileSync(configPath, JSON.stringify({
auth: {},
api: { baseUrl: 'https://api.deepl.com', usePro: true },
api: { baseUrl: 'https://api.deepl.com' },
defaults: { targetLangs: [], formality: 'default', preserveFormatting: true },
cache: { enabled: true, maxSize: 1073741824, ttl: 2592000 },
output: { format: 'text', verbose: false, color: false },
Expand All @@ -103,7 +103,7 @@ describe('CLI Entry Point', () => {
const configPath = path.join(testConfigDir, 'config.json');
fs.writeFileSync(configPath, JSON.stringify({
auth: {},
api: { baseUrl: 'https://api.deepl.com', usePro: true },
api: { baseUrl: 'https://api.deepl.com' },
defaults: { targetLangs: [], formality: 'default', preserveFormatting: true },
cache: { enabled: true, maxSize: 1073741824, ttl: 2592000 },
output: { format: 'text', verbose: false, color: true },
Expand Down
Loading
Loading