feat: Claude Compatible Providers System (#629)

* feat: refactor Claude API Profiles to Claude Compatible Providers

- Rename ClaudeApiProfile to ClaudeCompatibleProvider with models[] array
- Each ProviderModel has mapsToClaudeModel field for Claude tier mapping
- Add providerType field for provider-specific icons (glm, minimax, openrouter)
- Add thinking level support for provider models in phase selectors
- Show all mapped Claude models per provider model (e.g., "Maps to Haiku, Sonnet, Opus")
- Add Bulk Replace feature to switch all phases to a provider at once
- Hide Bulk Replace button when no providers are enabled
- Fix project-level phaseModelOverrides not persisting after refresh
- Fix deleting last provider not persisting (remove empty array guard)
- Add getProviderByModelId() helper for all SDK routes
- Update all routes to pass provider config for provider models
- Update terminology from "profiles" to "providers" throughout UI
- Update documentation to reflect new provider system

* fix: atomic writer race condition and bulk replace reset to defaults

1. AtomicWriter Race Condition Fix (libs/utils/src/atomic-writer.ts):
   - Changed temp file naming from Date.now() to Date.now() + random hex
   - Uses crypto.randomBytes(4).toString('hex') for uniqueness
   - Prevents ENOENT errors when multiple concurrent writes happen
     within the same millisecond

2. Bulk Replace "Anthropic Direct" Reset (both dialogs):
   - When selecting "Anthropic Direct", now uses DEFAULT_PHASE_MODELS
   - Properly resets thinking levels and other settings to defaults
   - Added thinkingLevel to the change detection comparison
   - Affects both global and project-level bulk replace dialogs

* fix: update tests for new model resolver passthrough behavior

1. model-resolver tests:
   - Unknown models now pass through unchanged (provider model support)
   - Removed expectations for warnings on unknown models
   - Updated case sensitivity and edge case tests accordingly
   - Added tests for provider-like model names (GLM-4.7, MiniMax-M2.1)

2. atomic-writer tests:
   - Updated regex to match new temp file format with random suffix
   - Format changed from .tmp.{timestamp} to .tmp.{timestamp}.{hex}

* refactor: simplify getPhaseModelWithOverrides calls per code review

Address code review feedback on PR #629:
- Make settingsService parameter optional in getPhaseModelWithOverrides
- Function now handles undefined settingsService gracefully by returning defaults
- Remove redundant ternary checks in 4 call sites:
  - apps/server/src/routes/context/routes/describe-file.ts
  - apps/server/src/routes/context/routes/describe-image.ts
  - apps/server/src/routes/worktree/routes/generate-commit-message.ts
  - apps/server/src/services/auto-mode-service.ts
- Remove unused DEFAULT_PHASE_MODELS imports where applicable

* test: fix server tests for provider model passthrough behavior

- Update model-resolver.test.ts to expect unknown models to pass through
  unchanged (supports ClaudeCompatibleProvider models like GLM-4.7)
- Remove warning expectations for unknown models (valid for providers)
- Add missing getCredentials and getGlobalSettings mocks to
  ideation-service.test.ts for settingsService

* fix: address code review feedback for model providers

- Honor thinkingLevel in generate-commit-message.ts
- Pass claudeCompatibleProvider in ideation-service.ts for provider models
- Resolve provider configuration for model overrides in generate-suggestions.ts
- Update "Active Profile" to "Active Provider" label in project-claude-section
- Use substring instead of deprecated substr in api-profiles-section
- Preserve provider enabled state when editing in api-profiles-section

* fix: address CodeRabbit review issues for Claude Compatible Providers

- Fix TypeScript TS2339 error in generate-suggestions.ts where
  settingsService was narrowed to 'never' type in else branch
- Use DEFAULT_PHASE_MODELS per-phase defaults instead of hardcoded
  'sonnet' in settings-helpers.ts
- Remove duplicate eventHooks key in use-settings-migration.ts
- Add claudeCompatibleProviders to localStorage migration parsing
  and merging functions
- Handle canonical claude-* model IDs (claude-haiku, claude-sonnet,
  claude-opus) in project-models-section display names

This resolves the CI build failures and addresses code review feedback.

* fix: skip broken list-view-priority E2E test and add Priority column label

- Skip list-view-priority.spec.ts with TODO explaining the infrastructure
  issue: setupRealProject only sets localStorage but server settings
  take precedence with localStorageMigrated: true
- Add 'Priority' label to list-header.tsx for the priority column
  (was empty string, now shows proper header text)
- Increase column width to accommodate the label

The E2E test issue is that tests create features in a temp directory,
but the server loads from the E2E Test Project fixture path set in
setup-e2e-fixtures.mjs. Needs infrastructure fix to properly switch
projects or create features through UI instead of on disk.
This commit is contained in:
Stefan de Vogelaere
2026-01-20 20:57:23 +01:00
committed by GitHub
parent 8facdc66a9
commit a1f234c7e2
48 changed files with 3870 additions and 1089 deletions

View File

@@ -113,11 +113,12 @@ export function resolveModelString(
return canonicalKey;
}
// Unknown model key - use default
console.warn(
`[ModelResolver] Unknown model key "${canonicalKey}", using default: "${defaultModel}"`
// Unknown model key - pass through as-is (could be a provider model like GLM-4.7, MiniMax-M2.1)
// This allows ClaudeCompatibleProvider models to work without being registered here
console.log(
`[ModelResolver] Unknown model key "${canonicalKey}", passing through unchanged (may be a provider model)`
);
return defaultModel;
return canonicalKey;
}
/**
@@ -145,6 +146,8 @@ export interface ResolvedPhaseModel {
model: string;
/** Optional thinking level for extended thinking */
thinkingLevel?: ThinkingLevel;
/** Provider ID if using a ClaudeCompatibleProvider */
providerId?: string;
}
/**
@@ -198,8 +201,23 @@ export function resolvePhaseModel(
// Handle new PhaseModelEntry object format
console.log(
`[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}"`
`[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}", providerId="${phaseModel.providerId}"`
);
// If providerId is set, pass through the model string unchanged
// (it's a provider-specific model ID like "GLM-4.5-Air", not a Claude alias)
if (phaseModel.providerId) {
console.log(
`[ModelResolver] Using provider model: providerId="${phaseModel.providerId}", model="${phaseModel.model}"`
);
return {
model: phaseModel.model, // Pass through unchanged
thinkingLevel: phaseModel.thinkingLevel,
providerId: phaseModel.providerId,
};
}
// No providerId - resolve through normal Claude model mapping
return {
model: resolveModelString(phaseModel.model, defaultModel),
thinkingLevel: phaseModel.thinkingLevel,

View File

@@ -168,32 +168,38 @@ describe('model-resolver', () => {
});
});
describe('with unknown model keys', () => {
it('should return default for unknown model key', () => {
describe('with unknown model keys (provider models)', () => {
// Unknown models are now passed through unchanged to support
// ClaudeCompatibleProvider models like GLM-4.7, MiniMax-M2.1, etc.
it('should pass through unknown model key unchanged (may be provider model)', () => {
const result = resolveModelString('unknown-model');
expect(result).toBe(DEFAULT_MODELS.claude);
expect(result).toBe('unknown-model');
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('passing through unchanged')
);
});
it('should warn about unknown model key', () => {
it('should pass through provider-like model names', () => {
const glmModel = resolveModelString('GLM-4.7');
const minimaxModel = resolveModelString('MiniMax-M2.1');
expect(glmModel).toBe('GLM-4.7');
expect(minimaxModel).toBe('MiniMax-M2.1');
});
it('should not warn about unknown model keys (they are valid provider models)', () => {
resolveModelString('unknown-model');
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown model key'));
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('unknown-model'));
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should use custom default for unknown model key', () => {
it('should ignore custom default for unknown model key (passthrough takes precedence)', () => {
const customDefault = 'claude-opus-4-20241113';
const result = resolveModelString('truly-unknown-model', customDefault);
expect(result).toBe(customDefault);
});
it('should warn and show default being used', () => {
const customDefault = 'claude-custom-default';
resolveModelString('invalid-key', customDefault);
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining(customDefault));
// Unknown models pass through unchanged, default is not used
expect(result).toBe('truly-unknown-model');
});
});
@@ -202,17 +208,17 @@ describe('model-resolver', () => {
const resultUpper = resolveModelString('SONNET');
const resultLower = resolveModelString('sonnet');
// Uppercase should not resolve (falls back to default)
expect(resultUpper).toBe(DEFAULT_MODELS.claude);
// Lowercase should resolve
// Uppercase is passed through (could be a provider model)
expect(resultUpper).toBe('SONNET');
// Lowercase should resolve to Claude model
expect(resultLower).toBe(CLAUDE_MODEL_MAP.sonnet);
});
it('should handle mixed case in claude- strings', () => {
const result = resolveModelString('Claude-Sonnet-4-20250514');
// Capital 'C' means it won't match 'claude-', falls back to default
expect(result).toBe(DEFAULT_MODELS.claude);
// Capital 'C' means it won't match 'claude-', passed through as provider model
expect(result).toBe('Claude-Sonnet-4-20250514');
});
});
@@ -220,14 +226,15 @@ describe('model-resolver', () => {
it('should handle model key with whitespace', () => {
const result = resolveModelString(' sonnet ');
// Will not match due to whitespace, falls back to default
expect(result).toBe(DEFAULT_MODELS.claude);
// Will not match due to whitespace, passed through as-is (could be provider model)
expect(result).toBe(' sonnet ');
});
it('should handle special characters in model key', () => {
const result = resolveModelString('model@123');
expect(result).toBe(DEFAULT_MODELS.claude);
// Passed through as-is (could be a provider model)
expect(result).toBe('model@123');
});
});
});
@@ -325,11 +332,11 @@ describe('model-resolver', () => {
expect(result).toBe(CLAUDE_MODEL_MAP.opus);
});
it('should handle fallback chain: unknown -> session -> default', () => {
const result = getEffectiveModel('invalid', 'also-invalid', 'claude-opus-4-20241113');
it('should pass through unknown model (may be provider model)', () => {
const result = getEffectiveModel('GLM-4.7', 'also-unknown', 'claude-opus-4-20241113');
// Both invalid models fall back to default
expect(result).toBe('claude-opus-4-20241113');
// Unknown models pass through unchanged (could be provider models)
expect(result).toBe('GLM-4.7');
});
it('should handle session with alias, no explicit', () => {
@@ -523,19 +530,21 @@ describe('model-resolver', () => {
expect(result.thinkingLevel).toBeUndefined();
});
it('should handle unknown model alias in entry', () => {
const entry: PhaseModelEntry = { model: 'unknown-model' as any };
it('should pass through unknown model in entry (may be provider model)', () => {
const entry: PhaseModelEntry = { model: 'GLM-4.7' as any };
const result = resolvePhaseModel(entry);
expect(result.model).toBe(DEFAULT_MODELS.claude);
// Unknown models pass through unchanged (could be provider models)
expect(result.model).toBe('GLM-4.7');
});
it('should use custom default for unknown model in entry', () => {
const entry: PhaseModelEntry = { model: 'invalid' as any, thinkingLevel: 'high' };
it('should pass through unknown model with thinkingLevel', () => {
const entry: PhaseModelEntry = { model: 'MiniMax-M2.1' as any, thinkingLevel: 'high' };
const customDefault = 'claude-haiku-4-5-20251001';
const result = resolvePhaseModel(entry, customDefault);
expect(result.model).toBe(customDefault);
// Unknown models pass through, thinkingLevel is preserved
expect(result.model).toBe('MiniMax-M2.1');
expect(result.thinkingLevel).toBe('high');
});
});

View File

@@ -161,8 +161,14 @@ export type {
EventHookHttpAction,
EventHookAction,
EventHook,
// Claude API profile types
// Claude-compatible provider types (new)
ApiKeySource,
ClaudeCompatibleProviderType,
ClaudeModelAlias,
ProviderModel,
ClaudeCompatibleProvider,
ClaudeCompatibleProviderTemplate,
// Claude API profile types (deprecated)
ClaudeApiProfile,
ClaudeApiProfileTemplate,
} from './settings.js';
@@ -180,7 +186,9 @@ export {
getThinkingTokenBudget,
// Event hook constants
EVENT_HOOK_TRIGGER_LABELS,
// Claude API profile constants
// Claude-compatible provider templates (new)
CLAUDE_PROVIDER_TEMPLATES,
// Claude API profile constants (deprecated)
CLAUDE_API_PROFILE_TEMPLATES,
} from './settings.js';

View File

@@ -2,7 +2,12 @@
* Shared types for AI model providers
*/
import type { ThinkingLevel, ClaudeApiProfile, Credentials } from './settings.js';
import type {
ThinkingLevel,
ClaudeApiProfile,
ClaudeCompatibleProvider,
Credentials,
} from './settings.js';
import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js';
/**
@@ -213,11 +218,19 @@ export interface ExecuteOptions {
* Active Claude API profile for alternative endpoint configuration.
* When set, uses profile's settings (base URL, auth, model mappings) instead of direct Anthropic API.
* When undefined, uses direct Anthropic API (via API key or Claude Max CLI OAuth).
* @deprecated Use claudeCompatibleProvider instead
*/
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.
* Claude-compatible provider for alternative endpoint configuration.
* When set, uses provider's connection settings (base URL, auth) instead of direct Anthropic API.
* Models are passed directly without alias mapping.
* Takes precedence over claudeApiProfile if both are set.
*/
claudeCompatibleProvider?: ClaudeCompatibleProvider;
/**
* Credentials for resolving 'credentials' apiKeySource in Claude API profiles/providers.
* When a profile/provider has apiKeySource='credentials', the Anthropic key from this object is used.
*/
credentials?: Credentials;
}

