mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
Merge remote-tracking branch 'origin/main' into feature/v0.13.0rc-1768936017583-e6ni
# Conflicts: # apps/ui/src/components/views/board-view.tsx
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,8 +167,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';
|
||||
@@ -186,7 +192,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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
178
libs/utils/src/string-utils.ts
Normal file
178
libs/utils/src/string-utils.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* String utility functions for common text operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Truncate a string to a maximum length, adding an ellipsis if truncated
|
||||
* @param str - The string to truncate
|
||||
* @param maxLength - Maximum length of the result (including ellipsis)
|
||||
* @param ellipsis - The ellipsis string to use (default: '...')
|
||||
* @returns The truncated string
|
||||
*/
|
||||
export function truncate(str: string, maxLength: number, ellipsis: string = '...'): string {
|
||||
if (maxLength < ellipsis.length) {
|
||||
throw new Error(
|
||||
`maxLength (${maxLength}) must be at least the length of ellipsis (${ellipsis.length})`
|
||||
);
|
||||
}
|
||||
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
|
||||
return str.slice(0, maxLength - ellipsis.length) + ellipsis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to kebab-case (e.g., "Hello World" -> "hello-world")
|
||||
* @param str - The string to convert
|
||||
* @returns The kebab-case string
|
||||
*/
|
||||
export function toKebabCase(str: string): string {
|
||||
return str
|
||||
.replace(/([a-z])([A-Z])/g, '$1-$2') // camelCase -> camel-Case
|
||||
.replace(/[\s_]+/g, '-') // spaces and underscores -> hyphens
|
||||
.replace(/[^a-zA-Z0-9-]/g, '') // remove non-alphanumeric (except hyphens)
|
||||
.replace(/-+/g, '-') // collapse multiple hyphens
|
||||
.replace(/^-|-$/g, '') // remove leading/trailing hyphens
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to camelCase (e.g., "hello-world" -> "helloWorld")
|
||||
* @param str - The string to convert
|
||||
* @returns The camelCase string
|
||||
*/
|
||||
export function toCamelCase(str: string): string {
|
||||
return str
|
||||
.replace(/[^a-zA-Z0-9\s_-]/g, '') // remove special characters
|
||||
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ''))
|
||||
.replace(/^[A-Z]/, (char) => char.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to PascalCase (e.g., "hello-world" -> "HelloWorld")
|
||||
* @param str - The string to convert
|
||||
* @returns The PascalCase string
|
||||
*/
|
||||
export function toPascalCase(str: string): string {
|
||||
const camel = toCamelCase(str);
|
||||
return camel.charAt(0).toUpperCase() + camel.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize the first letter of a string
|
||||
* @param str - The string to capitalize
|
||||
* @returns The string with first letter capitalized
|
||||
*/
|
||||
export function capitalize(str: string): string {
|
||||
if (str.length === 0) {
|
||||
return str;
|
||||
}
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicate whitespace from a string, preserving single spaces
|
||||
* @param str - The string to clean
|
||||
* @returns The string with duplicate whitespace removed
|
||||
*/
|
||||
export function collapseWhitespace(str: string): string {
|
||||
return str.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is empty or contains only whitespace
|
||||
* @param str - The string to check
|
||||
* @returns True if the string is blank
|
||||
*/
|
||||
export function isBlank(str: string | null | undefined): boolean {
|
||||
return str === null || str === undefined || str.trim().length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is not empty and contains non-whitespace characters
|
||||
* @param str - The string to check
|
||||
* @returns True if the string is not blank
|
||||
*/
|
||||
export function isNotBlank(str: string | null | undefined): boolean {
|
||||
return !isBlank(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse a string to an integer, returning a default value on failure
|
||||
* @param str - The string to parse
|
||||
* @param defaultValue - The default value if parsing fails (default: 0)
|
||||
* @returns The parsed integer or the default value
|
||||
*/
|
||||
export function safeParseInt(str: string | null | undefined, defaultValue: number = 0): number {
|
||||
if (isBlank(str)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const parsed = parseInt(str!, 10);
|
||||
return isNaN(parsed) ? defaultValue : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slug from a string (URL-friendly identifier)
|
||||
* @param str - The string to convert to a slug
|
||||
* @param maxLength - Optional maximum length for the slug
|
||||
* @returns The slugified string
|
||||
*/
|
||||
export function slugify(str: string, maxLength?: number): string {
|
||||
let slug = str
|
||||
.toLowerCase()
|
||||
.normalize('NFD') // Normalize unicode characters
|
||||
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Collapse multiple hyphens
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
||||
|
||||
if (maxLength !== undefined && slug.length > maxLength) {
|
||||
// Truncate at word boundary if possible
|
||||
slug = slug.slice(0, maxLength);
|
||||
const lastHyphen = slug.lastIndexOf('-');
|
||||
if (lastHyphen > maxLength * 0.5) {
|
||||
slug = slug.slice(0, lastHyphen);
|
||||
}
|
||||
slug = slug.replace(/-$/g, ''); // Remove trailing hyphen after truncation
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special regex characters in a string
|
||||
* @param str - The string to escape
|
||||
* @returns The escaped string safe for use in a RegExp
|
||||
*/
|
||||
export function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluralize a word based on count
|
||||
* @param word - The singular form of the word
|
||||
* @param count - The count to base pluralization on
|
||||
* @param pluralForm - Optional custom plural form (default: word + 's')
|
||||
* @returns The word in singular or plural form
|
||||
*/
|
||||
export function pluralize(word: string, count: number, pluralForm?: string): string {
|
||||
if (count === 1) {
|
||||
return word;
|
||||
}
|
||||
return pluralForm || `${word}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a count with its associated word (e.g., "1 item", "3 items")
|
||||
* @param count - The count
|
||||
* @param singular - The singular form of the word
|
||||
* @param plural - Optional custom plural form
|
||||
* @returns Formatted string with count and word
|
||||
*/
|
||||
export function formatCount(count: number, singular: string, plural?: string): string {
|
||||
return `${count} ${pluralize(singular, count, plural)}`;
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user