diff --git a/README.md b/README.md index 54a93ad..3406b45 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,12 @@ npm install -g claude-adapter ## Configuration +Runtime configuration priority: + +`ANTHROPIC_*` environment variables > `~/.claude-adapter/config.json` > interactive prompt. + +Environment variables are runtime-only overrides. They are not persisted back to `~/.claude-adapter/config.json`. + ### CLI Options The CLI accepts several flags to customize runtime behavior: @@ -112,6 +118,29 @@ The core of the adapter's flexibility lies in its ability to map Claude's expect | `sonnet` | Balanced tasks | `deepseek-3.2`, `minimax-m2.1` | | `haiku` | Low-latency ops | `gpt-5-mini`, `gpt-oss-120b` | +### Runtime Environment Variable Mapping + +These variables can override startup configuration for the current process: + +| Environment Variable | Runtime Target | +| -------------------- | -------------- | +| `ANTHROPIC_BASE_URL` | `baseUrl` | +| `ANTHROPIC_API_KEY` | `apiKey` (highest priority) | +| `ANTHROPIC_AUTH_TOKEN` | `apiKey` fallback when `ANTHROPIC_API_KEY` is absent | +| `ANTHROPIC_DEFAULT_OPUS_MODEL` | `models.opus` | +| `ANTHROPIC_DEFAULT_SONNET_MODEL` | `models.sonnet` | +| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | `models.haiku` | +| `ANTHROPIC_DEFAULT_MODEL` | fallback model for unset opus/sonnet/haiku | + +Example: + +```bash +ANTHROPIC_BASE_URL=https://api.openai.com/v1 \ +ANTHROPIC_API_KEY=sk-*** \ +ANTHROPIC_DEFAULT_MODEL=gpt-5-mini \ +claude-adapter --no-claude-settings +``` + --- ## API Reference diff --git a/package-lock.json b/package-lock.json index edbf83f..00d5729 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "claude-adapter": "dist/cli.js" }, "devDependencies": { + "@types/commander": "^2.12.0", "@types/inquirer": "^9.0.7", "@types/jest": "^29.5.14", "@types/node": "^22.10.2", @@ -1607,6 +1608,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/commander": { + "version": "2.12.0", + "resolved": "https://registry.npmmirror.com/@types/commander/-/commander-2.12.0.tgz", + "integrity": "sha512-DDmRkovH7jPjnx7HcbSnqKg2JeNANyxNZeUvB0iE+qKBLN+vzN5iSIwt+J2PFSmBuYEut4mgQvI/fTX9YQH/vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", diff --git a/package.json b/package.json index 3a71f1e..20dd9f8 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "openai": "4.76.0" }, "devDependencies": { + "@types/commander": "^2.12.0", "@types/inquirer": "^9.0.7", "@types/jest": "^29.5.14", "@types/node": "^22.10.2", @@ -72,4 +73,4 @@ "engines": { "node": ">=20.0.0" } -} \ No newline at end of file +} diff --git a/src/cli.ts b/src/cli.ts index bb34df6..9166953 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,10 +13,29 @@ import { createServer, findAvailablePort } from './server'; import { UI } from './utils/ui'; import { checkForUpdates } from './utils/update'; import { getMetadata } from './utils/metadata'; +import { resolveRuntimeConfig } from './utils/runtimeConfig'; import { version } from '../package.json'; const program = new Command(); +interface CliOptions { + port: string; + reconfigure?: boolean; + claudeSettings: boolean; +} + +function isValidUrl(input: string | undefined): boolean { + if (!input) { + return false; + } + try { + new URL(input); + return true; + } catch { + return false; + } +} + program .name('claude-adapter') .description('Proxy adapter to use OpenAI API with Claude Code') @@ -26,7 +45,7 @@ program .option('-p, --port ', 'Port to run the proxy server on', '3080') .option('-r, --reconfigure', 'Force reconfiguration even if config exists') .option('--no-claude-settings', 'Skip updating Claude Code settings files') - .action(async (options) => { + .action(async (options: CliOptions) => { UI.banner(); UI.header('Adapt any model for Claude Code'); @@ -42,10 +61,18 @@ program UI.info('Skipping Claude settings update (--no-claude-settings)'); } - // Step 2: Load or create configuration - let config = loadConfig(); - - if (!config || options.reconfigure) { + // Step 2: Load file config and resolve runtime config using env > file > prompt + const fileConfig = loadConfig(); + const resolvedRuntime = resolveRuntimeConfig(fileConfig, process.env); + let config = resolvedRuntime.config as AdapterConfig | null; + + if ( + !config || + options.reconfigure || + !isValidUrl(config.baseUrl) || + !config.apiKey || + !config.models?.opus + ) { UI.log(''); // Spacing config = await promptForConfiguration(); saveConfig(config); @@ -56,7 +83,9 @@ program UI.log(''); // Spacing const toolStyle = await promptForToolCallingStyle(); config.toolFormat = toolStyle; - saveConfig(config); + if (fileConfig) { + saveConfig({ ...fileConfig, toolFormat: toolStyle }); + } console.log(`\x1b[2m✔\x1b[0m Tool Format: ${UI.dim(`[${config.toolFormat.toUpperCase()}]`)}`); UI.info('Tool calling preference saved'); } else { @@ -64,6 +93,13 @@ program console.log(`\x1b[2m✔\x1b[0m Tool Format: ${UI.dim(`[${config.toolFormat.toUpperCase()}]`)}`); } + for (const warning of resolvedRuntime.warnings) { + UI.info(warning); + } + UI.info( + `Config source: baseUrl=${resolvedRuntime.sources.baseUrl}, apiKey=${resolvedRuntime.sources.apiKey}, models(opus=${resolvedRuntime.sources.models.opus}, sonnet=${resolvedRuntime.sources.models.sonnet}, haiku=${resolvedRuntime.sources.models.haiku})` + ); + // Step 3: Find available port and start server const preferredPort = parseInt(options.port, 10) || 3080; const port = await findAvailablePort(preferredPort); diff --git a/src/utils/runtimeConfig.ts b/src/utils/runtimeConfig.ts new file mode 100644 index 0000000..b18c23a --- /dev/null +++ b/src/utils/runtimeConfig.ts @@ -0,0 +1,115 @@ +import { AdapterConfig, ModelConfig } from '../types/config'; + +type ConfigSource = 'env' | 'file' | 'prompt' | 'fallback'; + +export interface RuntimeConfigSources { + baseUrl: ConfigSource; + apiKey: string; + models: { + opus: ConfigSource; + sonnet: ConfigSource; + haiku: ConfigSource; + }; +} + +export interface RuntimeConfigResult { + config: Partial; + sources: RuntimeConfigSources; + warnings: string[]; +} + +function readNonEmptyEnv(env: NodeJS.ProcessEnv, key: string): string | undefined { + const value = env[key]?.trim(); + return value ? value : undefined; +} + +function resolveModelTier( + envValue: string | undefined, + defaultModel: string | undefined, + fileValue: string | undefined, + fallbackValue?: string +): { value: string | undefined; source: ConfigSource } { + if (envValue) { + return { value: envValue, source: 'env' }; + } + if (defaultModel) { + return { value: defaultModel, source: 'fallback' }; + } + if (fileValue) { + return { value: fileValue, source: 'file' }; + } + if (fallbackValue) { + return { value: fallbackValue, source: 'fallback' }; + } + return { value: undefined, source: 'prompt' }; +} + +export function resolveRuntimeConfig( + fileConfig: AdapterConfig | null, + env: NodeJS.ProcessEnv = process.env +): RuntimeConfigResult { + const warnings: string[] = []; + + const envBaseUrl = readNonEmptyEnv(env, 'ANTHROPIC_BASE_URL'); + const envApiKey = readNonEmptyEnv(env, 'ANTHROPIC_API_KEY'); + const envAuthToken = readNonEmptyEnv(env, 'ANTHROPIC_AUTH_TOKEN'); + const envDefaultModel = readNonEmptyEnv(env, 'ANTHROPIC_DEFAULT_MODEL'); + + if (envApiKey && envAuthToken) { + warnings.push('ANTHROPIC_AUTH_TOKEN ignored because ANTHROPIC_API_KEY is set'); + } + + const opus = resolveModelTier( + readNonEmptyEnv(env, 'ANTHROPIC_DEFAULT_OPUS_MODEL'), + envDefaultModel, + fileConfig?.models?.opus + ); + const sonnet = resolveModelTier( + readNonEmptyEnv(env, 'ANTHROPIC_DEFAULT_SONNET_MODEL'), + envDefaultModel, + fileConfig?.models?.sonnet, + opus.value + ); + const haiku = resolveModelTier( + readNonEmptyEnv(env, 'ANTHROPIC_DEFAULT_HAIKU_MODEL'), + envDefaultModel, + fileConfig?.models?.haiku, + sonnet.value + ); + + const resolvedModels: ModelConfig | undefined = opus.value + ? { + opus: opus.value, + sonnet: sonnet.value || opus.value, + haiku: haiku.value || sonnet.value || opus.value, + } + : undefined; + + const resolvedApiKey = envApiKey ?? envAuthToken ?? fileConfig?.apiKey; + + return { + config: { + baseUrl: envBaseUrl ?? fileConfig?.baseUrl, + apiKey: resolvedApiKey, + models: resolvedModels, + toolFormat: fileConfig?.toolFormat, + port: fileConfig?.port, + }, + sources: { + baseUrl: envBaseUrl ? 'env' : fileConfig?.baseUrl ? 'file' : 'prompt', + apiKey: envApiKey + ? 'env(ANTHROPIC_API_KEY)' + : envAuthToken + ? 'env(ANTHROPIC_AUTH_TOKEN)' + : fileConfig?.apiKey + ? 'file' + : 'prompt', + models: { + opus: opus.source, + sonnet: sonnet.source, + haiku: haiku.source, + }, + }, + warnings, + }; +} diff --git a/tests/runtimeConfig.test.ts b/tests/runtimeConfig.test.ts new file mode 100644 index 0000000..9ebbfb4 --- /dev/null +++ b/tests/runtimeConfig.test.ts @@ -0,0 +1,141 @@ +import { AdapterConfig } from '../src/types/config'; +import { resolveRuntimeConfig } from '../src/utils/runtimeConfig'; + +function baseFileConfig(): AdapterConfig { + return { + baseUrl: 'https://file.example/v1', + apiKey: 'file-api-key', + models: { + opus: 'file-opus', + sonnet: 'file-sonnet', + haiku: 'file-haiku', + }, + toolFormat: 'xml', + }; +} + +describe('resolveRuntimeConfig', () => { + it('uses file values when env is not set', () => { + const result = resolveRuntimeConfig(baseFileConfig(), {}); + expect(result.config.baseUrl).toBe('https://file.example/v1'); + expect(result.config.apiKey).toBe('file-api-key'); + expect(result.config.models).toEqual({ + opus: 'file-opus', + sonnet: 'file-sonnet', + haiku: 'file-haiku', + }); + expect(result.sources.baseUrl).toBe('file'); + expect(result.sources.apiKey).toBe('file'); + }); + + it('uses ANTHROPIC_BASE_URL over file config', () => { + const result = resolveRuntimeConfig(baseFileConfig(), { + ANTHROPIC_BASE_URL: 'https://env.example/v1', + }); + expect(result.config.baseUrl).toBe('https://env.example/v1'); + expect(result.sources.baseUrl).toBe('env'); + }); + + it('uses ANTHROPIC_API_KEY over ANTHROPIC_AUTH_TOKEN', () => { + const result = resolveRuntimeConfig(baseFileConfig(), { + ANTHROPIC_API_KEY: 'env-api-key', + ANTHROPIC_AUTH_TOKEN: 'env-auth-token', + }); + expect(result.config.apiKey).toBe('env-api-key'); + expect(result.sources.apiKey).toBe('env(ANTHROPIC_API_KEY)'); + expect(result.warnings).toContain('ANTHROPIC_AUTH_TOKEN ignored because ANTHROPIC_API_KEY is set'); + }); + + it('uses ANTHROPIC_AUTH_TOKEN when ANTHROPIC_API_KEY is missing', () => { + const result = resolveRuntimeConfig(baseFileConfig(), { + ANTHROPIC_AUTH_TOKEN: 'env-auth-token', + }); + expect(result.config.apiKey).toBe('env-auth-token'); + expect(result.sources.apiKey).toBe('env(ANTHROPIC_AUTH_TOKEN)'); + }); + + it('uses ANTHROPIC_DEFAULT_MODEL as fallback for tiers', () => { + const result = resolveRuntimeConfig(baseFileConfig(), { + ANTHROPIC_DEFAULT_MODEL: 'env-default', + }); + expect(result.config.models).toEqual({ + opus: 'env-default', + sonnet: 'env-default', + haiku: 'env-default', + }); + expect(result.sources.models.opus).toBe('fallback'); + expect(result.sources.models.sonnet).toBe('fallback'); + expect(result.sources.models.haiku).toBe('fallback'); + }); + + it('prioritizes tier-specific model over ANTHROPIC_DEFAULT_MODEL', () => { + const result = resolveRuntimeConfig(baseFileConfig(), { + ANTHROPIC_DEFAULT_MODEL: 'env-default', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'env-sonnet', + }); + expect(result.config.models).toEqual({ + opus: 'env-default', + sonnet: 'env-sonnet', + haiku: 'env-default', + }); + expect(result.sources.models.sonnet).toBe('env'); + }); + + it('uses all tier-specific model overrides when provided', () => { + const result = resolveRuntimeConfig(baseFileConfig(), { + ANTHROPIC_DEFAULT_OPUS_MODEL: 'env-opus', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'env-sonnet', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'env-haiku', + }); + expect(result.config.models).toEqual({ + opus: 'env-opus', + sonnet: 'env-sonnet', + haiku: 'env-haiku', + }); + expect(result.sources.models.opus).toBe('env'); + expect(result.sources.models.sonnet).toBe('env'); + expect(result.sources.models.haiku).toBe('env'); + }); + + it('ignores blank env values', () => { + const result = resolveRuntimeConfig(baseFileConfig(), { + ANTHROPIC_BASE_URL: ' ', + ANTHROPIC_API_KEY: '', + ANTHROPIC_DEFAULT_MODEL: ' ', + }); + expect(result.config.baseUrl).toBe('https://file.example/v1'); + expect(result.config.apiKey).toBe('file-api-key'); + expect(result.config.models).toEqual({ + opus: 'file-opus', + sonnet: 'file-sonnet', + haiku: 'file-haiku', + }); + }); + + it('returns prompt sources when no file and env are available', () => { + const result = resolveRuntimeConfig(null, {}); + expect(result.config.baseUrl).toBeUndefined(); + expect(result.config.apiKey).toBeUndefined(); + expect(result.sources.baseUrl).toBe('prompt'); + expect(result.sources.apiKey).toBe('prompt'); + expect(result.sources.models.opus).toBe('prompt'); + }); + + it('falls back sonnet and haiku to opus when only opus is available', () => { + const fileOnlyOpus: AdapterConfig = { + baseUrl: 'https://x', + apiKey: 'k', + models: { + opus: 'file-opus', + sonnet: '', + haiku: '', + }, + }; + const result = resolveRuntimeConfig(fileOnlyOpus, {}); + expect(result.config.models).toEqual({ + opus: 'file-opus', + sonnet: 'file-opus', + haiku: 'file-opus', + }); + }); +});