diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index 9dd9cf0f..f408b9ec 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -351,30 +351,39 @@ export async function getCustomSubagents( return Object.keys(merged).length > 0 ? merged : undefined; } +/** Result from getActiveClaudeApiProfile */ +export interface ActiveClaudeApiProfileResult { + /** The active profile, or undefined if using direct Anthropic API */ + profile: ClaudeApiProfile | undefined; + /** Credentials for resolving 'credentials' apiKeySource */ + credentials: import('@automaker/types').Credentials | undefined; +} + /** - * Get the active Claude API profile from global settings. - * Returns undefined if no profile is active (uses direct Anthropic API). + * Get the active Claude API profile and credentials from global settings. + * Returns both the profile and credentials for resolving 'credentials' apiKeySource. * * @param settingsService - Optional settings service instance * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') - * @returns Promise resolving to the active profile, or undefined if none active + * @returns Promise resolving to object with profile and credentials */ export async function getActiveClaudeApiProfile( settingsService?: SettingsService | null, logPrefix = '[SettingsHelper]' -): Promise { +): Promise { if (!settingsService) { - return undefined; + return { profile: undefined, credentials: undefined }; } try { const globalSettings = await settingsService.getGlobalSettings(); + const credentials = await settingsService.getCredentials(); const profiles = globalSettings.claudeApiProfiles || []; const activeProfileId = globalSettings.activeClaudeApiProfileId; // No active profile selected - use direct Anthropic API if (!activeProfileId) { - return undefined; + return { profile: undefined, credentials }; } // Find the active profile by ID @@ -382,15 +391,15 @@ export async function getActiveClaudeApiProfile( if (activeProfile) { logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}`); - return activeProfile; + return { profile: activeProfile, credentials }; } else { logger.warn( `${logPrefix} Active profile ID "${activeProfileId}" not found, falling back to direct Anthropic API` ); - return undefined; + return { profile: undefined, credentials }; } } catch (error) { logger.error(`${logPrefix} Failed to load Claude API profile:`, error); - return undefined; + return { profile: undefined, credentials: undefined }; } } diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 4e99901e..c65397d3 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -14,6 +14,7 @@ import { getThinkingTokenBudget, validateBareModelId, type ClaudeApiProfile, + type Credentials, } from '@automaker/types'; import type { ExecuteOptions, @@ -56,19 +57,47 @@ const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_AL * When no profile is provided, uses direct Anthropic API settings from process.env. * * @param profile - Optional Claude API profile for alternative endpoint configuration + * @param credentials - Optional credentials object for resolving 'credentials' apiKeySource */ -function buildEnv(profile?: ClaudeApiProfile): Record { +function buildEnv( + profile?: ClaudeApiProfile, + credentials?: Credentials +): Record { const env: Record = {}; if (profile) { // Use profile configuration (clean switch - don't inherit non-system vars from process.env) - logger.debug('Building environment from Claude API profile:', { name: profile.name }); + logger.debug('Building environment from Claude API profile:', { + name: profile.name, + apiKeySource: profile.apiKeySource ?? 'inline', + }); + + // Resolve API key based on source strategy + let apiKey: string | undefined; + const source = profile.apiKeySource ?? 'inline'; // Default to inline for backwards compat + + switch (source) { + case 'inline': + apiKey = profile.apiKey; + break; + case 'env': + apiKey = process.env.ANTHROPIC_API_KEY; + break; + case 'credentials': + apiKey = credentials?.apiKeys?.anthropic; + break; + } + + // Warn if no API key found + if (!apiKey) { + logger.warn(`No API key found for profile "${profile.name}" with source "${source}"`); + } // Authentication if (profile.useAuthToken) { - env['ANTHROPIC_AUTH_TOKEN'] = profile.apiKey; + env['ANTHROPIC_AUTH_TOKEN'] = apiKey; } else { - env['ANTHROPIC_API_KEY'] = profile.apiKey; + env['ANTHROPIC_API_KEY'] = apiKey; } // Endpoint configuration @@ -149,6 +178,7 @@ export class ClaudeProvider extends BaseProvider { sdkSessionId, thinkingLevel, claudeApiProfile, + credentials, } = options; // Convert thinking level to token budget @@ -163,7 +193,7 @@ export class ClaudeProvider extends BaseProvider { // Pass only explicitly allowed environment variables to SDK // When a profile is active, uses profile settings (clean switch) // When no profile, uses direct Anthropic API (from process.env or CLI OAuth) - env: buildEnv(claudeApiProfile), + env: buildEnv(claudeApiProfile, credentials), // Pass through allowedTools if provided by caller (decided by sdk-options.ts) ...(allowedTools && { allowedTools }), // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation diff --git a/apps/server/src/providers/simple-query-service.ts b/apps/server/src/providers/simple-query-service.ts index f797fd5c..6ffbed0f 100644 --- a/apps/server/src/providers/simple-query-service.ts +++ b/apps/server/src/providers/simple-query-service.ts @@ -21,6 +21,7 @@ import type { ThinkingLevel, ReasoningEffort, ClaudeApiProfile, + Credentials, } from '@automaker/types'; import { stripProviderPrefix } from '@automaker/types'; @@ -57,6 +58,8 @@ export interface SimpleQueryOptions { settingSources?: Array<'user' | 'project' | 'local'>; /** Active Claude API profile for alternative endpoint configuration */ claudeApiProfile?: ClaudeApiProfile; + /** Credentials for resolving 'credentials' apiKeySource in Claude API profiles */ + credentials?: Credentials; } /** @@ -129,6 +132,7 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise { logger.debug(`Feature text block received (${text.length} chars)`); events.emit('spec-regeneration:event', { diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index 233e4e9a..f1d14140 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -105,7 +105,10 @@ ${prompts.appSpec.structuredSpecInstructions}`; logger.info('Using model:', model); // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile(settingsService, '[SpecRegeneration]'); + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[SpecRegeneration]' + ); let responseText = ''; let structuredOutput: SpecOutput | null = null; @@ -140,6 +143,7 @@ Your entire response should be valid JSON starting with { and ending with }. No readOnly: true, // Spec generation only reads code, we write the spec ourselves settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { type: 'json_schema', diff --git a/apps/server/src/routes/app-spec/sync-spec.ts b/apps/server/src/routes/app-spec/sync-spec.ts index 14526865..be0c3cd1 100644 --- a/apps/server/src/routes/app-spec/sync-spec.ts +++ b/apps/server/src/routes/app-spec/sync-spec.ts @@ -161,7 +161,10 @@ export async function syncSpec( const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile(settingsService, '[SpecSync]'); + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[SpecSync]' + ); // Use AI to analyze tech stack const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack. @@ -192,6 +195,7 @@ Return ONLY this JSON format, no other text: readOnly: true, settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource onText: (text) => { logger.debug(`Tech analysis text: ${text.substring(0, 100)}`); }, diff --git a/apps/server/src/routes/backlog-plan/generate-plan.ts b/apps/server/src/routes/backlog-plan/generate-plan.ts index b0c7eaed..63fad8d5 100644 --- a/apps/server/src/routes/backlog-plan/generate-plan.ts +++ b/apps/server/src/routes/backlog-plan/generate-plan.ts @@ -166,7 +166,10 @@ ${userPrompt}`; } // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile(settingsService, '[BacklogPlan]'); + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[BacklogPlan]' + ); // Execute the query const stream = provider.executeQuery({ @@ -181,6 +184,7 @@ ${userPrompt}`; readOnly: true, // Plan generation only generates text, doesn't write files thinkingLevel, // Pass thinking level for extended thinking claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); let responseText = ''; diff --git a/apps/server/src/routes/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts index d8ba073a..ecc82f5b 100644 --- a/apps/server/src/routes/context/routes/describe-file.ts +++ b/apps/server/src/routes/context/routes/describe-file.ts @@ -167,7 +167,10 @@ ${contentToAnalyze}`; logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`); // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile(settingsService, '[DescribeFile]'); + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[DescribeFile]' + ); // Use simpleQuery - provider abstraction handles routing to correct provider const result = await simpleQuery({ @@ -180,6 +183,7 @@ ${contentToAnalyze}`; readOnly: true, // File description only reads, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); const description = result.text; diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index ea54273c..93018e8b 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -286,7 +286,10 @@ export function createDescribeImageHandler( const prompts = await getPromptCustomization(settingsService, '[DescribeImage]'); // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile(settingsService, '[DescribeImage]'); + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[DescribeImage]' + ); // Build the instruction text from centralized prompts const instructionText = prompts.contextDescription.describeImagePrompt; @@ -330,6 +333,7 @@ export function createDescribeImageHandler( readOnly: true, // Image description only reads, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`); diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 20eaf56d..54235d80 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -130,7 +130,10 @@ export function createEnhanceHandler( logger.debug(`Using model: ${resolvedModel}`); // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile(settingsService, '[EnhancePrompt]'); + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[EnhancePrompt]' + ); // Use simpleQuery - provider abstraction handles routing to correct provider // The system prompt is combined with user prompt since some providers @@ -144,6 +147,7 @@ export function createEnhanceHandler( thinkingLevel, readOnly: true, // Prompt enhancement only generates text, doesn't write files claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); const enhancedText = result.text; diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index 1cbccf5b..966c6628 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -64,7 +64,10 @@ export function createGenerateTitleHandler( const systemPrompt = prompts.titleGeneration.systemPrompt; // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile(settingsService, '[GenerateTitle]'); + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[GenerateTitle]' + ); const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`; @@ -76,6 +79,7 @@ export function createGenerateTitleHandler( maxTurns: 1, allowedTools: [], claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); const title = result.text; diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index 2a314295..a690766d 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -170,7 +170,10 @@ ${basePrompt}`; logger.info(`Using model: ${model}`); // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile(settingsService, '[IssueValidation]'); + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[IssueValidation]' + ); // Use streamingQuery with event callbacks const result = await streamingQuery({ @@ -184,6 +187,7 @@ ${basePrompt}`; readOnly: true, // Issue validation only reads code, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { type: 'json_schema', diff --git a/apps/server/src/routes/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts index 8dd2bccf..46453ec1 100644 --- a/apps/server/src/routes/suggestions/generate-suggestions.ts +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -197,7 +197,10 @@ ${prompts.suggestions.baseTemplate}`; logger.info('[Suggestions] Using model:', model); // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile(settingsService, '[Suggestions]'); + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + settingsService, + '[Suggestions]' + ); let responseText = ''; @@ -231,6 +234,7 @@ Your entire response should be valid JSON starting with { and ending with }. No readOnly: true, // Suggestions only reads code, doesn't write settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource outputFormat: useStructuredOutput ? { type: 'json_schema', diff --git a/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/apps/server/src/routes/worktree/routes/generate-commit-message.ts index 86a11756..37893444 100644 --- a/apps/server/src/routes/worktree/routes/generate-commit-message.ts +++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -169,7 +169,7 @@ export function createGenerateCommitMessageHandler( const systemPrompt = await getSystemPrompt(settingsService); // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile( + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( settingsService, '[GenerateCommitMessage]' ); @@ -196,6 +196,7 @@ export function createGenerateCommitMessageHandler( allowedTools: [], readOnly: true, claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }); // Wrap with timeout to prevent indefinite hangs diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 49105df0..982ce7d1 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -276,7 +276,7 @@ export class AgentService { : undefined; // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile( + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( this.settingsService, '[AgentService]' ); @@ -386,6 +386,7 @@ export class AgentService { thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; // Build prompt content with images diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index bd342dc9..e385c2b4 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -2059,7 +2059,10 @@ Format your response as a structured markdown document.`; }); // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile(this.settingsService, '[AutoMode]'); + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[AutoMode]' + ); const options: ExecuteOptions = { prompt, @@ -2071,6 +2074,7 @@ Format your response as a structured markdown document.`; settingSources: sdkOptions.settingSources, thinkingLevel: analysisThinkingLevel, // Pass thinking level claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; const stream = provider.executeQuery(options); @@ -2940,7 +2944,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. } // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile(this.settingsService, '[AutoMode]'); + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( + this.settingsService, + '[AutoMode]' + ); const executeOptions: ExecuteOptions = { prompt: promptContent, @@ -2954,6 +2961,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; // Execute via provider diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index 16769fca..bd383c7c 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -224,7 +224,7 @@ export class IdeationService { const bareModel = stripProviderPrefix(modelId); // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile( + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( this.settingsService, '[IdeationService]' ); @@ -239,6 +239,7 @@ export class IdeationService { abortController: activeSession.abortController!, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; const stream = provider.executeQuery(executeOptions); @@ -686,7 +687,7 @@ export class IdeationService { const bareModel = stripProviderPrefix(modelId); // Get active Claude API profile for alternative endpoint configuration - const claudeApiProfile = await getActiveClaudeApiProfile( + const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( this.settingsService, '[IdeationService]' ); @@ -702,6 +703,7 @@ export class IdeationService { allowedTools: [], abortController: new AbortController(), claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource }; const stream = provider.executeQuery(executeOptions); diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 5b9f81cb..7063b577 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -166,6 +166,41 @@ export class SettingsService { needsSave = true; } + // Migration v4 -> v5: Auto-create "Direct Anthropic" profile for existing users + // If user has an Anthropic API key in credentials but no profiles, create a + // "Direct Anthropic" profile that references the credentials and set it as active. + if (storedVersion < 5) { + try { + const credentials = await this.getCredentials(); + const hasAnthropicKey = !!credentials.apiKeys?.anthropic; + const hasNoProfiles = !result.claudeApiProfiles || result.claudeApiProfiles.length === 0; + const hasNoActiveProfile = !result.activeClaudeApiProfileId; + + if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) { + const directAnthropicProfile = { + id: `profile-${Date.now()}-direct-anthropic`, + name: 'Direct Anthropic', + baseUrl: 'https://api.anthropic.com', + apiKeySource: 'credentials' as const, + useAuthToken: false, + }; + + result.claudeApiProfiles = [directAnthropicProfile]; + result.activeClaudeApiProfileId = directAnthropicProfile.id; + + logger.info( + 'Migration v4->v5: Created "Direct Anthropic" profile using existing credentials' + ); + } + } catch (error) { + logger.warn( + 'Migration v4->v5: Could not check credentials for auto-profile creation:', + error + ); + } + needsSave = true; + } + // Update version if any migration occurred if (needsSave) { result.version = SETTINGS_VERSION; diff --git a/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx index 840c8e63..8bb68204 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx @@ -109,6 +109,15 @@ export function ApiKeysSection() { {/* Security Notice */} + {/* Profile Usage Note */} +
+

+ API Keys saved here can be used by API Profiles with "credentials" as the API key + source. This lets you share a single key across multiple profile configurations without + re-entering it. +

+
+ {/* Action Buttons */}
-
- {currentTemplate?.apiKeyUrl && ( - - Get API Key from {currentTemplate.name} - + + + {formData.apiKeySource === 'credentials' && ( +

+ Will use the Anthropic key from Settings → API Keys +

+ )} + {formData.apiKeySource === 'env' && ( +

+ Will use ANTHROPIC_API_KEY environment variable +

)} + {/* API Key (only shown for inline source) */} + {formData.apiKeySource === 'inline' && ( +
+ +
+ setFormData({ ...formData, apiKey: e.target.value })} + placeholder="Enter API key" + className="pr-10" + /> + +
+ {currentTemplate?.apiKeyUrl && ( + + Get API Key from {currentTemplate.name} + + )} +
+ )} + {/* Use Auth Token */}
diff --git a/docs/UNIFIED_API_KEY_PROFILES.md b/docs/UNIFIED_API_KEY_PROFILES.md new file mode 100644 index 00000000..77afc20f --- /dev/null +++ b/docs/UNIFIED_API_KEY_PROFILES.md @@ -0,0 +1,383 @@ +# Unified Claude API Key and Profile System + +This document describes the implementation of a unified API key sourcing system for Claude API profiles, allowing flexible configuration of how API keys are resolved. + +## Problem Statement + +Previously, Automaker had two separate systems for configuring Claude API access: + +1. **API Keys section** (`credentials.json`): Stored Anthropic API key, used when no profile was active +2. **API Profiles section** (`settings.json`): Stored alternative endpoint configs (e.g., z.AI GLM) with their own inline API keys + +This created several issues: + +- Users configured Anthropic key in one place, but alternative endpoints in another +- No way to create a "Direct Anthropic" profile that reused the stored credentials +- Environment variable detection didn't integrate with the profile system +- Duplicated API key entry when users wanted the same key for multiple configurations + +## Solution Overview + +The solution introduces a flexible `apiKeySource` field on Claude API profiles that determines where the API key is resolved from: + +| Source | Description | +| ------------- | ----------------------------------------------------------------- | +| `inline` | API key stored directly in the profile (legacy behavior, default) | +| `env` | Uses `ANTHROPIC_API_KEY` environment variable | +| `credentials` | Uses the Anthropic key from Settings → API Keys | + +This allows: + +- A single API key to be shared across multiple profile configurations +- "Direct Anthropic" profile that references saved credentials +- Environment variable support for CI/CD and containerized deployments +- Backwards compatibility with existing inline key profiles + +## Implementation Details + +### Type Changes + +#### New Type: `ApiKeySource` + +```typescript +// libs/types/src/settings.ts +export type ApiKeySource = 'inline' | 'env' | 'credentials'; +``` + +#### Updated Interface: `ClaudeApiProfile` + +```typescript +export interface ClaudeApiProfile { + id: string; + name: string; + baseUrl: string; + + // NEW: API key sourcing strategy (default: 'inline' for backwards compat) + apiKeySource?: ApiKeySource; + + // Now optional - only required when apiKeySource = 'inline' + apiKey?: string; + + // Existing fields unchanged... + useAuthToken?: boolean; + timeoutMs?: number; + modelMappings?: { haiku?: string; sonnet?: string; opus?: string }; + disableNonessentialTraffic?: boolean; +} +``` + +#### Updated Interface: `ClaudeApiProfileTemplate` + +```typescript +export interface ClaudeApiProfileTemplate { + name: string; + baseUrl: string; + defaultApiKeySource?: ApiKeySource; // NEW: Suggested source for this template + useAuthToken: boolean; + // ... other fields +} +``` + +### Provider Templates + +The following provider templates are available: + +#### Direct Anthropic + +```typescript +{ + name: 'Direct Anthropic', + baseUrl: 'https://api.anthropic.com', + defaultApiKeySource: 'credentials', + useAuthToken: false, + description: 'Standard Anthropic API with your API key', + apiKeyUrl: 'https://console.anthropic.com/settings/keys', +} +``` + +#### OpenRouter + +Access Claude and 300+ other models through OpenRouter's unified API. + +```typescript +{ + name: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api', + defaultApiKeySource: 'inline', + useAuthToken: true, + description: 'Access Claude and 300+ models via OpenRouter', + apiKeyUrl: 'https://openrouter.ai/keys', +} +``` + +**Notes:** + +- Uses `ANTHROPIC_AUTH_TOKEN` with your OpenRouter API key +- No model mappings by default - OpenRouter auto-maps Anthropic models +- Can customize model mappings to use any OpenRouter-supported model (e.g., `openai/gpt-5.1-codex-max`) + +#### z.AI GLM + +```typescript +{ + name: 'z.AI GLM', + baseUrl: 'https://api.z.ai/api/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'GLM-4.5-Air', + sonnet: 'GLM-4.7', + opus: 'GLM-4.7', + }, + disableNonessentialTraffic: true, + description: '3× usage at fraction of cost via GLM Coding Plan', + apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list', +} +``` + +#### MiniMax + +MiniMax M2.1 coding model with extended context support. + +```typescript +{ + name: 'MiniMax', + baseUrl: 'https://api.minimax.io/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 coding model with extended context', + apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key', +} +``` + +#### MiniMax (China) + +Same as MiniMax but using the China-region endpoint. + +```typescript +{ + name: 'MiniMax (China)', + baseUrl: 'https://api.minimaxi.com/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 for users in China', + apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', +} +``` + +### Server-Side Changes + +#### 1. Environment Building (`claude-provider.ts`) + +The `buildEnv()` function now resolves API keys based on the `apiKeySource`: + +```typescript +function buildEnv( + profile?: ClaudeApiProfile, + credentials?: Credentials // NEW parameter +): Record { + if (profile) { + // Resolve API key based on source strategy + let apiKey: string | undefined; + const source = profile.apiKeySource ?? 'inline'; + + switch (source) { + case 'inline': + apiKey = profile.apiKey; + break; + case 'env': + apiKey = process.env.ANTHROPIC_API_KEY; + break; + case 'credentials': + apiKey = credentials?.apiKeys?.anthropic; + break; + } + + // ... rest of profile-based env building + } + // ... no-profile fallback +} +``` + +#### 2. Settings Helper (`settings-helpers.ts`) + +The `getActiveClaudeApiProfile()` function now returns both profile and credentials: + +```typescript +export interface ActiveClaudeApiProfileResult { + profile: ClaudeApiProfile | undefined; + credentials: Credentials | undefined; +} + +export async function getActiveClaudeApiProfile( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]' +): Promise { + // Returns both profile and credentials for API key resolution +} +``` + +#### 3. Auto-Migration (`settings-service.ts`) + +A v4→v5 migration automatically creates a "Direct Anthropic" profile for existing users: + +```typescript +// Migration v4 -> v5: Auto-create "Direct Anthropic" profile +if (storedVersion < 5) { + const credentials = await this.getCredentials(); + const hasAnthropicKey = !!credentials.apiKeys?.anthropic; + const hasNoProfiles = !result.claudeApiProfiles?.length; + const hasNoActiveProfile = !result.activeClaudeApiProfileId; + + if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) { + // Create "Direct Anthropic" profile with apiKeySource: 'credentials' + // and set it as active + } +} +``` + +#### 4. Updated Call Sites + +All files that call `getActiveClaudeApiProfile()` were updated to: + +1. Destructure both `profile` and `credentials` from the result +2. Pass `credentials` to the provider via `ExecuteOptions` + +**Files updated:** + +- `apps/server/src/services/agent-service.ts` +- `apps/server/src/services/auto-mode-service.ts` (2 locations) +- `apps/server/src/services/ideation-service.ts` (2 locations) +- `apps/server/src/providers/simple-query-service.ts` +- `apps/server/src/routes/enhance-prompt/routes/enhance.ts` +- `apps/server/src/routes/context/routes/describe-file.ts` +- `apps/server/src/routes/context/routes/describe-image.ts` +- `apps/server/src/routes/github/routes/validate-issue.ts` +- `apps/server/src/routes/worktree/routes/generate-commit-message.ts` +- `apps/server/src/routes/features/routes/generate-title.ts` +- `apps/server/src/routes/backlog-plan/generate-plan.ts` +- `apps/server/src/routes/app-spec/sync-spec.ts` +- `apps/server/src/routes/app-spec/generate-features-from-spec.ts` +- `apps/server/src/routes/app-spec/generate-spec.ts` +- `apps/server/src/routes/suggestions/generate-suggestions.ts` + +### UI Changes + +#### 1. Profile Form (`api-profiles-section.tsx`) + +Added an API Key Source selector dropdown: + +```tsx + +``` + +The API Key input field is now conditionally rendered only when `apiKeySource === 'inline'`. + +#### 2. API Keys Section (`api-keys-section.tsx`) + +Added an informational note: + +> API Keys saved here can be used by API Profiles with "credentials" as the API key source. This lets you share a single key across multiple profile configurations without re-entering it. + +## User Flows + +### New User Flow + +1. Go to Settings → API Keys +2. Enter Anthropic API key and save +3. Go to Settings → Providers → Claude +4. Create new profile from "Direct Anthropic" template +5. API Key Source defaults to "credentials" - no need to re-enter key +6. Save profile and set as active + +### Existing User Migration + +When an existing user with an Anthropic API key (but no profiles) loads settings: + +1. System detects v4→v5 migration needed +2. Automatically creates "Direct Anthropic" profile with `apiKeySource: 'credentials'` +3. Sets new profile as active +4. User's existing workflow continues to work seamlessly + +### Environment Variable Flow + +For CI/CD or containerized deployments: + +1. Set `ANTHROPIC_API_KEY` in environment +2. Create profile with `apiKeySource: 'env'` +3. Profile will use the environment variable at runtime + +## Backwards Compatibility + +- Profiles without `apiKeySource` field default to `'inline'` +- Existing profiles with inline `apiKey` continue to work unchanged +- No changes to the credentials file format +- Settings version bumped from 4 to 5 (migration is additive) + +## Files Changed + +| File | Changes | +| --------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `libs/types/src/settings.ts` | Added `ApiKeySource` type, updated `ClaudeApiProfile`, added Direct Anthropic template | +| `libs/types/src/provider.ts` | Added `credentials` field to `ExecuteOptions` | +| `libs/types/src/index.ts` | Exported `ApiKeySource` type | +| `apps/server/src/providers/claude-provider.ts` | Updated `buildEnv()` to resolve keys from different sources | +| `apps/server/src/lib/settings-helpers.ts` | Updated return type to include credentials | +| `apps/server/src/services/settings-service.ts` | Added v4→v5 auto-migration | +| `apps/server/src/providers/simple-query-service.ts` | Added credentials passthrough | +| `apps/server/src/services/*.ts` | Updated to pass credentials | +| `apps/server/src/routes/**/*.ts` | Updated to pass credentials (15 files) | +| `apps/ui/src/.../api-profiles-section.tsx` | Added API Key Source selector | +| `apps/ui/src/.../api-keys-section.tsx` | Added profile usage note | + +## Testing + +To verify the implementation: + +1. **New user flow**: Create "Direct Anthropic" profile, select `credentials` source, enter key in API Keys section → verify it works +2. **Existing user migration**: User with credentials.json key sees auto-created "Direct Anthropic" profile +3. **Env var support**: Create profile with `env` source, set ANTHROPIC_API_KEY → verify it works +4. **z.AI GLM unchanged**: Existing profiles with inline keys continue working +5. **Backwards compat**: Profiles without `apiKeySource` field default to `inline` + +```bash +# Build and run +npm run build:packages +npm run dev:web + +# Run server tests +npm run test:server +``` + +## Future Enhancements + +Potential future improvements: + +1. **UI indicators**: Show whether credentials/env key is configured when selecting those sources +2. **Validation**: Warn if selected source has no key configured +3. **Per-provider credentials**: Support different credential keys for different providers +4. **Key rotation**: Support for rotating keys without updating profiles diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index d68f3f66..123dbeda 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -162,6 +162,7 @@ export type { EventHookAction, EventHook, // Claude API profile types + ApiKeySource, ClaudeApiProfile, ClaudeApiProfileTemplate, } from './settings.js'; diff --git a/libs/types/src/provider.ts b/libs/types/src/provider.ts index e2040b9b..6fddb460 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -2,7 +2,7 @@ * Shared types for AI model providers */ -import type { ThinkingLevel, ClaudeApiProfile } from './settings.js'; +import type { ThinkingLevel, ClaudeApiProfile, Credentials } from './settings.js'; import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js'; /** @@ -215,6 +215,11 @@ export interface ExecuteOptions { * When undefined, uses direct Anthropic API (via API key or Claude Max CLI OAuth). */ claudeApiProfile?: ClaudeApiProfile; + /** + * Credentials for resolving 'credentials' apiKeySource in Claude API profiles. + * When a profile has apiKeySource='credentials', the Anthropic key from this object is used. + */ + credentials?: Credentials; } /** diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 5ebdc3dc..a76912f9 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -105,6 +105,15 @@ export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode'; // Claude API Profiles - Configuration for Claude-compatible API endpoints // ============================================================================ +/** + * ApiKeySource - Strategy for sourcing API keys + * + * - 'inline': API key stored directly in the profile (legacy/default behavior) + * - 'env': Use ANTHROPIC_API_KEY environment variable + * - 'credentials': Use the Anthropic key from Settings → API Keys (credentials.json) + */ +export type ApiKeySource = 'inline' | 'env' | 'credentials'; + /** * ClaudeApiProfile - Configuration for a Claude-compatible API endpoint * @@ -117,8 +126,15 @@ export interface ClaudeApiProfile { name: string; /** ANTHROPIC_BASE_URL - custom API endpoint */ baseUrl: string; - /** API key value */ - apiKey: string; + /** + * API key sourcing strategy (default: 'inline' for backwards compatibility) + * - 'inline': Use apiKey field value + * - 'env': Use ANTHROPIC_API_KEY environment variable + * - 'credentials': Use the Anthropic key from credentials.json + */ + apiKeySource?: ApiKeySource; + /** API key value (only required when apiKeySource = 'inline' or undefined) */ + apiKey?: string; /** If true, use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY */ useAuthToken?: boolean; /** API_TIMEOUT_MS override in milliseconds */ @@ -140,6 +156,8 @@ export interface ClaudeApiProfile { export interface ClaudeApiProfileTemplate { name: string; baseUrl: string; + /** Default API key source for this template (user chooses when creating) */ + defaultApiKeySource?: ApiKeySource; useAuthToken: boolean; timeoutMs?: number; modelMappings?: ClaudeApiProfile['modelMappings']; @@ -150,9 +168,26 @@ export interface ClaudeApiProfileTemplate { /** Predefined templates for known Claude-compatible providers */ export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [ + { + name: 'Direct Anthropic', + baseUrl: 'https://api.anthropic.com', + defaultApiKeySource: 'credentials', + useAuthToken: false, + description: 'Standard Anthropic API with your API key', + apiKeyUrl: 'https://console.anthropic.com/settings/keys', + }, + { + name: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api', + defaultApiKeySource: 'inline', + useAuthToken: true, + description: 'Access Claude and 300+ models via OpenRouter', + apiKeyUrl: 'https://openrouter.ai/keys', + }, { name: 'z.AI GLM', baseUrl: 'https://api.z.ai/api/anthropic', + defaultApiKeySource: 'inline', useAuthToken: true, timeoutMs: 3000000, modelMappings: { @@ -164,6 +199,36 @@ export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [ description: '3× usage at fraction of cost via GLM Coding Plan', apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list', }, + { + name: 'MiniMax', + baseUrl: 'https://api.minimax.io/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 coding model with extended context', + apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key', + }, + { + name: 'MiniMax (China)', + baseUrl: 'https://api.minimaxi.com/anthropic', + defaultApiKeySource: 'inline', + useAuthToken: true, + timeoutMs: 3000000, + modelMappings: { + haiku: 'MiniMax-M2.1', + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + }, + disableNonessentialTraffic: true, + description: 'MiniMax M2.1 for users in China', + apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', + }, // Future: Add AWS Bedrock, Google Vertex, etc. ]; @@ -906,7 +971,7 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = { }; /** Current version of the global settings schema */ -export const SETTINGS_VERSION = 4; +export const SETTINGS_VERSION = 5; /** Current version of the credentials schema */ export const CREDENTIALS_VERSION = 1; /** Current version of the project settings schema */ diff --git a/package-lock.json b/package-lock.json index 32f39ec0..c851c9aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6190,6 +6190,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6199,7 +6200,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -8410,6 +8411,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/d3-color": { @@ -11303,7 +11305,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11367,7 +11368,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" },