feat: unify Claude API key and profile system with flexible key sourcing

- Add ApiKeySource type ('inline' | 'env' | 'credentials') to ClaudeApiProfile
- Allow profiles to source API keys from credentials.json or environment variables
- Add provider templates: OpenRouter, MiniMax, MiniMax (China)
- Auto-migrate existing users with Anthropic key to "Direct Anthropic" profile
- Update all API call sites to pass credentials for key resolution
- Add API key source selector to profile creation UI
- Increment settings version to 5 for migration support

This allows users to:
- Share a single API key across multiple profile configurations
- Use environment variables for CI/CD deployments
- Easily switch between providers without re-entering keys
This commit is contained in:
Stefan de Vogelaere
2026-01-19 17:28:28 +01:00
parent 10b49bd3b4
commit b88c940a36
25 changed files with 706 additions and 71 deletions

View File

@@ -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<ClaudeApiProfile | undefined> {
): Promise<ActiveClaudeApiProfileResult> {
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 };
}
}

View File

@@ -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<string, string | undefined> {
function buildEnv(
profile?: ClaudeApiProfile,
credentials?: Credentials
): Record<string, string | undefined> {
const env: Record<string, string | undefined> = {};
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

View File

@@ -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<SimpleQu
readOnly: options.readOnly,
settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
for await (const msg of provider.executeQuery(providerOptions)) {
@@ -212,6 +216,7 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
readOnly: options.readOnly,
settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
for await (const msg of provider.executeQuery(providerOptions)) {

View File

@@ -128,7 +128,10 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
logger.info('Using model:', model);
// Get active Claude API profile for alternative endpoint configuration
const claudeApiProfile = await getActiveClaudeApiProfile(settingsService, '[FeatureGeneration]');
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[FeatureGeneration]'
);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
@@ -142,6 +145,7 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
readOnly: true, // Feature generation 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
onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {

View File

@@ -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',

View File

@@ -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)}`);
},

View File

@@ -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 = '';

View File

@@ -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;

View File

@@ -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`);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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',

View File

@@ -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',

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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;

View File

@@ -109,6 +109,15 @@ export function ApiKeysSection() {
{/* Security Notice */}
<SecurityNotice />
{/* Profile Usage Note */}
<div className="text-xs text-muted-foreground/80 px-1">
<p>
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.
</p>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap items-center gap-3 pt-2">
<Button

View File

@@ -39,7 +39,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { ClaudeApiProfile } from '@automaker/types';
import type { ClaudeApiProfile, ApiKeySource } from '@automaker/types';
import { CLAUDE_API_PROFILE_TEMPLATES } from '@automaker/types';
// Generate unique ID for profiles
@@ -56,6 +56,7 @@ function maskApiKey(key: string): string {
interface ProfileFormData {
name: string;
baseUrl: string;
apiKeySource: ApiKeySource;
apiKey: string;
useAuthToken: boolean;
timeoutMs: string; // String for input, convert to number
@@ -70,6 +71,7 @@ interface ProfileFormData {
const emptyFormData: ProfileFormData = {
name: '',
baseUrl: '',
apiKeySource: 'inline',
apiKey: '',
useAuthToken: false,
timeoutMs: '',
@@ -109,6 +111,7 @@ export function ApiProfilesSection() {
setFormData({
name: template.name,
baseUrl: template.baseUrl,
apiKeySource: template.defaultApiKeySource ?? 'inline',
apiKey: '',
useAuthToken: template.useAuthToken,
timeoutMs: template.timeoutMs?.toString() ?? '',
@@ -137,7 +140,8 @@ export function ApiProfilesSection() {
setFormData({
name: profile.name,
baseUrl: profile.baseUrl,
apiKey: profile.apiKey,
apiKeySource: profile.apiKeySource ?? 'inline',
apiKey: profile.apiKey ?? '',
useAuthToken: profile.useAuthToken ?? false,
timeoutMs: profile.timeoutMs?.toString() ?? '',
modelMappings: {
@@ -158,7 +162,9 @@ export function ApiProfilesSection() {
id: editingProfileId ?? generateProfileId(),
name: formData.name.trim(),
baseUrl: formData.baseUrl.trim(),
apiKey: formData.apiKey,
apiKeySource: formData.apiKeySource,
// Only include apiKey when source is 'inline'
apiKey: formData.apiKeySource === 'inline' ? formData.apiKey : undefined,
useAuthToken: formData.useAuthToken,
timeoutMs: formData.timeoutMs ? parseInt(formData.timeoutMs, 10) : undefined,
modelMappings:
@@ -188,10 +194,11 @@ export function ApiProfilesSection() {
setDeleteConfirmId(null);
};
// API key is only required when source is 'inline'
const isFormValid =
formData.name.trim().length > 0 &&
formData.baseUrl.trim().length > 0 &&
formData.apiKey.length > 0;
(formData.apiKeySource !== 'inline' || formData.apiKey.length > 0);
return (
<div
@@ -333,40 +340,74 @@ export function ApiProfilesSection() {
/>
</div>
{/* API Key */}
{/* API Key Source */}
<div className="space-y-2">
<Label htmlFor="profile-api-key">API Key</Label>
<div className="relative">
<Input
id="profile-api-key"
type={showApiKey ? 'text' : 'password'}
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
placeholder="Enter API key"
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground hover:bg-transparent"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
{currentTemplate?.apiKeyUrl && (
<a
href={currentTemplate.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-brand-500 hover:text-brand-400"
>
Get API Key from {currentTemplate.name} <ExternalLink className="w-3 h-3" />
</a>
<Label>API Key Source</Label>
<Select
value={formData.apiKeySource}
onValueChange={(value: ApiKeySource) =>
setFormData({ ...formData, apiKeySource: value })
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select API key source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="credentials">
Use saved API key (from Settings API Keys)
</SelectItem>
<SelectItem value="env">Use environment variable (ANTHROPIC_API_KEY)</SelectItem>
<SelectItem value="inline">Enter key for this profile only</SelectItem>
</SelectContent>
</Select>
{formData.apiKeySource === 'credentials' && (
<p className="text-xs text-muted-foreground">
Will use the Anthropic key from Settings API Keys
</p>
)}
{formData.apiKeySource === 'env' && (
<p className="text-xs text-muted-foreground">
Will use ANTHROPIC_API_KEY environment variable
</p>
)}
</div>
{/* API Key (only shown for inline source) */}
{formData.apiKeySource === 'inline' && (
<div className="space-y-2">
<Label htmlFor="profile-api-key">API Key</Label>
<div className="relative">
<Input
id="profile-api-key"
type={showApiKey ? 'text' : 'password'}
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
placeholder="Enter API key"
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground hover:bg-transparent"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
{currentTemplate?.apiKeyUrl && (
<a
href={currentTemplate.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-brand-500 hover:text-brand-400"
>
Get API Key from {currentTemplate.name} <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
)}
{/* Use Auth Token */}
<div className="flex items-center justify-between py-2">
<div>