diff --git a/apps/cli/src/commands/setup.ts b/apps/cli/src/commands/setup.ts index 1da081a..34cdbe0 100644 --- a/apps/cli/src/commands/setup.ts +++ b/apps/cli/src/commands/setup.ts @@ -13,7 +13,7 @@ import { type ShannonConfig, saveConfig } from '../config/writer.js'; const SHANNON_HOME = path.join(os.homedir(), '.shannon'); -type Provider = 'anthropic' | 'bedrock' | 'vertex' | 'router'; +type Provider = 'anthropic' | 'custom_base_url' | 'bedrock' | 'vertex' | 'router'; export async function setup(): Promise { p.intro('Shannon Setup'); @@ -23,6 +23,7 @@ export async function setup(): Promise { message: 'Select your AI provider', options: [ { value: 'anthropic' as const, label: 'Claude Direct', hint: 'recommended' }, + { value: 'custom_base_url' as const, label: 'Custom Base URL', hint: 'proxies, gateways' }, { value: 'bedrock' as const, label: 'Claude via AWS Bedrock' }, { value: 'vertex' as const, label: 'Claude via Google Vertex AI' }, { value: 'router' as const, label: 'Router', hint: 'experimental' }, @@ -44,6 +45,8 @@ async function setupProvider(provider: Provider): Promise { switch (provider) { case 'anthropic': return setupAnthropic(); + case 'custom_base_url': + return setupCustomBaseUrl(); case 'bedrock': return setupBedrock(); case 'vertex': @@ -113,6 +116,66 @@ async function setupAnthropic(): Promise { return config; } +async function setupCustomBaseUrl(): Promise { + const baseUrl = await p.text({ + message: 'Endpoint URL', + placeholder: 'https://your-proxy.example.com', + validate: (value) => { + if (!value) return 'Endpoint URL is required'; + try { + new URL(value); + } catch { + return 'Must be a valid URL'; + } + return undefined; + }, + }); + if (p.isCancel(baseUrl)) return cancelAndExit(); + + const authToken = await promptSecret('Enter the auth token for the custom endpoint'); + + const config: ShannonConfig = { + custom_base_url: { base_url: baseUrl, auth_token: authToken }, + }; + + const customizeModels = await p.confirm({ + message: + 'Do you want to change the default models?\n' + + ' Small - claude-haiku-4-5-20251001\n' + + ' Medium - claude-sonnet-4-6\n' + + ' Large - claude-opus-4-6', + initialValue: false, + }); + if (p.isCancel(customizeModels)) return cancelAndExit(); + + if (customizeModels) { + const small = await p.text({ + message: 'Small model ID', + initialValue: 'claude-haiku-4-5-20251001', + validate: required('Small model ID is required'), + }); + if (p.isCancel(small)) return cancelAndExit(); + + const medium = await p.text({ + message: 'Medium model ID', + initialValue: 'claude-sonnet-4-6', + validate: required('Medium model ID is required'), + }); + if (p.isCancel(medium)) return cancelAndExit(); + + const large = await p.text({ + message: 'Large model ID', + initialValue: 'claude-opus-4-6', + validate: required('Large model ID is required'), + }); + if (p.isCancel(large)) return cancelAndExit(); + + config.models = { small, medium, large }; + } + + return config; +} + async function setupBedrock(): Promise { const region = await p.text({ message: 'AWS Region', diff --git a/apps/cli/src/config/resolver.ts b/apps/cli/src/config/resolver.ts index 45dad35..039c362 100644 --- a/apps/cli/src/config/resolver.ts +++ b/apps/cli/src/config/resolver.ts @@ -40,6 +40,10 @@ const CONFIG_MAP: readonly ConfigMapping[] = [ { env: 'ANTHROPIC_VERTEX_PROJECT_ID', toml: 'vertex.project_id', type: 'string' }, { env: 'GOOGLE_APPLICATION_CREDENTIALS', toml: 'vertex.key_path', type: 'string' }, + // Custom Base URL + { env: 'ANTHROPIC_BASE_URL', toml: 'custom_base_url.base_url', type: 'string' }, + { env: 'ANTHROPIC_AUTH_TOKEN', toml: 'custom_base_url.auth_token', type: 'string' }, + // Router { env: 'ROUTER_DEFAULT', toml: 'router.default', type: 'string' }, { env: 'OPENAI_API_KEY', toml: 'router.openai_key', type: 'string' }, @@ -133,6 +137,15 @@ function validateProviderFields(config: TOMLConfig, provider: string, errors: st } break; + case 'custom_base_url': { + const required = ['base_url', 'auth_token']; + const missing = required.filter((k) => !keys.includes(k)); + if (missing.length > 0) { + errors.push(`[custom_base_url] missing required keys: ${missing.join(', ')}`); + } + break; + } + case 'bedrock': { const required = ['use', 'region', 'token']; const missing = required.filter((k) => !keys.includes(k)); @@ -229,7 +242,7 @@ function validateConfig(config: TOMLConfig): string[] { } // 4. Only one provider section allowed (ignore empty sections) - const PROVIDER_SECTIONS = ['anthropic', 'bedrock', 'vertex', 'router'] as const; + const PROVIDER_SECTIONS = ['anthropic', 'custom_base_url', 'bedrock', 'vertex', 'router'] as const; const present = PROVIDER_SECTIONS.filter((s) => { const section = config[s]; return section && typeof section === 'object' && Object.keys(section).length > 0; diff --git a/apps/cli/src/config/writer.ts b/apps/cli/src/config/writer.ts index efabea4..58ee7e9 100644 --- a/apps/cli/src/config/writer.ts +++ b/apps/cli/src/config/writer.ts @@ -10,6 +10,7 @@ import { getConfigFile } from '../home.js'; export interface ShannonConfig { core?: { max_tokens?: number }; anthropic?: { api_key?: string; oauth_token?: string }; + custom_base_url?: { base_url?: string; auth_token?: string }; bedrock?: { use?: boolean; region?: string; token?: string }; vertex?: { use?: boolean; region?: string; project_id?: string; key_path?: string }; router?: { default?: string; openai_key?: string; openrouter_key?: string }; diff --git a/apps/cli/src/env.ts b/apps/cli/src/env.ts index a0ce8e1..71c2f0e 100644 --- a/apps/cli/src/env.ts +++ b/apps/cli/src/env.ts @@ -64,7 +64,7 @@ export function buildEnvFlags(): string[] { interface CredentialValidation { valid: boolean; error?: string; - mode: 'api-key' | 'oauth' | 'bedrock' | 'vertex' | 'router'; + mode: 'api-key' | 'oauth' | 'custom-base-url' | 'bedrock' | 'vertex' | 'router'; } /** Check if router credentials are present in the environment. */ @@ -72,11 +72,17 @@ export function isRouterConfigured(): boolean { return !!(process.env.ROUTER_DEFAULT && (process.env.OPENAI_API_KEY || process.env.OPENROUTER_API_KEY)); } +/** Check if a custom Anthropic-compatible base URL is configured. */ +function isCustomBaseUrlConfigured(): boolean { + return !!(process.env.ANTHROPIC_BASE_URL && process.env.ANTHROPIC_AUTH_TOKEN); +} + /** Detect which providers are configured via environment variables. */ function detectProviders(): string[] { const providers: string[] = []; if (process.env.ANTHROPIC_API_KEY) providers.push('Anthropic API key'); if (process.env.CLAUDE_CODE_OAUTH_TOKEN) providers.push('Anthropic OAuth'); + if (isCustomBaseUrlConfigured()) providers.push('Custom Base URL'); if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') providers.push('AWS Bedrock'); if (process.env.CLAUDE_CODE_USE_VERTEX === '1') providers.push('Google Vertex'); if (isRouterConfigured()) providers.push('Router'); @@ -103,6 +109,11 @@ export function validateCredentials(): CredentialValidation { if (process.env.CLAUDE_CODE_OAUTH_TOKEN) { return { valid: true, mode: 'oauth' }; } + if (isCustomBaseUrlConfigured()) { + // Set auth token as API key so the SDK can initialize + process.env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_AUTH_TOKEN; + return { valid: true, mode: 'custom-base-url' }; + } if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') { const missing: string[] = []; if (!process.env.AWS_REGION) missing.push('AWS_REGION'); diff --git a/apps/worker/src/services/preflight.ts b/apps/worker/src/services/preflight.ts index ece222a..3b89d86 100644 --- a/apps/worker/src/services/preflight.ts +++ b/apps/worker/src/services/preflight.ts @@ -196,8 +196,8 @@ async function validateCredentials(logger: ActivityLogger): Promise