feat: unified Claude API key and profile system with z.AI, MiniMax, OpenRouter support (#600)

* feat: add Claude API provider profiles for alternative endpoints

Add support for managing multiple Claude-compatible API endpoints
(z.AI GLM, AWS Bedrock, etc.) through provider profiles in settings.

Features:
- New ClaudeApiProfile type with base URL, API key, model mappings
- Pre-configured z.AI GLM template with correct model names
- Profile selector in Settings > Claude > API Profiles
- Clean switching between profiles and direct Anthropic API
- Immediate persistence to prevent data loss on restart

Profile support added to all execution paths:
- Agent service (chat)
- Ideation service
- Auto-mode service (feature agents, enhancements)
- Simple query service (title generation, descriptions, etc.)
- Backlog planning, commit messages, spec generation
- GitHub issue validation, suggestions

Environment variables set when profile is active:
- ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN/API_KEY
- ANTHROPIC_DEFAULT_HAIKU/SONNET/OPUS_MODEL
- API_TIMEOUT_MS, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
This commit is contained in:
Stefan de Vogelaere
2026-01-19 20:36:58 +01:00
committed by GitHub
parent 63b8eb0991
commit d97c4b7b57
45 changed files with 2661 additions and 146 deletions

View File

@@ -5,7 +5,12 @@
import type { SettingsService } from '../services/settings-service.js';
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
import { createLogger } from '@automaker/utils';
import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types';
import type {
MCPServerConfig,
McpServerConfig,
PromptCustomization,
ClaudeApiProfile,
} from '@automaker/types';
import {
mergeAutoModePrompts,
mergeAgentPrompts,
@@ -345,3 +350,80 @@ 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 and credentials from settings.
* Checks project settings first for per-project overrides, then falls back to 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]')
* @param projectPath - Optional project path for per-project override
* @returns Promise resolving to object with profile and credentials
*/
export async function getActiveClaudeApiProfile(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]',
projectPath?: string
): Promise<ActiveClaudeApiProfileResult> {
if (!settingsService) {
return { profile: undefined, credentials: undefined };
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const profiles = globalSettings.claudeApiProfiles || [];
// Check for project-level override first
let activeProfileId: string | null | undefined;
let isProjectOverride = false;
if (projectPath) {
const projectSettings = await settingsService.getProjectSettings(projectPath);
// undefined = use global, null = explicit no profile, string = specific profile
if (projectSettings.activeClaudeApiProfileId !== undefined) {
activeProfileId = projectSettings.activeClaudeApiProfileId;
isProjectOverride = true;
}
}
// Fall back to global if project doesn't specify
if (activeProfileId === undefined && !isProjectOverride) {
activeProfileId = globalSettings.activeClaudeApiProfileId;
}
// No active profile selected - use direct Anthropic API
if (!activeProfileId) {
if (isProjectOverride && activeProfileId === null) {
logger.info(`${logPrefix} Project explicitly using Direct Anthropic API`);
}
return { profile: undefined, credentials };
}
// Find the active profile by ID
const activeProfile = profiles.find((p) => p.id === activeProfileId);
if (activeProfile) {
const overrideSuffix = isProjectOverride ? ' (project override)' : '';
logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}${overrideSuffix}`);
return { profile: activeProfile, credentials };
} else {
logger.warn(
`${logPrefix} Active profile ID "${activeProfileId}" not found, falling back to direct Anthropic API`
);
return { profile: undefined, credentials };
}
} catch (error) {
logger.error(`${logPrefix} Failed to load Claude API profile:`, error);
return { profile: undefined, credentials: undefined };
}
}

View File

@@ -10,7 +10,12 @@ import { BaseProvider } from './base-provider.js';
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
const logger = createLogger('ClaudeProvider');
import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
import {
getThinkingTokenBudget,
validateBareModelId,
type ClaudeApiProfile,
type Credentials,
} from '@automaker/types';
import type {
ExecuteOptions,
ProviderMessage,
@@ -21,9 +26,19 @@ import type {
// Explicit allowlist of environment variables to pass to the SDK.
// Only these vars are passed - nothing else from process.env leaks through.
const ALLOWED_ENV_VARS = [
// Authentication
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
// Endpoint configuration
'ANTHROPIC_BASE_URL',
'API_TIMEOUT_MS',
// Model mappings
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
// Traffic control
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
// System vars (always from process.env)
'PATH',
'HOME',
'SHELL',
@@ -33,16 +48,114 @@ const ALLOWED_ENV_VARS = [
'LC_ALL',
];
// System vars are always passed from process.env regardless of profile
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
/**
* Build environment for the SDK with only explicitly allowed variables
* Build environment for the SDK with only explicitly allowed variables.
* When a profile is provided, uses profile configuration (clean switch - don't inherit from process.env).
* 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(): Record<string, string | undefined> {
function buildEnv(
profile?: ClaudeApiProfile,
credentials?: Credentials
): Record<string, string | undefined> {
const env: Record<string, string | undefined> = {};
for (const key of ALLOWED_ENV_VARS) {
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,
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'] = apiKey;
} else {
env['ANTHROPIC_API_KEY'] = apiKey;
}
// Endpoint configuration
env['ANTHROPIC_BASE_URL'] = profile.baseUrl;
if (profile.timeoutMs) {
env['API_TIMEOUT_MS'] = String(profile.timeoutMs);
}
// Model mappings
if (profile.modelMappings?.haiku) {
env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = profile.modelMappings.haiku;
}
if (profile.modelMappings?.sonnet) {
env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = profile.modelMappings.sonnet;
}
if (profile.modelMappings?.opus) {
env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = profile.modelMappings.opus;
}
// Traffic control
if (profile.disableNonessentialTraffic) {
env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1';
}
} else {
// Use direct Anthropic API - pass through credentials or environment variables
// This supports:
// 1. API Key mode: ANTHROPIC_API_KEY from credentials (UI settings) or env
// 2. Claude Max plan: Uses CLI OAuth auth (SDK handles this automatically)
// 3. Custom endpoints via ANTHROPIC_BASE_URL env var (backward compatibility)
//
// Priority: credentials file (UI settings) -> environment variable
// Note: Only auth and endpoint vars are passed. Model mappings and traffic
// control are NOT passed (those require a profile for explicit configuration).
if (credentials?.apiKeys?.anthropic) {
env['ANTHROPIC_API_KEY'] = credentials.apiKeys.anthropic;
} else if (process.env.ANTHROPIC_API_KEY) {
env['ANTHROPIC_API_KEY'] = process.env.ANTHROPIC_API_KEY;
}
// If using Claude Max plan via CLI auth, the SDK handles auth automatically
// when no API key is provided. We don't set ANTHROPIC_AUTH_TOKEN here
// unless it was explicitly set in process.env (rare edge case).
if (process.env.ANTHROPIC_AUTH_TOKEN) {
env['ANTHROPIC_AUTH_TOKEN'] = process.env.ANTHROPIC_AUTH_TOKEN;
}
// Pass through ANTHROPIC_BASE_URL if set in environment (backward compatibility)
if (process.env.ANTHROPIC_BASE_URL) {
env['ANTHROPIC_BASE_URL'] = process.env.ANTHROPIC_BASE_URL;
}
}
// Always add system vars from process.env
for (const key of SYSTEM_ENV_VARS) {
if (process.env[key]) {
env[key] = process.env[key];
}
}
return env;
}
@@ -70,6 +183,8 @@ export class ClaudeProvider extends BaseProvider {
conversationHistory,
sdkSessionId,
thinkingLevel,
claudeApiProfile,
credentials,
} = options;
// Convert thinking level to token budget
@@ -82,7 +197,9 @@ export class ClaudeProvider extends BaseProvider {
maxTurns,
cwd,
// Pass only explicitly allowed environment variables to SDK
env: buildEnv(),
// 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, 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

@@ -20,6 +20,8 @@ import type {
ContentBlock,
ThinkingLevel,
ReasoningEffort,
ClaudeApiProfile,
Credentials,
} from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types';
@@ -54,6 +56,10 @@ export interface SimpleQueryOptions {
readOnly?: boolean;
/** Setting sources for CLAUDE.md loading */
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;
}
/**
@@ -125,6 +131,8 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise<SimpleQu
reasoningEffort: options.reasoningEffort,
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)) {
@@ -207,6 +215,8 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
reasoningEffort: options.reasoningEffort,
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

@@ -14,7 +14,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger('SpecRegeneration');
@@ -123,6 +127,13 @@ 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 { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[FeatureGeneration]',
projectPath
);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt,
@@ -134,6 +145,8 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
thinkingLevel,
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

@@ -16,7 +16,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
const logger = createLogger('SpecRegeneration');
@@ -100,6 +104,13 @@ ${prompts.appSpec.structuredSpecInstructions}`;
logger.info('Using model:', model);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[SpecRegeneration]',
projectPath
);
let responseText = '';
let structuredOutput: SpecOutput | null = null;
@@ -132,6 +143,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
thinkingLevel,
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

@@ -15,7 +15,10 @@ import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
import {
extractImplementedFeatures,
@@ -157,6 +160,13 @@ export async function syncSpec(
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[SpecSync]',
projectPath
);
// Use AI to analyze tech stack
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
@@ -185,6 +195,8 @@ Return ONLY this JSON format, no other text:
thinkingLevel,
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

@@ -25,7 +25,11 @@ import {
saveBacklogPlan,
} from './common.js';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
const featureLoader = new FeatureLoader();
@@ -161,6 +165,13 @@ ${userPrompt}`;
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
}
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[BacklogPlan]',
projectPath
);
// Execute the query
const stream = provider.executeQuery({
prompt: finalPrompt,
@@ -173,6 +184,8 @@ ${userPrompt}`;
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
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

@@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeFile');
@@ -165,6 +166,13 @@ ${contentToAnalyze}`;
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[DescribeFile]',
cwd
);
// Use simpleQuery - provider abstraction handles routing to correct provider
const result = await simpleQuery({
prompt,
@@ -175,6 +183,8 @@ ${contentToAnalyze}`;
thinkingLevel,
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

@@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeImage');
@@ -284,6 +285,13 @@ export function createDescribeImageHandler(
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[DescribeImage]');
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[DescribeImage]',
cwd
);
// Build the instruction text from centralized prompts
const instructionText = prompts.contextDescription.describeImagePrompt;
@@ -325,6 +333,8 @@ export function createDescribeImageHandler(
thinkingLevel,
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

@@ -12,7 +12,10 @@ import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
import {
buildUserPrompt,
isValidEnhancementMode,
@@ -33,6 +36,8 @@ interface EnhanceRequestBody {
model?: string;
/** Optional thinking level for Claude models */
thinkingLevel?: ThinkingLevel;
/** Optional project path for per-project Claude API profile */
projectPath?: string;
}
/**
@@ -62,7 +67,7 @@ export function createEnhanceHandler(
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { originalText, enhancementMode, model, thinkingLevel } =
const { originalText, enhancementMode, model, thinkingLevel, projectPath } =
req.body as EnhanceRequestBody;
// Validate required fields
@@ -126,6 +131,14 @@ export function createEnhanceHandler(
logger.debug(`Using model: ${resolvedModel}`);
// Get active Claude API profile for alternative endpoint configuration
// Uses project-specific profile if projectPath provided, otherwise global
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[EnhancePrompt]',
projectPath
);
// Use simpleQuery - provider abstraction handles routing to correct provider
// The system prompt is combined with user prompt since some providers
// don't have a separate system prompt concept
@@ -137,6 +150,8 @@ export function createEnhanceHandler(
allowedTools: [],
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

@@ -10,12 +10,16 @@ import { createLogger } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('GenerateTitle');
interface GenerateTitleRequestBody {
description: string;
projectPath?: string;
}
interface GenerateTitleSuccessResponse {
@@ -33,7 +37,7 @@ export function createGenerateTitleHandler(
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { description } = req.body as GenerateTitleRequestBody;
const { description, projectPath } = req.body as GenerateTitleRequestBody;
if (!description || typeof description !== 'string') {
const response: GenerateTitleErrorResponse = {
@@ -60,6 +64,14 @@ export function createGenerateTitleHandler(
const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]');
const systemPrompt = prompts.titleGeneration.systemPrompt;
// Get active Claude API profile for alternative endpoint configuration
// Uses project-specific profile if projectPath provided, otherwise global
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[GenerateTitle]',
projectPath
);
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
// Use simpleQuery - provider abstraction handles all the streaming/extraction
@@ -69,6 +81,8 @@ export function createGenerateTitleHandler(
cwd: process.cwd(),
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

@@ -34,7 +34,11 @@ import {
ValidationComment,
ValidationLinkedPR,
} from './validation-schema.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
import {
trySetValidationRunning,
clearValidationStatus,
@@ -43,7 +47,6 @@ import {
logger,
} from './validation-common.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
/**
* Request body for issue validation
@@ -166,6 +169,13 @@ ${basePrompt}`;
logger.info(`Using model: ${model}`);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[IssueValidation]',
projectPath
);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt: finalPrompt,
@@ -177,6 +187,8 @@ ${basePrompt}`;
reasoningEffort: effectiveReasoningEffort,
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

@@ -15,7 +15,11 @@ import { FeatureLoader } from '../../services/feature-loader.js';
import { getAppSpecPath } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
const logger = createLogger('Suggestions');
@@ -192,6 +196,13 @@ ${prompts.suggestions.baseTemplate}`;
logger.info('[Suggestions] Using model:', model);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[Suggestions]',
projectPath
);
let responseText = '';
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
@@ -223,6 +234,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
thinkingLevel,
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

@@ -10,7 +10,6 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import { existsSync } from 'fs';
import { join } from 'path';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
@@ -18,6 +17,7 @@ import { mergeCommitMessagePrompts } from '@automaker/prompts';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getErrorMessage, logError } from '../common.js';
import { getActiveClaudeApiProfile } from '../../../lib/settings-helpers.js';
const logger = createLogger('GenerateCommitMessage');
const execAsync = promisify(exec);
@@ -74,33 +74,6 @@ interface GenerateCommitMessageErrorResponse {
error: string;
}
async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
export function createGenerateCommitMessageHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
@@ -195,57 +168,54 @@ export function createGenerateCommitMessageHandler(
// Get the effective system prompt (custom or default)
const systemPrompt = await getSystemPrompt(settingsService);
let message: string;
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[GenerateCommitMessage]',
worktreePath
);
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info(`Using Cursor provider for model: ${model}`);
// Get provider for the model type
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
// For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation
const effectivePrompt = isCursorModel(model)
? `${systemPrompt}\n\n${userPrompt}`
: userPrompt;
const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt;
const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`;
logger.info(`Using ${provider.getName()} provider for model: ${model}`);
let responseText = '';
const cursorStream = provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
cwd: worktreePath,
maxTurns: 1,
allowedTools: [],
readOnly: true,
});
let responseText = '';
const stream = provider.executeQuery({
prompt: effectivePrompt,
model: bareModel,
cwd: worktreePath,
systemPrompt: effectiveSystemPrompt,
maxTurns: 1,
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
for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
// Wrap with timeout to prevent indefinite hangs
for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if available (some providers return final text here)
responseText = msg.result;
}
message = responseText.trim();
} else {
// Use Claude SDK for Claude models
const stream = query({
prompt: userPrompt,
options: {
model,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'default',
},
});
// Wrap with timeout to prevent indefinite hangs
message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS));
}
const message = responseText.trim();
if (!message || message.trim().length === 0) {
logger.warn('Received empty response from model');
const response: GenerateCommitMessageErrorResponse = {

View File

@@ -29,6 +29,7 @@ import {
getSkillsConfiguration,
getSubagentsConfiguration,
getCustomSubagents,
getActiveClaudeApiProfile,
} from '../lib/settings-helpers.js';
interface Message {
@@ -274,6 +275,13 @@ export class AgentService {
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
: undefined;
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[AgentService]',
effectiveWorkDir
);
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
// Use the user's message as task context for smart memory selection
const contextResult = await loadContextFiles({
@@ -378,6 +386,8 @@ export class AgentService {
agents: customSubagents, // Pass custom subagents for task delegation
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

@@ -63,6 +63,7 @@ import {
filterClaudeMdFromContext,
getMCPServersFromSettings,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../lib/settings-helpers.js';
import { getNotificationService } from './notification-service.js';
@@ -2057,6 +2058,13 @@ Format your response as a structured markdown document.`;
thinkingLevel: analysisThinkingLevel,
});
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[AutoMode]',
projectPath
);
const options: ExecuteOptions = {
prompt,
model: sdkOptions.model ?? analysisModel,
@@ -2066,6 +2074,8 @@ Format your response as a structured markdown document.`;
abortController,
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);
@@ -2934,6 +2944,13 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
);
}
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[AutoMode]',
finalProjectPath
);
const executeOptions: ExecuteOptions = {
prompt: promptContent,
model: bareModel,
@@ -2945,6 +2962,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
settingSources: sdkOptions.settingSources,
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
@@ -3247,6 +3266,8 @@ After generating the revised spec, output:
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
let revisionText = '';
@@ -3392,6 +3413,8 @@ After generating the revised spec, output:
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
let taskOutput = '';
@@ -3486,6 +3509,8 @@ After generating the revised spec, output:
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
for await (const msg of continuationStream) {

View File

@@ -41,7 +41,7 @@ import type { FeatureLoader } from './feature-loader.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { resolveModelString } from '@automaker/model-resolver';
import { stripProviderPrefix } from '@automaker/types';
import { getPromptCustomization } from '../lib/settings-helpers.js';
import { getPromptCustomization, getActiveClaudeApiProfile } from '../lib/settings-helpers.js';
const logger = createLogger('IdeationService');
@@ -223,6 +223,13 @@ export class IdeationService {
// Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[IdeationService]',
projectPath
);
const executeOptions: ExecuteOptions = {
prompt: message,
model: bareModel,
@@ -232,6 +239,8 @@ export class IdeationService {
maxTurns: 1, // Single turn for ideation
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);
@@ -678,6 +687,13 @@ export class IdeationService {
// Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[IdeationService]',
projectPath
);
const executeOptions: ExecuteOptions = {
prompt: prompt.prompt,
model: bareModel,
@@ -688,6 +704,8 @@ export class IdeationService {
// Disable all tools - we just want text generation, not codebase analysis
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;
@@ -372,6 +407,7 @@ export class SettingsService {
ignoreEmptyArrayOverwrite('recentFolders');
ignoreEmptyArrayOverwrite('mcpServers');
ignoreEmptyArrayOverwrite('enabledCursorModels');
ignoreEmptyArrayOverwrite('claudeApiProfiles');
// Empty object overwrite guard
if (
@@ -597,6 +633,17 @@ export class SettingsService {
};
}
// Handle activeClaudeApiProfileId special cases:
// - "__USE_GLOBAL__" marker means delete the key (use global setting)
// - null means explicit "Direct Anthropic API"
// - string means specific profile ID
if (
'activeClaudeApiProfileId' in updates &&
updates.activeClaudeApiProfileId === '__USE_GLOBAL__'
) {
delete updated.activeClaudeApiProfileId;
}
await writeSettingsJson(settingsPath, updated);
logger.info(`Project settings updated for ${projectPath}`);