View File

@@ -102,7 +102,7 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number
export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode';
// ============================================================================
// Claude API Profiles - Configuration for Claude-compatible API endpoints
// Claude-Compatible Providers - Configuration for Claude-compatible API endpoints
// ============================================================================
/**
@@ -114,10 +114,90 @@ export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode';
*/
export type ApiKeySource = 'inline' | 'env' | 'credentials';
/**
* ClaudeCompatibleProviderType - Type of Claude-compatible provider
*
* Used to determine provider-specific UI screens and default configurations.
*/
export type ClaudeCompatibleProviderType =
| 'anthropic' // Direct Anthropic API (built-in)
| 'glm' // z.AI GLM
| 'minimax' // MiniMax
| 'openrouter' // OpenRouter proxy
| 'custom'; // User-defined custom provider
/**
* ClaudeModelAlias - The three main Claude model aliases for mapping
*/
export type ClaudeModelAlias = 'haiku' | 'sonnet' | 'opus';
/**
* ProviderModel - A model exposed by a Claude-compatible provider
*
* Each provider configuration can expose multiple models that will appear
* in all model dropdowns throughout the app. Models map directly to a
* Claude model (haiku, sonnet, opus) for bulk replace and display.
*/
export interface ProviderModel {
/** Model ID sent to the API (e.g., "GLM-4.7", "MiniMax-M2.1") */
id: string;
/** Display name shown in UI (e.g., "GLM 4.7", "MiniMax M2.1") */
displayName: string;
/** Which Claude model this maps to (for bulk replace and display) */
mapsToClaudeModel?: ClaudeModelAlias;
/** Model capabilities */
capabilities?: {
/** Whether model supports vision/image inputs */
supportsVision?: boolean;
/** Whether model supports extended thinking */
supportsThinking?: boolean;
/** Maximum thinking level if thinking is supported */
maxThinkingLevel?: ThinkingLevel;
};
}
/**
* ClaudeCompatibleProvider - Configuration for a Claude-compatible API endpoint
*
* Providers expose their models to all model dropdowns in the app.
* Each provider has its own API configuration (endpoint, credentials, etc.)
*/
export interface ClaudeCompatibleProvider {
/** Unique identifier (uuid) */
id: string;
/** Display name (e.g., "z.AI GLM (Work)", "MiniMax") */
name: string;
/** Provider type determines UI screen and default settings */
providerType: ClaudeCompatibleProviderType;
/** Whether this provider is enabled (models appear in dropdowns) */
enabled?: boolean;
// Connection settings
/** ANTHROPIC_BASE_URL - custom API endpoint */
baseUrl: string;
/** API key sourcing strategy */
apiKeySource: ApiKeySource;
/** API key value (only required when apiKeySource = 'inline') */
apiKey?: string;
/** If true, use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY */
useAuthToken?: boolean;
/** API_TIMEOUT_MS override in milliseconds */
timeoutMs?: number;
/** Set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 */
disableNonessentialTraffic?: boolean;
/** Models exposed by this provider (appear in all dropdowns) */
models: ProviderModel[];
/** Provider-specific settings for future extensibility */
providerSettings?: Record<string, unknown>;
}
/**
* ClaudeApiProfile - Configuration for a Claude-compatible API endpoint
*
* Allows using alternative providers like z.AI GLM, AWS Bedrock, etc.
* @deprecated Use ClaudeCompatibleProvider instead. This type is kept for
* backward compatibility during migration.
*/
export interface ClaudeApiProfile {
/** Unique identifier (uuid) */
@@ -139,7 +219,7 @@ export interface ClaudeApiProfile {
useAuthToken?: boolean;
/** API_TIMEOUT_MS override in milliseconds */
timeoutMs?: number;
/** Optional model name mappings */
/** Optional model name mappings (deprecated - use ClaudeCompatibleProvider.models instead) */
modelMappings?: {
/** Maps to ANTHROPIC_DEFAULT_HAIKU_MODEL */
haiku?: string;
@@ -152,11 +232,136 @@ export interface ClaudeApiProfile {
disableNonessentialTraffic?: boolean;
}
/** Known provider templates for quick setup */
/**
* ClaudeCompatibleProviderTemplate - Template for quick provider setup
*
* Contains pre-configured settings for known Claude-compatible providers.
*/
export interface ClaudeCompatibleProviderTemplate {
/** Template identifier for matching */
templateId: ClaudeCompatibleProviderType;
/** Display name for the template */
name: string;
/** Provider type */
providerType: ClaudeCompatibleProviderType;
/** API base URL */
baseUrl: string;
/** Default API key source for this template */
defaultApiKeySource: ApiKeySource;
/** Use auth token instead of API key */
useAuthToken: boolean;
/** Timeout in milliseconds */
timeoutMs?: number;
/** Disable non-essential traffic */
disableNonessentialTraffic?: boolean;
/** Description shown in UI */
description: string;
/** URL to get API key */
apiKeyUrl?: string;
/** Default models for this provider */
defaultModels: ProviderModel[];
}
/** Predefined templates for known Claude-compatible providers */
export const CLAUDE_PROVIDER_TEMPLATES: ClaudeCompatibleProviderTemplate[] = [
{
templateId: 'anthropic',
name: 'Direct Anthropic',
providerType: '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',
defaultModels: [
{ id: 'claude-haiku', displayName: 'Claude Haiku', mapsToClaudeModel: 'haiku' },
{ id: 'claude-sonnet', displayName: 'Claude Sonnet', mapsToClaudeModel: 'sonnet' },
{ id: 'claude-opus', displayName: 'Claude Opus', mapsToClaudeModel: 'opus' },
],
},
{
templateId: 'openrouter',
name: 'OpenRouter',
providerType: 'openrouter',
baseUrl: 'https://openrouter.ai/api',
defaultApiKeySource: 'inline',
useAuthToken: true,
description: 'Access Claude and 300+ models via OpenRouter',
apiKeyUrl: 'https://openrouter.ai/keys',
defaultModels: [
// OpenRouter users manually add model IDs
{
id: 'anthropic/claude-3.5-haiku',
displayName: 'Claude 3.5 Haiku',
mapsToClaudeModel: 'haiku',
},
{
id: 'anthropic/claude-3.5-sonnet',
displayName: 'Claude 3.5 Sonnet',
mapsToClaudeModel: 'sonnet',
},
{ id: 'anthropic/claude-3-opus', displayName: 'Claude 3 Opus', mapsToClaudeModel: 'opus' },
],
},
{
templateId: 'glm',
name: 'z.AI GLM',
providerType: 'glm',
baseUrl: 'https://api.z.ai/api/anthropic',
defaultApiKeySource: 'inline',
useAuthToken: true,
timeoutMs: 3000000,
disableNonessentialTraffic: true,
description: '3× usage at fraction of cost via GLM Coding Plan',
apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list',
defaultModels: [
{ id: 'GLM-4.5-Air', displayName: 'GLM 4.5 Air', mapsToClaudeModel: 'haiku' },
{ id: 'GLM-4.7', displayName: 'GLM 4.7', mapsToClaudeModel: 'sonnet' },
{ id: 'GLM-4.7', displayName: 'GLM 4.7', mapsToClaudeModel: 'opus' },
],
},
{
templateId: 'minimax',
name: 'MiniMax',
providerType: 'minimax',
baseUrl: 'https://api.minimax.io/anthropic',
defaultApiKeySource: 'inline',
useAuthToken: true,
timeoutMs: 3000000,
disableNonessentialTraffic: true,
description: 'MiniMax M2.1 coding model with extended context',
apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key',
defaultModels: [
{ id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'haiku' },
{ id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'sonnet' },
{ id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'opus' },
],
},
{
templateId: 'minimax',
name: 'MiniMax (China)',
providerType: 'minimax',
baseUrl: 'https://api.minimaxi.com/anthropic',
defaultApiKeySource: 'inline',
useAuthToken: true,
timeoutMs: 3000000,
disableNonessentialTraffic: true,
description: 'MiniMax M2.1 for users in China',
apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key',
defaultModels: [
{ id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'haiku' },
{ id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'sonnet' },
{ id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'opus' },
],
},
];
/**
* @deprecated Use ClaudeCompatibleProviderTemplate instead
*/
export interface ClaudeApiProfileTemplate {
name: string;
baseUrl: string;
/** Default API key source for this template (user chooses when creating) */
defaultApiKeySource?: ApiKeySource;
useAuthToken: boolean;
timeoutMs?: number;
@@ -166,7 +371,9 @@ export interface ClaudeApiProfileTemplate {
apiKeyUrl?: string;
}
/** Predefined templates for known Claude-compatible providers */
/**
* @deprecated Use CLAUDE_PROVIDER_TEMPLATES instead
*/
export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [
{
name: 'Direct Anthropic',
@@ -229,7 +436,6 @@ export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [
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.
];
// ============================================================================
@@ -340,8 +546,21 @@ const DEFAULT_CODEX_ADDITIONAL_DIRS: string[] = [];
* - Claude models: Use thinkingLevel for extended thinking
* - Codex models: Use reasoningEffort for reasoning intensity
* - Cursor models: Handle thinking internally
*
* For Claude-compatible provider models (GLM, MiniMax, OpenRouter, etc.),
* the providerId field specifies which provider configuration to use.
*/
export interface PhaseModelEntry {
/**
* Provider ID for Claude-compatible provider models.
* - undefined: Use native Anthropic API (no custom provider)
* - string: Use the specified ClaudeCompatibleProvider by ID
*
* Only required when using models from a ClaudeCompatibleProvider.
* Native Claude models (claude-haiku, claude-sonnet, claude-opus) and
* other providers (Cursor, Codex, OpenCode) don't need this field.
*/
providerId?: string;
/** The model to use (supports Claude, Cursor, Codex, OpenCode, and dynamic provider IDs) */
model: ModelId;
/** Extended thinking level (only applies to Claude models, defaults to 'none') */
@@ -790,16 +1009,24 @@ export interface GlobalSettings {
*/
eventHooks?: EventHook[];
// Claude API Profiles Configuration
// Claude-Compatible Providers Configuration
/**
* Claude-compatible API endpoint profiles
* Allows using alternative providers like z.AI GLM, AWS Bedrock, etc.
* Claude-compatible provider configurations.
* Each provider exposes its models to all model dropdowns in the app.
* Models can be mixed across providers (e.g., use GLM for enhancements, Anthropic for generation).
*/
claudeCompatibleProviders?: ClaudeCompatibleProvider[];
// Deprecated Claude API Profiles (kept for migration)
/**
* @deprecated Use claudeCompatibleProviders instead.
* Kept for backward compatibility during migration.
*/
claudeApiProfiles?: ClaudeApiProfile[];
/**
* Active profile ID (null/undefined = use direct Anthropic API)
* When set, the corresponding profile's settings will be used for Claude API calls
* @deprecated No longer used. Models are selected per-phase via phaseModels.
* Each PhaseModelEntry can specify a providerId for provider-specific models.
*/
activeClaudeApiProfileId?: string | null;
@@ -951,12 +1178,19 @@ export interface ProjectSettings {
/** Maximum concurrent agents for this project (overrides global maxConcurrency) */
maxConcurrentAgents?: number;
// Claude API Profile Override (per-project)
// Phase Model Overrides (per-project)
/**
* Override the active Claude API profile for this project.
* - undefined: Use global setting (activeClaudeApiProfileId)
* - null: Explicitly use Direct Anthropic API (no profile)
* - string: Use specific profile by ID
* Override phase model settings for this project.
* Any phase not specified here falls back to global phaseModels setting.
* Allows per-project customization of which models are used for each task.
*/
phaseModelOverrides?: Partial<PhaseModelConfig>;
// Deprecated Claude API Profile Override
/**
* @deprecated Use phaseModelOverrides instead.
* Models are now selected per-phase via phaseModels/phaseModelOverrides.
* Each PhaseModelEntry can specify a providerId for provider-specific models.
*/
activeClaudeApiProfileId?: string | null;
}
@@ -992,7 +1226,7 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
};
/** Current version of the global settings schema */
export const SETTINGS_VERSION = 5;
export const SETTINGS_VERSION = 6;
/** Current version of the credentials schema */
export const CREDENTIALS_VERSION = 1;
/** Current version of the project settings schema */
@@ -1081,6 +1315,9 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
skillsSources: ['user', 'project'],
enableSubagents: true,
subagentsSources: ['user', 'project'],
// New provider system
claudeCompatibleProviders: [],
// Deprecated - kept for migration
claudeApiProfiles: [],
activeClaudeApiProfileId: null,
autoModeByWorktree: {},

View File

@@ -7,6 +7,7 @@
import { secureFs } from '@automaker/platform';
import path from 'path';
import crypto from 'crypto';
import { createLogger } from './logger.js';
import { mkdirSafe } from './fs-utils.js';
@@ -99,7 +100,9 @@ export async function atomicWriteJson<T>(
): Promise<void> {
const { indent = 2, createDirs = false, backupCount = 0 } = options;
const resolvedPath = path.resolve(filePath);
const tempPath = `${resolvedPath}.tmp.${Date.now()}`;
// Use timestamp + random suffix to ensure uniqueness even for concurrent writes
const uniqueSuffix = `${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
const tempPath = `${resolvedPath}.tmp.${uniqueSuffix}`;
// Create parent directories if requested
if (createDirs) {

View File

@@ -64,16 +64,17 @@ describe('atomic-writer.ts', () => {
await atomicWriteJson(filePath, data);
// Verify writeFile was called with temp file path and JSON content
// Format: .tmp.{timestamp}.{random-hex}
expect(secureFs.writeFile).toHaveBeenCalledTimes(1);
const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0];
expect(writeCall[0]).toMatch(/\.tmp\.\d+$/);
expect(writeCall[0]).toMatch(/\.tmp\.\d+\.[a-f0-9]+$/);
expect(writeCall[1]).toBe(JSON.stringify(data, null, 2));
expect(writeCall[2]).toBe('utf-8');
// Verify rename was called with temp -> target
expect(secureFs.rename).toHaveBeenCalledTimes(1);
const renameCall = (secureFs.rename as unknown as MockInstance).mock.calls[0];
expect(renameCall[0]).toMatch(/\.tmp\.\d+$/);
expect(renameCall[0]).toMatch(/\.tmp\.\d+\.[a-f0-9]+$/);
expect(renameCall[1]).toBe(path.resolve(filePath));
});