diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..d20ad8090 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,292 @@ +# Changelog + +All notable changes to AutoMaker will be documented in this file. + +## [Unreleased] - 2025-01 + +### Added - Multi-Provider UX Enhancements + +This release enhances the multi-provider system with improved user experience for provider management and model equivalence routing. + +#### Backend Changes + +**Model Resolver System** + +- Added `resolveModelWithProviderAvailability()` function for provider-aware model resolution +- `MODEL_EQUIVALENCE` map for cross-provider model fallback: + - `claude-opus-4.5-...` ↔ `glm-4.7` (Premium tier) + - `claude-sonnet-4.5-...` ↔ `glm-4.6` (Balanced tier) + - `claude-haiku-4.5-...` ↔ `glm-4.5-air` (Speed tier) +- `getProviderForModel()` helper function for provider detection +- `EnabledProviders` type for provider availability configuration + +**Provider Query Layer** + +- `executeProviderQuery()` accepts `enabledProviders` option +- Automatic model substitution when provider is disabled +- Logged fallback behavior for debugging + +**Type System** + +- `GlobalSettings` includes `enabledProviders: { claude: boolean; zai: boolean }` +- `DEFAULT_GLOBAL_SETTINGS` has both providers enabled by default +- Exported `MODEL_EQUIVALENCE` and `getProviderForModel` from types package + +#### Frontend Changes + +**Settings UI - Provider Cards** + +- Redesigned API Keys section with provider cards +- Each provider card includes: + - Provider name, icon, and description + - Enable/disable toggle switch + - API key input with visibility toggle + - Test connection button + - Verification status indicator +- Auto-enable provider when valid API key is saved +- Manual toggle to disable providers without removing API keys + +**Setup Wizard - Provider Selection** + +- New `ProviderSelectionStep` component for choosing primary provider +- Provider selection cards with feature comparison: + - Claude: CLI support + API key, multiple models (Haiku, Sonnet, Opus) + - Zai: API key only, GLM models (GLM-4.7, GLM-4.6v, GLM-4.6, GLM-4.5-Air) +- New `ZaiSetupStep` component for Zai API key configuration +- Updated wizard flow: `welcome → theme → provider_selection → provider_setup → github → complete` +- Dynamic routing based on selected provider + +**State Management** + +- `app-store` includes `enabledProviders` state with actions: + - `setEnabledProviders()` - Set provider enable states + - `toggleProvider()` - Toggle single provider +- `setup-store` includes `selectedProvider` for wizard flow +- Both providers persisted in settings + +**New Components** + +- `provider-card.tsx` - Reusable provider configuration card +- `provider-selection-step.tsx` - Provider choice in setup wizard +- `zai-setup-step.tsx` - Zai-specific setup step + +#### Architecture Updates + +- Updated `architecture.md` with: + - Provider enable/disable UX documentation + - Model equivalence routing explanation + - Onboarding wizard flow documentation + - Provider configuration in state management + +### Added - Zai (GLM) Provider Integration + +This release adds support for Z.ai's GLM models as an alternative AI provider alongside Claude. + +#### Backend Changes + +**New Provider Implementation** + +- `ZaiProvider` class implementing the `BaseProvider` interface + - Full support for GLM-4.7, GLM-4.6v, GLM-4.6, and GLM-4.5-Air models + - Tool calling support (Read, Write, Edit, Glob, Grep, Bash) + - Vision support via GLM-4.6v with automatic fallback for non-vision models + - Extended thinking mode support with `reasoning_content` streaming + - Structured output via JSON response format + +**Provider-Agnostic Query Layer** + +- `executeProviderQuery()` function for unified AI provider routing +- Automatic model-based provider selection +- Provider-specific API key management +- Structured output handling for non-Claude providers + +**Provider Factory Updates** + +- GLM model prefix routing (`glm-*` → ZaiProvider) +- Alias support (`glm` → `glm-4.5-air`) +- Provider discovery and status checking + +**Service Layer Updates** + +- `AgentService`: Provider-agnostic model selection and query execution +- `AutoModeService`: Zai model support for autonomous feature execution +- `SettingsService`: Zai API key storage and migration + +**Route Updates** + +- All AI routes converted to use `executeProviderQuery()`: + - `/api/agent` - Agent conversations + - `/api/app-spec/generate-spec` - Specification generation + - `/api/app-spec/generate-features` - Feature generation + - `/api/suggestions` - AI suggestions + - `/api/enhance-prompt` - Prompt enhancement + - `/api/context/describe-file` - File description + +**New Endpoints** + +- `GET /api/models/providers` - Provider status and availability +- `GET /api/models/available` - Lists all available models from all providers + +#### Frontend Changes + +**Model Selection** + +- Zai models integrated into model selector with badges (Premium, Vision, Balanced, Speed) +- Model constants with provider metadata +- Display names and model aliases + +**Settings & Configuration** + +- Zai provider configuration in API providers config +- API key management with live validation +- Authentication status display for Zai +- Feature defaults support for Zai models + +**Profiles** + +- Provider auto-detection from model selection +- Thinking level support for Zai models +- Model support utilities (`getProviderFromModel`, `modelSupportsThinking`) + +**Type System** + +- `ModelProvider` type: `'claude' | 'zai'` +- `ZAI_MODEL_MAP` for model alias resolution +- Credentials interface includes `zai` property +- Model display metadata for UI rendering + +**Agent View** + +- Reasoning content display with collapsible sections +- Brain icon for extended thinking +- User setting `showReasoningByDefault` for default expanded state + +#### Library Changes + +**Model Resolver (`libs/model-resolver`)** + +- Provider-aware model resolution with `providerHint` parameter +- Zai model aliases and display names +- Unified model string resolution + +**Types (`libs/types`)** + +- `ModelProvider` type definition +- `ZAI_MODEL_MAP` constant +- Model display metadata for GLM models +- Updated settings types for Zai credentials + +#### Tests + +**New Test Files** + +- `zai-provider.test.ts` (560 lines) - Comprehensive unit tests covering: + - Basic query execution + - Model selection and configuration + - Tool calling (all 6 tools) + - System prompt handling + - Reasoning content support + - Vision support and fallback + - Structured output requests + - Error handling + - Installation detection + - Configuration validation + +**Updated Tests** + +- `provider-factory.test.ts` - Zai model routing tests + +#### Documentation + +**Architecture Documentation** + +- Comprehensive Zai integration section in `architecture.md`: + - Provider architecture overview + - Model information and capabilities table + - Tool mapping documentation + - Vision handling explanation + - Extended thinking mode documentation + - Security features + - Configuration guide + - Extension points for adding new providers + +#### Configuration + +**Environment Variables** + +- `ZAI_API_KEY` - Z.ai API authentication +- `AUTOMAKER_MODEL_*` - Use-specific model selection (supports Zai models) + +**Model Selection** +| Variable | Purpose | +|----------|---------| +| `AUTOMAKER_MODEL_SPEC` | Model for spec generation | +| `AUTOMAKER_MODEL_FEATURES` | Model for feature generation | +| `AUTOMAKER_MODEL_SUGGESTIONS` | Model for suggestions | +| `AUTOMAKER_MODEL_CHAT` | Model for chat sessions | +| `AUTOMAKER_MODEL_AUTO` | Model for auto-mode execution | +| `AUTOMAKER_MODEL_DEFAULT` | Default fallback model | + +### Breaking Changes + +None. The Zai integration is fully additive and maintains backward compatibility with existing Claude-only workflows. + +### Migration Guide + +**Adding Zai API Key:** + +1. Open Settings → API Keys +2. Enter your Z.ai API key (get from https://open.bigmodel.cn/) +3. Click "Test" to validate +4. Save + +**Using Zai Models:** + +- Select a GLM model from the model selector in any AI feature +- Or set `AUTOMAKER_MODEL_DEFAULT=glm-4.7` environment variable + +**Provider-Aware Defaults:** + +```typescript +import { resolveModelString } from '@automaker/model-resolver'; + +// Default to Zai +const model = resolveModelString(undefined, undefined, 'zai'); +// Returns: 'glm-4.7' + +// Default to Claude +const model = resolveModelString(undefined, undefined, 'claude'); +// Returns: 'claude-opus-4.5-20251101' +``` + +**Provider Name Change:** + +If your code reads `ModelDefinition.provider`, it will return `'claude'` instead of `'anthropic'`. +Use `BaseProvider.normalizeProviderName()` for backward compatibility if needed: + +```typescript +import { BaseProvider } from '@/providers/base-provider'; + +// Legacy code expecting 'anthropic' +const provider = model.provider; // Returns 'claude' + +// Normalize for backward compatibility +const normalized = BaseProvider.normalizeProviderName(provider); +// Returns 'anthropic' if provider is 'claude' +``` + +### Known Issues + +- ~~Streaming not yet implemented for Zai~~ (streaming support is now implemented) +- Tool execution uses shell commands (requires security hardening for production use) +- Structured output for non-Claude models uses regex parsing (planned: capability flag) +- Model display naming inconsistent between views ("Opus" vs "GLM-4.7") + +### Future Enhancements + +- ~~Add streaming support for Zai models~~ (implemented) +- Implement native structured output capability flag +- Security hardening for tool execution +- E2E tests for Zai integration +- Provider registration map instead of if/else chains +- Visual provider indicators in UI diff --git a/README.md b/README.md index f9cabfd0d..c0e52454f 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,32 @@ DATA_DIR=./data The application can store your API key securely in the settings UI. The key is persisted in the `DATA_DIR` directory. +#### Option 3: Z.ai (GLM) API Key + +If using Z.ai GLM models, you can provide your API key using one of these methods: + +##### 3a. Shell Configuration + +Add to your `~/.bashrc` or `~/.zshrc`: + +```bash +export ZAI_API_KEY="your-zai-key" +``` + +Then restart your terminal or run `source ~/.bashrc` (or `source ~/.zshrc`). + +##### 3b. .env File + +Add to your `.env` file in the project root: + +```bash +ZAI_API_KEY=your-zai-key +``` + +##### 3c. In-App Storage + +The Z.ai API key can also be stored securely in the settings UI under API Keys. + ## Features ### Core Workflow @@ -334,7 +360,7 @@ The application can store your API key securely in the settings UI. The key is p ### AI & Planning -- 🧠 **Multi-Model Support** - Choose from Claude Opus, Sonnet, and Haiku per feature +- 🧠 **Multi-Model Support** - Choose from Claude (Opus, Sonnet, Haiku) or Z.ai (GLM-4.7, GLM-4.6v, GLM-4.6, GLM-4.5-Air) models per feature - 💭 **Extended Thinking** - Enable thinking modes (none, medium, deep, ultra) for complex problem-solving - 📝 **Planning Modes** - Four planning levels: skip (direct implementation), lite (quick plan), spec (task breakdown), full (phased execution) - ✅ **Plan Approval** - Review and approve AI-generated plans before implementation begins @@ -480,7 +506,7 @@ automaker/ ### Key Architectural Patterns - **Event-Driven Architecture** - All server operations emit events that stream to the frontend -- **Provider Pattern** - Extensible AI provider system (currently Claude, designed for future providers) +- **Provider Pattern** - Extensible AI provider system supporting Claude (Anthropic) and Z.ai (GLM) models - **Service-Oriented Backend** - Modular services for agent management, features, terminals, settings - **State Management** - Zustand with persistence for frontend state across restarts - **File-Based Storage** - No database; features stored as JSON files in `.automaker/` directory diff --git a/apps/server/package.json b/apps/server/package.json index 081c7f23e..493a31227 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -31,6 +31,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", + "express-rate-limit": "^8.2.1", "morgan": "^1.10.1", "node-pty": "1.1.0-beta41", "ws": "^8.18.3" diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 6b63ffa8d..6a0645472 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -12,6 +12,8 @@ import morgan from 'morgan'; import { WebSocketServer, WebSocket } from 'ws'; import { createServer } from 'http'; import dotenv from 'dotenv'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { createEventEmitter, type EventEmitter } from './lib/events.js'; import { initAllowedPaths } from '@automaker/platform'; @@ -51,15 +53,32 @@ import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; import { cleanupStaleValidations } from './routes/github/routes/validation-common.js'; -// Load environment variables +// Load environment variables from multiple locations +// Try root .env first, then local .env +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootEnvPath = path.join(__dirname, '../../../.env'); + +console.log(`[Server] Loading .env from: ${rootEnvPath}`); +const rootResult = dotenv.config({ path: rootEnvPath }); +console.log(`[Server] Root .env loaded: ${Object.keys(rootResult.parsed || {}).length} variables`); dotenv.config(); +// Log model overrides for debugging +if (process.env.AUTOMAKER_MODEL_SPEC) { + console.log(`[Server] AUTOMAKER_MODEL_SPEC=${process.env.AUTOMAKER_MODEL_SPEC}`); +} +if (process.env.AUTOMAKER_MODEL_DEFAULT) { + console.log(`[Server] AUTOMAKER_MODEL_DEFAULT=${process.env.AUTOMAKER_MODEL_DEFAULT}`); +} + const PORT = parseInt(process.env.PORT || '3008', 10); const DATA_DIR = process.env.DATA_DIR || './data'; const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true // Check for required environment variables const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; +const hasZaiKey = !!process.env.ZAI_API_KEY; if (!hasAnthropicKey) { console.warn(` @@ -78,6 +97,23 @@ if (!hasAnthropicKey) { console.log('[Server] ✓ ANTHROPIC_API_KEY detected (API key auth)'); } +if (!hasZaiKey) { + console.warn(` +╔═══════════════════════════════════════════════════════════════════════╗ +║ ⚠️ WARNING: Z.ai API key not configured ║ +║ ║ +║ Z.ai GLM models require ZAI_API_KEY to function. ║ +║ ║ +║ Set your Z.ai API key: ║ +║ export ZAI_API_KEY="your-key-here" ║ +║ ║ +║ Or configure in Settings. Zai models will not work without this. ║ +╚═══════════════════════════════════════════════════════════════════════╝ +`); +} else { + console.log('[Server] ✓ ZAI_API_KEY detected (GLM models available)'); +} + // Initialize security initAllowedPaths(); @@ -144,9 +180,9 @@ app.use('/api', authMiddleware); app.use('/api/fs', createFsRoutes(events)); app.use('/api/agent', createAgentRoutes(agentService, events)); app.use('/api/sessions', createSessionsRoutes(agentService)); -app.use('/api/features', createFeaturesRoutes(featureLoader)); +app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService)); app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); -app.use('/api/enhance-prompt', createEnhancePromptRoutes()); +app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); app.use('/api/worktree', createWorktreeRoutes()); app.use('/api/git', createGitRoutes()); app.use('/api/setup', createSetupRoutes()); diff --git a/apps/server/src/lib/app-spec-format.ts b/apps/server/src/lib/app-spec-format.ts index 2894bbc4c..8209963b4 100644 --- a/apps/server/src/lib/app-spec-format.ts +++ b/apps/server/src/lib/app-spec-format.ts @@ -11,9 +11,11 @@ export { specOutputSchema } from '@automaker/types'; /** * Escape special XML characters + * Handles undefined values by converting them to empty strings */ -function escapeXml(str: string): string { - return str +function escapeXml(str: string | undefined): string { + if (str === undefined || str === null) return ''; + return String(str) .replace(/&/g, '&') .replace(//g, '>') @@ -27,6 +29,11 @@ function escapeXml(str: string): string { export function specToXml(spec: import('@automaker/types').SpecOutput): string { const indent = ' '; + // Helper to safely map arrays, defaulting to empty array + const safeMap = (arr: T[] | undefined, fn: (item: T) => R): R[] => { + return (arr || []).map(fn); + }; + let xml = ` ${indent}${escapeXml(spec.project_name)} @@ -36,28 +43,27 @@ ${indent}${indent}${escapeXml(spec.overview)} ${indent} ${indent} -${spec.technology_stack.map((t) => `${indent}${indent}${escapeXml(t)}`).join('\n')} +${safeMap(spec.technology_stack, (t) => `${indent}${indent}${escapeXml(t)}`).join('\n')} ${indent} ${indent} -${spec.core_capabilities.map((c) => `${indent}${indent}${escapeXml(c)}`).join('\n')} +${safeMap(spec.core_capabilities, (c) => `${indent}${indent}${escapeXml(c)}`).join('\n')} ${indent} ${indent} -${spec.implemented_features - .map( - (f) => `${indent}${indent} +${safeMap( + spec.implemented_features, + (f) => `${indent}${indent} ${indent}${indent}${indent}${escapeXml(f.name)} ${indent}${indent}${indent}${escapeXml(f.description)}${ - f.file_locations && f.file_locations.length > 0 - ? `\n${indent}${indent}${indent} -${f.file_locations.map((loc) => `${indent}${indent}${indent}${indent}${escapeXml(loc)}`).join('\n')} + f.file_locations && f.file_locations.length > 0 + ? `\n${indent}${indent}${indent} +${safeMap(f.file_locations, (loc) => `${indent}${indent}${indent}${indent}${escapeXml(loc)}`).join('\n')} ${indent}${indent}${indent}` - : '' - } + : '' + } ${indent}${indent}` - ) - .join('\n')} +).join('\n')} ${indent}`; // Optional sections @@ -65,7 +71,7 @@ ${indent}`; xml += ` ${indent} -${spec.additional_requirements.map((r) => `${indent}${indent}${escapeXml(r)}`).join('\n')} +${safeMap(spec.additional_requirements, (r) => `${indent}${indent}${escapeXml(r)}`).join('\n')} ${indent}`; } @@ -73,7 +79,7 @@ ${indent}`; xml += ` ${indent} -${spec.development_guidelines.map((g) => `${indent}${indent}${escapeXml(g)}`).join('\n')} +${safeMap(spec.development_guidelines, (g) => `${indent}${indent}${escapeXml(g)}`).join('\n')} ${indent}`; } @@ -81,15 +87,14 @@ ${indent}`; xml += ` ${indent} -${spec.implementation_roadmap - .map( - (r) => `${indent}${indent} +${safeMap( + spec.implementation_roadmap, + (r) => `${indent}${indent} ${indent}${indent}${indent}${escapeXml(r.phase)} ${indent}${indent}${indent}${escapeXml(r.status)} ${indent}${indent}${indent}${escapeXml(r.description)} ${indent}${indent}` - ) - .join('\n')} +).join('\n')} ${indent}`; } diff --git a/apps/server/src/lib/provider-query.ts b/apps/server/src/lib/provider-query.ts new file mode 100644 index 000000000..122320e3b --- /dev/null +++ b/apps/server/src/lib/provider-query.ts @@ -0,0 +1,535 @@ +/** + * Provider-Agnostic Query Utility + * + * Provides a unified interface for querying AI models through the provider system. + * This replaces direct Claude SDK calls with provider-agnostic implementations. + * + * Key features: + * - Works with any provider (Claude, Z.ai, etc.) + * - Handles structured output for Claude via SDK + * - Handles structured output for other providers via prompt engineering + parsing + * - Compatible with existing message streaming format + */ + +import type { EventEmitter } from './events.js'; +import { ProviderFactory } from '../providers/provider-factory.js'; +import { + resolveModelString, + resolveModelWithProviderAvailability, + type EnabledProviders, +} from '@automaker/model-resolver'; +import { getModelForUseCase } from './sdk-options.js'; +import type { Options } from '@anthropic-ai/claude-agent-sdk'; +import { createLogger } from '@automaker/utils'; +import type { ProviderMessage } from '../providers/types.js'; + +const logger = createLogger('ProviderQuery'); + +export interface ProviderQueryOptions { + /** Working directory */ + cwd: string; + /** Prompt to send (can be string for text or array for multimodal input with images) */ + prompt: string | Array<{ type: string; text?: string; source?: object }>; + /** Model to use (resolved by getModelForUseCase if not provided) */ + model?: string; + /** Use case for model selection ('spec', 'features', 'suggestions', etc.) */ + useCase?: 'spec' | 'features' | 'suggestions' | 'chat' | 'auto' | 'default'; + /** Max turns for the query */ + maxTurns?: number; + /** Allowed tools */ + allowedTools?: string[]; + /** Abort controller */ + abortController?: AbortController; + /** Enable CLAUDE.md auto-loading */ + autoLoadClaudeMd?: boolean; + /** Structured output schema (for Claude SDK or prompt-based for others) */ + outputFormat?: { + type: 'json_schema'; + schema: Record; + }; + /** API keys for providers (maps provider name to API key) */ + apiKeys?: { + anthropic?: string; + zai?: string; + google?: string; + openai?: string; + }; + /** Enabled providers (controls provider availability) */ + enabledProviders?: EnabledProviders; + /** System prompt to guide the model's behavior */ + systemPrompt?: string; +} + +export interface StructuredOutputInfo { + schema: Record; + prompt: string; +} + +/** + * Build a prompt that encourages JSON output matching a schema + * Used for providers that don't have native structured output support + */ +export function buildStructuredOutputPrompt( + basePrompt: string, + schema: Record +): string { + const schemaDescription = describeSchema(schema); + return `${basePrompt} + +IMPORTANT: You must respond with valid JSON that matches this schema: +${schemaDescription} + +Your response must be ONLY the JSON object, with no additional text, markdown formatting, or explanation.`; +} + +/** + * Generate a human-readable description of a JSON schema + */ +function describeSchema(schema: Record, indent = 0): string { + const prefix = ' '.repeat(indent); + let description = ''; + + if (schema.type === 'object' && schema.properties) { + const props = schema.properties as Record>; + const required = (schema.required || []) as string[]; + + for (const [key, propSchema] of Object.entries(props)) { + const isRequired = required.includes(key); + description += `${prefix}${key}: ${describeProperty(propSchema)}`; + if (isRequired) description += ' (required)'; + description += '\n'; + + if (propSchema.type === 'object' && propSchema.properties) { + description += `${prefix} Properties:\n`; + description += describeSchema(propSchema, indent + 2); + } + } + } else if (schema.type === 'array' && schema.items) { + const items = schema.items as Record; + description += `${prefix}Array of: ${describeProperty(items)}\n`; + if (items.type === 'object' && items.properties) { + description += describeSchema(items, indent + 1); + } + } + + return description; +} + +function describeProperty(prop: Record): string { + const type = prop.type as string; + const description = (prop.description || '') as string; + + let desc = type || 'unknown'; + if (prop.enum) { + desc += ` (one of: ${(prop.enum as string[]).join(', ')})`; + } + if (description) { + desc += ` - ${description}`; + } + return desc; +} + +/** + * Execute a query using the provider system + * + * This is a provider-agnostic alternative to Claude SDK's query() + * that works with any provider through the ProviderFactory. + */ +export async function* executeProviderQuery( + options: ProviderQueryOptions +): AsyncGenerator< + | ProviderMessage + | { type: 'result'; subtype?: string; structured_output?: unknown; result?: string } +> { + const { + cwd, + prompt, + model: explicitModel, + useCase = 'default', + maxTurns = 100, + allowedTools, + abortController, + autoLoadClaudeMd = false, + outputFormat, + apiKeys, + enabledProviders, + systemPrompt: providedSystemPrompt, + } = options; + + // Resolve the model to use + let resolvedModel = getModelForUseCase(useCase, explicitModel); + + // Apply provider availability check if enabledProviders is provided + if (enabledProviders) { + const fallbackModel = resolveModelWithProviderAvailability( + resolvedModel, + enabledProviders, + explicitModel // Use explicit model as fallback if all providers disabled + ); + if (fallbackModel !== resolvedModel) { + logger.info( + `[ProviderQuery] Model substituted due to provider availability: ${resolvedModel} -> ${fallbackModel}` + ); + } + resolvedModel = fallbackModel; + } + + logger.info(`[ProviderQuery] Using model: ${resolvedModel} for use case: ${useCase}`); + + // Determine the provider and API key + let apiKey: string | undefined; + let providerName: 'claude' | 'zai' | undefined; + if (apiKeys) { + // Get provider name directly from model string to avoid instantiating twice + const { getProviderForModel } = await import('@automaker/model-resolver'); + providerName = getProviderForModel(resolvedModel); + logger.info(`[ProviderQuery] Using provider: ${providerName}`); + + // Map provider name to API key + // Provider name normalization: canonical names map to API key names + // 'claude' -> anthropic (legacy), 'zai' -> zai, etc. + const apiKeyMap: Record = { + claude: apiKeys.anthropic, + zai: apiKeys.zai, + google: apiKeys.google, + openai: apiKeys.openai, + }; + apiKey = providerName ? apiKeyMap[providerName] : undefined; + if (apiKey) { + logger.info(`[ProviderQuery] Using API key from settings for ${providerName}`); + } + } else { + const { getProviderForModel } = await import('@automaker/model-resolver'); + providerName = getProviderForModel(resolvedModel); + logger.info(`[ProviderQuery] Using provider: ${providerName}`); + } + + // Get provider with API key config if available (instantiated only once) + const providerWithKey = ProviderFactory.getProviderForModel( + resolvedModel, + apiKey ? { apiKey } : undefined + ); + + // Build the final prompt + let finalPrompt = prompt; + + // For non-Claude providers with structured output, modify the prompt + // Only applies to string prompts, not array prompts (which may contain images) + if (outputFormat && providerName !== 'claude' && typeof prompt === 'string') { + finalPrompt = buildStructuredOutputPrompt(prompt, outputFormat.schema); + logger.info('[ProviderQuery] Added structured output instructions to prompt'); + } + + // Build system prompt: use provided one, or autoLoadClaudeMd for Claude + let systemPrompt: string | undefined = providedSystemPrompt; + if (!systemPrompt && autoLoadClaudeMd && providerName === 'claude') { + systemPrompt = + 'You are an AI programming assistant. Use the available tools to read and analyze code.'; + } + + // Execute via provider + const executeOptions = { + prompt: finalPrompt, + model: resolvedModel, + cwd, + maxTurns, + allowedTools, + abortController, + systemPrompt, + }; + + logger.info('[ProviderQuery] Starting provider execution...'); + + let fullResponse = ''; + let structuredOutput: unknown = null; + + try { + for await (const msg of providerWithKey.executeQuery(executeOptions)) { + // Emit the raw message + yield msg; + + // Collect text content + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + fullResponse += block.text; + } + } + } + + // Check for structured output in result messages (Claude SDK format) + if (msg.type === 'result') { + const resultMsg = msg as { structured_output?: unknown; subtype?: string }; + if (resultMsg.structured_output) { + structuredOutput = resultMsg.structured_output; + logger.info('[ProviderQuery] Received structured output from provider'); + } + } + } + + // For non-Claude providers, try to parse structured output from text + if (outputFormat && !structuredOutput && providerName !== 'claude') { + logger.info('[ProviderQuery] Attempting to parse structured output from text...'); + structuredOutput = parseJsonFromText(fullResponse); + if (structuredOutput) { + logger.info('[ProviderQuery] Successfully parsed structured output from text'); + // Emit the structured output in Claude SDK format + yield { + type: 'result', + subtype: 'success', + structured_output: structuredOutput, + }; + } else { + logger.warn('[ProviderQuery] Failed to parse structured output from text'); + } + } + } catch (error) { + logger.error('[ProviderQuery] Error during execution:', error); + yield { + type: 'error', + error: (error as Error).message, + }; + // Don't re-throw after yielding error - the caller already received the error + return; + } + + logger.info(`[ProviderQuery] Query complete. Response length: ${fullResponse.length} chars`); +} + +/** + * Try to parse JSON from text that may contain conversational content + * Improved implementation that avoids ReDoS and properly handles nested structures + */ +export function parseJsonFromText(text: string, schema?: Record): unknown | null { + // First try to parse the entire text as JSON + try { + const parsed = JSON.parse(text); + if (schema) { + return validateAgainstSchema(parsed, schema) ? parsed : null; + } + return parsed; + } catch { + // Not pure JSON, continue to extraction + } + + // Try to find a complete JSON object by matching braces + const objectResult = extractJsonBraces(text); + if (objectResult) { + try { + const parsed = JSON.parse(objectResult); + if (schema) { + return validateAgainstSchema(parsed, schema) ? parsed : null; + } + return parsed; + } catch { + // Invalid JSON, continue + } + } + + // Try to find a JSON array + const arrayResult = extractJsonBrackets(text); + if (arrayResult) { + try { + const parsed = JSON.parse(arrayResult); + if (schema) { + return validateAgainstSchema(parsed, schema) ? parsed : null; + } + return parsed; + } catch { + // Invalid JSON + } + } + + return null; +} + +/** + * Extract a complete JSON object from text by matching braces + * This avoids the ReDoS vulnerability of regex /\{[\s\S]*\}/ + */ +function extractJsonBraces(text: string): string | null { + let startPos = text.indexOf('{'); + if (startPos === -1) return null; + + let depth = 0; + let inString = false; + let escapeNext = false; + + for (let i = startPos; i < text.length; i++) { + const char = text[i]; + + if (escapeNext) { + escapeNext = false; + continue; + } + + if (char === '\\') { + escapeNext = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (!inString) { + if (char === '{') { + depth++; + } else if (char === '}') { + depth--; + if (depth === 0) { + // Found complete object + return text.substring(startPos, i + 1); + } + } + } + } + + return null; +} + +/** + * Extract a complete JSON array from text by matching brackets + */ +function extractJsonBrackets(text: string): string | null { + let startPos = text.indexOf('['); + if (startPos === -1) return null; + + let depth = 0; + let inString = false; + let escapeNext = false; + + for (let i = startPos; i < text.length; i++) { + const char = text[i]; + + if (escapeNext) { + escapeNext = false; + continue; + } + + if (char === '\\') { + escapeNext = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (!inString) { + if (char === '[') { + depth++; + } else if (char === ']') { + depth--; + if (depth === 0) { + // Found complete array + return text.substring(startPos, i + 1); + } + } + } + } + + return null; +} + +/** + * Basic schema validation for parsed JSON + * This is a lightweight validation - for full validation, consider using jsonschema package + */ +function validateAgainstSchema(data: unknown, schema: Record): boolean { + if (!schema) return true; + + const schemaType = schema.type as string; + + // Type validation + if (schemaType === 'object') { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return false; + } + + // Check required properties + const required = schema.required as string[] | undefined; + if (required && Array.isArray(required)) { + const dataObj = data as Record; + for (const prop of required) { + if (!(prop in dataObj)) { + return false; + } + } + } + + // Check properties + const properties = schema.properties as Record> | undefined; + if (properties) { + const dataObj = data as Record; + for (const [key, propSchema] of Object.entries(properties)) { + if (key in dataObj) { + if (!validateAgainstSchema(dataObj[key], propSchema)) { + return false; + } + } + } + } + } + + if (schemaType === 'array') { + if (!Array.isArray(data)) { + return false; + } + + const itemsSchema = schema.items as Record | undefined; + if (itemsSchema) { + for (const item of data) { + if (!validateAgainstSchema(item, itemsSchema)) { + return false; + } + } + } + } + + if (schemaType === 'string') { + if (typeof data !== 'string') { + return false; + } + } + + if (schemaType === 'number') { + if (typeof data !== 'number') { + return false; + } + } + + if (schemaType === 'boolean') { + if (typeof data !== 'boolean') { + return false; + } + } + + // Enum validation + if (schema.enum && Array.isArray(schema.enum)) { + if (!schema.enum.includes(data)) { + return false; + } + } + + return true; +} + +/** + * Create provider-agnostic options for spec generation + * Replaces createSpecGenerationOptions for provider-agnostic use + */ +export function createProviderQueryOptions( + config: Omit +): Pick { + const resolvedModel = getModelForUseCase(config.useCase || 'default', config.model); + + return { + model: resolvedModel, + maxTurns: config.maxTurns || 100, + allowedTools: config.allowedTools, + cwd: config.cwd, + }; +} diff --git a/apps/server/src/providers/base-provider.ts b/apps/server/src/providers/base-provider.ts index 2b1880d3c..84edc89ce 100644 --- a/apps/server/src/providers/base-provider.ts +++ b/apps/server/src/providers/base-provider.ts @@ -11,6 +11,22 @@ import type { ModelDefinition, } from './types.js'; +/** + * Supported provider features + * Used to check capability compatibility between providers + * + * Feature descriptions: + * - thinking: Unified thinking/reasoning capability (Claude extended thinking, Zai GLM thinking mode) + */ +export type ProviderFeature = + | 'tools' + | 'text' + | 'vision' + | 'mcp' + | 'browser' + | 'thinking' // Unified thinking capability for both Claude and Zai + | 'structuredOutput'; + /** * Base provider class that all provider implementations must extend */ @@ -68,14 +84,26 @@ export abstract class BaseProvider { } /** - * Check if the provider supports a specific feature - * @param feature Feature name (e.g., "vision", "tools", "mcp") - * @returns Whether the feature is supported + * Check if provider supports a specific feature + * @param feature Feature name (e.g., "vision", "tools", "mcp", "structuredOutput") + * @returns Whether of feature is supported */ - supportsFeature(feature: string): boolean { + supportsFeature(feature: ProviderFeature | string): boolean { + // Normalize legacy feature names for backward compatibility + const normalizedFeature = BaseProvider.normalizeFeatureName(feature); + // Default implementation - override in subclasses - const commonFeatures = ['tools', 'text']; - return commonFeatures.includes(feature); + const commonFeatures: ProviderFeature[] = ['tools', 'text']; + return commonFeatures.includes(normalizedFeature); + } + + /** + * Normalize legacy feature names to current canonical names + * Provides backward compatibility for code using 'extendedThinking' + */ + public static normalizeFeatureName(feature: ProviderFeature | string): ProviderFeature { + if (feature === 'extendedThinking') return 'thinking'; + return feature as ProviderFeature; } /** @@ -91,4 +119,13 @@ export abstract class BaseProvider { setConfig(config: Partial): void { this.config = { ...this.config, ...config }; } + + /** + * Normalize legacy provider names to current canonical names + * Provides backward compatibility for code using 'anthropic' provider name + */ + public static normalizeProviderName(provider: string): string { + if (provider === 'anthropic') return 'claude'; + return provider; + } } diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 9237cdf69..df7d59e59 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -6,7 +6,7 @@ */ import { query, type Options } from '@anthropic-ai/claude-agent-sdk'; -import { BaseProvider } from './base-provider.js'; +import { BaseProvider, type ProviderFeature } from './base-provider.js'; import type { ExecuteOptions, ProviderMessage, @@ -121,7 +121,7 @@ export class ClaudeProvider extends BaseProvider { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5', modelString: 'claude-opus-4-5-20251101', - provider: 'anthropic', + provider: 'claude', description: 'Most capable Claude model', contextWindow: 200000, maxOutputTokens: 16000, @@ -134,7 +134,7 @@ export class ClaudeProvider extends BaseProvider { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', modelString: 'claude-sonnet-4-20250514', - provider: 'anthropic', + provider: 'claude', description: 'Balanced performance and cost', contextWindow: 200000, maxOutputTokens: 16000, @@ -146,7 +146,7 @@ export class ClaudeProvider extends BaseProvider { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', modelString: 'claude-3-5-sonnet-20241022', - provider: 'anthropic', + provider: 'claude', description: 'Fast and capable', contextWindow: 200000, maxOutputTokens: 8000, @@ -158,7 +158,7 @@ export class ClaudeProvider extends BaseProvider { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', modelString: 'claude-haiku-4-5-20251001', - provider: 'anthropic', + provider: 'claude', description: 'Fastest Claude model', contextWindow: 200000, maxOutputTokens: 8000, @@ -173,8 +173,17 @@ export class ClaudeProvider extends BaseProvider { /** * Check if the provider supports a specific feature */ - supportsFeature(feature: string): boolean { - const supportedFeatures = ['tools', 'text', 'vision', 'thinking']; - return supportedFeatures.includes(feature); + supportsFeature(feature: ProviderFeature | string): boolean { + // Claude supports: tools, text, vision, extended thinking, structured output (native), mcp, browser + const supportedFeatures: ProviderFeature[] = [ + 'tools', + 'text', + 'vision', + 'thinking', + 'structuredOutput', + 'mcp', + 'browser', + ]; + return supportedFeatures.includes(feature as ProviderFeature); } } diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index 0ef9b36ea..26317b1f8 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -1,51 +1,90 @@ /** * Provider Factory - Routes model IDs to the appropriate provider * - * This factory implements model-based routing to automatically select - * the correct provider based on the model string. This makes adding - * new providers (Cursor, OpenCode, etc.) trivial - just add one line. + * This factory implements model-based routing using a registry pattern. + * New providers can be registered dynamically, making the system extensible. + * + * To add a new provider: + * 1. Import the provider class + * 2. Call ProviderFactory.registerProvider() with the registration config */ import { BaseProvider } from './base-provider.js'; import { ClaudeProvider } from './claude-provider.js'; -import type { InstallationStatus } from './types.js'; +import { ZaiProvider } from './zai-provider.js'; +import type { InstallationStatus, ProviderConfig } from './types.js'; + +/** + * Provider registration configuration + */ +export interface ProviderRegistration { + /** Provider class to instantiate */ + providerClass: new (config?: ProviderConfig) => BaseProvider; + /** Model prefixes that route to this provider (e.g., ['claude-', 'glm-']) */ + modelPrefixes: string[]; + /** Model aliases that route to this provider (e.g., ['opus', 'sonnet']) */ + aliases: string[]; + /** Provider name aliases (e.g., 'anthropic' for Claude) */ + providerAliases?: string[]; +} export class ProviderFactory { + /** + * Registry of all registered providers + */ + private static registry = new Map(); + + /** + * Register a provider with the factory + * + * @param name Unique provider name (e.g., 'claude', 'zai') + * @param registration Provider registration configuration + */ + static registerProvider(name: string, registration: ProviderRegistration): void { + this.registry.set(name.toLowerCase(), registration); + } + + /** + * Unregister a provider + * + * @param name Provider name to unregister + */ + static unregisterProvider(name: string): void { + this.registry.delete(name.toLowerCase()); + } + /** * Get the appropriate provider for a given model ID * - * @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "gpt-5.2", "cursor-fast") + * @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "glm-4.7") + * @param config Optional provider configuration (including API keys) * @returns Provider instance for the model */ - static getProviderForModel(modelId: string): BaseProvider { + static getProviderForModel(modelId: string, config?: ProviderConfig): BaseProvider { const lowerModel = modelId.toLowerCase(); - // Claude models (claude-*, opus, sonnet, haiku) - if (lowerModel.startsWith('claude-') || ['haiku', 'sonnet', 'opus'].includes(lowerModel)) { - return new ClaudeProvider(); + // Check model prefixes first + for (const [name, registration] of this.registry) { + if ( + registration.modelPrefixes.some((prefix) => lowerModel.startsWith(prefix)) || + registration.aliases.includes(lowerModel) + ) { + return new registration.providerClass(config); + } } - // Future providers: - // if (lowerModel.startsWith("cursor-")) { - // return new CursorProvider(); - // } - // if (lowerModel.startsWith("opencode-")) { - // return new OpenCodeProvider(); - // } - // Default to Claude for unknown models - console.warn(`[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude`); - return new ClaudeProvider(); + console.warn( + `[ProviderFactory] Unknown model "${modelId}", defaulting to Claude (registered providers: ${Array.from(this.registry.keys()).join(', ')})` + ); + return new ClaudeProvider(config); } /** * Get all available providers */ static getAllProviders(): BaseProvider[] { - return [ - new ClaudeProvider(), - // Future providers... - ]; + return Array.from(this.registry.values()).map(({ providerClass }) => new providerClass()); } /** @@ -69,26 +108,20 @@ export class ProviderFactory { /** * Get provider by name (for direct access if needed) * - * @param name Provider name (e.g., "claude", "cursor") + * @param name Provider name (e.g., "claude", "zai") * @returns Provider instance or null if not found */ - static getProviderByName(name: string): BaseProvider | null { + static getProviderByName(name: string, config?: ProviderConfig): BaseProvider | null { const lowerName = name.toLowerCase(); - switch (lowerName) { - case 'claude': - case 'anthropic': - return new ClaudeProvider(); - - // Future providers: - // case "cursor": - // return new CursorProvider(); - // case "opencode": - // return new OpenCodeProvider(); - - default: - return null; + // Check registered providers + for (const [providerName, registration] of this.registry) { + if (providerName === lowerName || registration.providerAliases?.includes(lowerName)) { + return new registration.providerClass(config); + } } + + return null; } /** @@ -106,3 +139,18 @@ export class ProviderFactory { return allModels; } } + +// Auto-register built-in providers on module load +ProviderFactory.registerProvider('claude', { + providerClass: ClaudeProvider, + modelPrefixes: ['claude-'], + aliases: ['haiku', 'sonnet', 'opus'], + providerAliases: ['anthropic'], +}); + +ProviderFactory.registerProvider('zai', { + providerClass: ZaiProvider, + modelPrefixes: ['glm-'], + aliases: ['glm'], + providerAliases: ['zhipuai', 'zhipu'], +}); diff --git a/apps/server/src/providers/types.ts b/apps/server/src/providers/types.ts index 5a5943614..f6b5f2f3d 100644 --- a/apps/server/src/providers/types.ts +++ b/apps/server/src/providers/types.ts @@ -1,105 +1,31 @@ /** - * Shared types for AI model providers - */ - -/** - * Configuration for a provider instance - */ -export interface ProviderConfig { - apiKey?: string; - cliPath?: string; - env?: Record; -} - -/** - * Message in conversation history - */ -export interface ConversationMessage { - role: 'user' | 'assistant'; - content: string | Array<{ type: string; text?: string; source?: object }>; -} - -/** - * Options for executing a query via a provider - */ -export interface ExecuteOptions { - prompt: string | Array<{ type: string; text?: string; source?: object }>; - model: string; - cwd: string; - systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string }; - maxTurns?: number; - allowedTools?: string[]; - mcpServers?: Record; - abortController?: AbortController; - conversationHistory?: ConversationMessage[]; // Previous messages for context - sdkSessionId?: string; // Claude SDK session ID for resuming conversations - settingSources?: Array<'user' | 'project' | 'local'>; // Claude filesystem settings to load -} - -/** - * Content block in a provider message (matches Claude SDK format) - */ -export interface ContentBlock { - type: 'text' | 'tool_use' | 'thinking' | 'tool_result'; - text?: string; - thinking?: string; - name?: string; - input?: unknown; - tool_use_id?: string; - content?: string; -} - -/** - * Message returned by a provider (matches Claude SDK streaming format) - */ -export interface ProviderMessage { - type: 'assistant' | 'user' | 'error' | 'result'; - subtype?: 'success' | 'error'; - session_id?: string; - message?: { - role: 'user' | 'assistant'; - content: ContentBlock[]; - }; - result?: string; - error?: string; - parent_tool_use_id?: string | null; -} - -/** - * Installation status for a provider - */ -export interface InstallationStatus { - installed: boolean; - path?: string; - version?: string; - method?: 'cli' | 'npm' | 'brew' | 'sdk'; - hasApiKey?: boolean; - authenticated?: boolean; - error?: string; -} - -/** - * Validation result - */ -export interface ValidationResult { - valid: boolean; - errors: string[]; - warnings?: string[]; -} - -/** - * Model definition - */ -export interface ModelDefinition { - id: string; - name: string; - modelString: string; - provider: string; - description: string; - contextWindow?: number; - maxOutputTokens?: number; - supportsVision?: boolean; - supportsTools?: boolean; - tier?: 'basic' | 'standard' | 'premium'; - default?: boolean; -} + * Provider types for server-side code + * + * This file re-exports types from @automaker/types for consistency. + * Server-specific types that don't belong in the shared package are defined here. + */ + +// Re-export all provider types from shared package +export type { + ProviderConfig, + ConversationMessage, + SystemPromptPreset, + ThinkingConfig, + ExecuteOptions, + TextBlock, + ToolUseBlock, + ThinkingBlock, + ToolResultBlock, + ContentBlock, + LegacyContentBlock, + ProviderMessage, + InstallationStatus, + ValidationResult, + ModelDefinition, +} from '@automaker/types'; + +// Import ProviderFeature from base-provider (server-specific) +import type { ProviderFeature } from './base-provider.js'; + +// Re-export for convenience +export type { ProviderFeature } from './base-provider.js'; diff --git a/apps/server/src/providers/zai-provider.ts b/apps/server/src/providers/zai-provider.ts new file mode 100644 index 000000000..bbc1427bb --- /dev/null +++ b/apps/server/src/providers/zai-provider.ts @@ -0,0 +1,1917 @@ +/** + * Z.ai Provider - Executes queries using Z.ai GLM models + * + * Integrates with Z.ai's OpenAI-compatible API to support GLM models + * with tool calling capabilities. GLM-4.6v is the only model that supports vision. + */ + +import { secureFs, validatePath } from '@automaker/platform'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { BaseProvider, type ProviderFeature } from './base-provider.js'; +import type { + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, + ContentBlock, + ProviderConfig, +} from './types.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('ZaiProvider'); + +const execAsync = promisify(exec); + +/** + * Z.ai SSE (Server-Sent Events) streaming response types + * Matches the format of Z.ai's OpenAI-compatible API + */ +interface ZaiSseToolCallDelta { + index: number; + id?: string; + function?: { name?: string; arguments?: string }; +} + +interface ZaiSseDelta { + content?: string; + reasoning_content?: string; + tool_calls?: ZaiSseToolCallDelta[]; +} + +interface ZaiSseChoice { + delta?: ZaiSseDelta; + finish_reason?: string; +} + +interface ZaiSseResponse { + choices?: ZaiSseChoice[]; +} + +/** + * Blocklist of dangerous commands for execute_command tool + * Only commands that can cause system-wide damage are blocked + * All other commands are allowed, with real security enforced by ALLOWED_ROOT_DIRECTORY + */ +const BLOCKED_COMMANDS = new Set([ + // Disk destruction commands + 'format', + 'fdisk', + 'diskpart', + 'diskutil', + // System control commands + 'shutdown', + 'reboot', + 'poweroff', + 'halt', + 'systemctl', + 'init', + 'telinit', + // User management commands + 'userdel', + 'groupdel', + 'passwd', + 'chpasswd', +]); + +/** + * Dangerous patterns that could cause system-wide damage + * These are blocked regardless of the command used + */ +const DANGEROUS_PATTERNS = [ + // Recursive delete from root + /rm\s+-[rf]+\s+\/$/, // rm -rf / (with / at end) + /rm\s+-[rf]+\s+\\$/, // rm -rf \ (Windows root) + /del\s+\/$/, // del / (Windows) + /del\s+\\$/, // del \ (Windows) + // Recursive delete with wildcards pointing to root + /rm\s+-[rf]+\s+\/\*/, // rm -rf /* (recursive from root) + /rm\s+-[rf]+\s+\\\*/, // rm -rf \* (Windows) + // format/fdisk/shutdown with any arguments + /^\s*format\s/, // format command (must be at start, not --format flag) + /^\s*fdisk\s/, // fdisk command + /^\s*diskpart\s/, // diskpart command + /^\s*diskutil\s/, // diskutil command + /^\s*shutdown\s+[a-z-]/i, // shutdown with flags + /^\s*reboot\s/, // reboot command + /^\s*poweroff\s/, // poweroff command + /^\s*halt\s/, // halt command + /^\s*systemctl\s+(poweroff|reboot|halt)/i, // systemctl poweroff/reboot/halt +]; + +/** + * File extensions for grep search - expand to cover more file types + */ +const GREP_EXTENSIONS = new Set([ + 'ts', + 'tsx', + 'js', + 'jsx', + 'mjs', + 'cjs', + 'json', + 'jsonc', + 'md', + 'mdx', + 'txt', + 'css', + 'scss', + 'sass', + 'less', + 'html', + 'htm', + 'xml', + 'svg', + 'yaml', + 'yml', + 'toml', + 'ini', + 'conf', + 'py', + 'rb', + 'php', + 'java', + 'go', + 'rs', + 'c', + 'cpp', + 'cc', + 'cxx', + 'h', + 'hpp', + 'sh', + 'bash', + 'zsh', + 'fish', + 'dockerfile', + 'dockerignore', + 'gitignore', + 'gitattributes', + 'env', + 'env.example', +]); + +/** + * Commands that take file paths as arguments + * Used for additional path validation + */ +const PATH_COMMANDS = new Set([ + 'rm', + 'del', + 'erase', + 'mv', + 'move', + 'cp', + 'copy', + 'xcopy', + 'mkdir', + 'md', + 'touch', + 'ln', + 'mklink', + 'cat', + 'type', + 'head', + 'tail', + 'ls', + 'dir', + 'find', + 'findstr', + 'grep', + 'sed', + 'git', + 'npm', + 'npx', + 'pnpm', + 'yarn', + 'bun', + 'node', + 'python', + 'python3', + 'tsc', + 'vitest', + 'jest', + 'pytest', + 'mocha', +]); + +/** + * Flags that indicate next arg is a path (e.g., -f, -o, --out) + */ +const PATH_FLAGS = new Set([ + '-f', + '-o', + '-i', + '--file', + '--output', + '--out', + '-C', + '-c', + '--config', +]); + +/** + * Extract path from --flag=value or -Cvalue format + * Returns the path part if found, null otherwise + */ +function extractPathFromFlag(arg: string): string | null { + // Handle --flag=path or --flag:path format + if (arg.startsWith('--')) { + const eqMatch = arg.match(/^--[^=]+=(.+)/); + if (eqMatch) return eqMatch[1]; + const colonMatch = arg.match(/^--[^:]+:(.+)/); + if (colonMatch) return colonMatch[1]; + } + // Handle -Cpath or -fpath format (single dash with value attached) + if (arg.startsWith('-') && !arg.startsWith('--') && arg.length > 2) { + // Some flags like -C, -f, -o can have attached values + const flag = arg.slice(0, 2); + if (PATH_FLAGS.has(flag)) { + return arg.slice(2); + } + } + return null; +} + +/** + * Check if an argument looks like a file path + */ +function looksLikePath(arg: string): boolean { + return ( + arg.startsWith('./') || + arg.includes('/') || + arg.includes('\\') || + /^\w+\.\w+$/.test(arg) || + arg === '-' || + arg === '.' + ); +} + +/** + * Sanitized command segment structure + */ +type SanitizedSegment = { + command: string; + args: string[]; + redirects: string[]; +}; + +type SanitizedPipe = SanitizedSegment & { separator: '|' | '&&' | '&' }; + +/** + * Parse, validate, and sanitize a single command segment (no chaining) + */ +function sanitizeCommandSegment(command: string, cwd: string): SanitizedSegment { + const trimmed = command.trim(); + if (!trimmed) { + throw new Error('Command cannot be empty'); + } + + // Check for dangerous patterns first (before parsing) + for (const pattern of DANGEROUS_PATTERNS) { + if (pattern.test(trimmed)) { + throw new Error(`Dangerous command pattern blocked: ${trimmed}`); + } + } + + // Extract shell redirects before parsing arguments + // Supported redirects: >, >>, <, 2>, 2>>, 2>&1, etc. + const redirects: string[] = []; + let commandWithoutRedirects = trimmed; + + // Pattern to match redirect operators with their targets + // Matches: >file, >>file, file, 2>>file, 2>&1, etc. + const redirectPattern = /(\d*>&?\d*|>>?|<)\s*(\S+|&\d+)/g; + let match; + while ((match = redirectPattern.exec(trimmed)) !== null) { + redirects.push(match[0]); + } + + // Remove redirects from command for validation (we'll add them back later) + commandWithoutRedirects = trimmed.replace(redirectPattern, '').trim(); + + // Split by whitespace but respect quotes + const parts: string[] = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + + for (let i = 0; i < commandWithoutRedirects.length; i++) { + const char = commandWithoutRedirects[i]; + + if (inQuote) { + if (char === quoteChar) { + inQuote = false; + quoteChar = ''; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = true; + quoteChar = char; + } else if (char === ' ' || char === '\t') { + if (current) { + parts.push(current); + current = ''; + } + } else { + current += char; + } + } + + if (inQuote) { + throw new Error('Unbalanced quotes in command'); + } + + if (current) { + parts.push(current); + } + + if (parts.length === 0) { + throw new Error('Command cannot be empty'); + } + + const baseCommand = parts[0]; + + // Windows is case-insensitive, also normalize .exe, .bat, .cmd extensions + const normalizedCommand = baseCommand.toLowerCase().replace(/\.(exe|bat|cmd)$/, ''); + + // Check if command is in blocklist (case-insensitive for Windows) + const commandBlocked = Array.from(BLOCKED_COMMANDS).some( + (blocked) => blocked.toLowerCase() === normalizedCommand + ); + + if (commandBlocked) { + throw new Error(`Command blocked for security: ${baseCommand}`); + } + + // Note: Path traversal security is enforced below by validatePath() on all resolved paths + // We don't check for '..' here because: + // 1. Relative paths like '../file' are valid when they resolve within the allowed directory + // 2. Flag values like '--config=../file.ts' should be allowed + // 3. validatePath() will catch actual escapes outside ALLOWED_ROOT_DIRECTORY + + // Track symlink target for ln command (ln source target - target is 2nd path arg) + let symlinkTargetIndex = -1; + if (baseCommand === 'ln') { + // ln creates source -> target, where target is the second path-like arg + let pathCount = 0; + for (let i = 1; i < parts.length; i++) { + const arg = parts[i]; + const embeddedPath = extractPathFromFlag(arg); + const hasPath = looksLikePath(arg) || embeddedPath !== null; + if (hasPath || PATH_FLAGS.has(arg)) { + pathCount++; + if (pathCount === 2) { + symlinkTargetIndex = i; + break; + } + } + } + } + + // Validate path-like arguments using platform guard + let nextArgIsPath = false; + for (let i = 1; i < parts.length; i++) { + const arg = parts[i]; + const embeddedPath = extractPathFromFlag(arg); + + // Check if this arg should be treated as a path + if (nextArgIsPath || (PATH_COMMANDS.has(baseCommand) && looksLikePath(arg))) { + // Use platform guard - blocks absolute paths and outside-root + const resolved = path.resolve(cwd, arg); + validatePath(resolved); // Throws if outside ALLOWED_ROOT_DIRECTORY + nextArgIsPath = false; + } else if (embeddedPath !== null) { + // Handle --flag=path or -Cpath format + const resolved = path.resolve(cwd, embeddedPath); + validatePath(resolved); // Throws if outside ALLOWED_ROOT_DIRECTORY + } else if (PATH_FLAGS.has(arg)) { + nextArgIsPath = true; + } + } + + // Special check for ln: verify symlink target won't point outside root + if (baseCommand === 'ln' && symlinkTargetIndex > 0) { + const targetArg = parts[symlinkTargetIndex]; + const embeddedPath = extractPathFromFlag(targetArg); + const pathToCheck = embeddedPath || targetArg; + + // For symlinks, verify the target exists and resolve to check if it escapes + const resolvedTarget = path.resolve(cwd, pathToCheck); + // Check if the target itself is within allowed root + try { + validatePath(resolvedTarget); + } catch { + throw new Error(`Symlink target outside allowed root: ${pathToCheck}`); + } + } + + return { command: baseCommand, args: parts.slice(1), redirects }; +} + +/** + * Validate and sanitize a shell command + * @param command - Command string to sanitize + * @param cwd - Current working directory for path validation + * @returns Object with command and sanitized arguments + * @throws Error if command is blocked or contains dangerous patterns + */ +function sanitizeCommand( + command: string, + cwd: string +): { command: string; args: string[]; redirects: string[]; pipes: SanitizedPipe[] } { + const trimmed = command.trim(); + if (!trimmed) { + throw new Error('Command cannot be empty'); + } + + // Split into command segments on pipes/ampersands + const tokens = trimmed.split(/(\||&&?)/); + const segments: string[] = []; + const separators: Array<'|' | '&&' | '&'> = []; + + for (const token of tokens) { + if (!token) continue; + const t = token.trim(); + if (!t) continue; + if (t === '|' || t === '&&' || t === '&') { + separators.push(t as '|' | '&&' | '&'); + } else { + segments.push(t); + } + } + + if (segments.length === 0) { + throw new Error('Command cannot be empty'); + } + if (separators.length > 0 && separators.length !== segments.length - 1) { + throw new Error('Invalid command chaining syntax'); + } + + const head = sanitizeCommandSegment(segments[0], cwd); + const pipes: SanitizedPipe[] = []; + + for (let i = 1; i < segments.length; i++) { + const seg = sanitizeCommandSegment(segments[i], cwd); + pipes.push({ ...seg, separator: separators[i - 1] }); + } + + return { ...head, pipes }; +} + +/** + * Z.ai API configuration + * API base URL can be overridden via ZAI_API_BASE_URL or ZAI_API_URL environment variables + */ +const ZAI_API_BASE = + process.env.ZAI_API_BASE_URL || process.env.ZAI_API_URL || 'https://api.z.ai/api/coding/paas/v4'; +const ZAI_MODEL = 'glm-4.7'; + +/** + * Tool definitions for Z.ai function calling + * Maps AutoMaker tools to Z.ai function format + */ +const ZAI_TOOLS = [ + { + type: 'function' as const, + function: { + name: 'read_file', + description: 'Read the contents of a file', + parameters: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the file to read', + }, + }, + required: ['filePath'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'write_file', + description: 'Write content to a file, creating directories if needed', + parameters: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the file to write', + }, + content: { + type: 'string', + description: 'Content to write to the file', + }, + }, + required: ['filePath', 'content'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'edit_file', + description: 'Edit a file by replacing old_string with new_string', + parameters: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the file to edit', + }, + oldString: { + type: 'string', + description: 'String to replace', + }, + newString: { + type: 'string', + description: 'Replacement string', + }, + }, + required: ['filePath', 'oldString', 'newString'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'glob_search', + description: 'Search for files matching a glob pattern', + parameters: { + type: 'object', + properties: { + pattern: { + type: 'string', + description: 'Glob pattern (e.g., "**/*.ts")', + }, + cwd: { + type: 'string', + description: 'Current working directory (defaults to project root if not provided)', + }, + }, + required: ['pattern'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'grep_search', + description: 'Search for content in files using regex pattern', + parameters: { + type: 'object', + properties: { + pattern: { + type: 'string', + description: 'Regex pattern to search for', + }, + searchPath: { + type: 'string', + description: 'Path to search in (defaults to project root if not provided)', + }, + }, + required: ['pattern'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'execute_command', + description: 'Execute a shell command (use with caution)', + parameters: { + type: 'object', + properties: { + command: { + type: 'string', + description: 'Command to execute', + }, + cwd: { + type: 'string', + description: + 'Working directory for command execution (defaults to project root if not provided)', + }, + }, + required: ['command'], + }, + }, + }, +]; + +/** + * Validate glob pattern for security + */ +function validateGlobPattern(pattern: string): void { + // Check for path traversal + if (pattern.includes('..')) { + throw new Error('Path traversal not allowed in glob pattern'); + } + + // Check for command injection characters + // Note: Allow / and \ as path separators for glob patterns + // Allow : for Windows drive letters in patterns like C:/folder/** + // Allow * ? {} [] as they are glob wildcards + const dangerousChars = /[;&|`$()<>"]/; + if (dangerousChars.test(pattern)) { + throw new Error('Invalid characters in glob pattern'); + } + + // Check for absolute paths (but allow patterns like **/*.ts) + if (path.isAbsolute(pattern) && !pattern.includes('*')) { + throw new Error('Absolute paths not allowed in glob pattern'); + } + + // Prevent ReDoS: Limit pattern length and consecutive wildcards + if (pattern.length > 500) { + throw new Error('Glob pattern too long (max 500 characters)'); + } + + // Count consecutive asterisks to prevent catastrophic patterns + const maxConsecutiveAsterisks = 10; + let consecutiveAsterisks = 0; + for (const char of pattern) { + if (char === '*') { + consecutiveAsterisks++; + if (consecutiveAsterisks > maxConsecutiveAsterisks) { + throw new Error( + `Too many consecutive wildcards in glob pattern (max ${maxConsecutiveAsterisks})` + ); + } + } else { + consecutiveAsterisks = 0; + } + } +} + +/** + * Native glob implementation using fs.readdir + * No external dependencies or shell commands for better security + */ +async function glob(pattern: string, cwd: string): Promise { + validateGlobPattern(pattern); + + try { + const results: string[] = []; + const regex = globToRegex(pattern, cwd); + const maxDepth = (pattern.match(/\//g) || []).length + 2; // Limit recursion depth + + async function walkDir(dir: string, depth: number): Promise { + if (depth > maxDepth) return; + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relativePath = path.relative(cwd, fullPath); + + // Skip dot files unless explicitly requested + if (!pattern.includes('.') && entry.name.startsWith('.')) { + continue; + } + + // Check if the path matches the pattern + if (regex.test(relativePath)) { + results.push(relativePath.replace(/\\/g, '/')); + } + + // Recurse into directories + if (entry.isDirectory()) { + // Check if pattern might have deeper matches + if (pattern.includes('/') || pattern.includes('*')) { + await walkDir(fullPath, depth + 1); + } + } + } + } catch { + // Skip directories we can't read + } + } + + await walkDir(cwd, 0); + return results; + } catch (error) { + logger.warn(`Glob failed: ${(error as Error).message}`); + return []; + } +} + +/** + * Convert a glob pattern to a RegExp for matching + */ +function globToRegex(globPattern: string, basePath: string): RegExp { + // Escape special regex characters except for glob wildcards + let regexStr = globPattern + .replace(/\./g, '\\.') // Literal dots + .replace(/\?/g, '[^/]') // ? matches any single character except / + .replace(/\*\*/g, '.*') // ** matches any number of path segments + .replace(/\*/g, '[^/]*'); // * matches any characters except / + + return new RegExp(`^${regexStr}$`); +} + +/** + * Native grep implementation using fs and glob + * No shell commands involved for better security + */ +async function grep( + pattern: string, + searchPath: string +): Promise> { + // Validate pattern for security + // Since we use native RegExp (not shell), all regex metacharacters are safe + // Main concerns are ReDoS and extreme pattern lengths + + // Prevent ReDoS: Limit pattern length and check for dangerous patterns + if (pattern.length > 500) { + throw new Error('Grep pattern too long (max 500 characters)'); + } + + // Check for nested quantifiers that can cause exponential backtracking + // Patterns like (a+)+, (a*)*, (a+)+?, etc. + const dangerousPatterns = [ + /\(\.[\*\+]\)[\*\+]/, // (a+)+ or (a*)* + /\(\.[\*\+]\)\{/g, // (a+){n, + /\(\.[\*\+]\)\{[\d,]+,\}/, // (a+){n,} + ]; + + for (const dangerous of dangerousPatterns) { + if (dangerous.test(pattern)) { + throw new Error('Grep pattern contains nested quantifiers that may cause performance issues'); + } + } + + try { + // Find all files with matching extensions in the search path + const extensionPatterns = Array.from(GREP_EXTENSIONS).flatMap((ext) => [ + `**/*.${ext}`, + `**/*.${ext.toUpperCase()}`, + ]); + + // Collect all matching file paths + const filePathsSet = new Set(); + for (const extPattern of extensionPatterns) { + const matches = await glob(extPattern, searchPath); + for (const match of matches) { + filePathsSet.add(match); + } + } + + const filePaths = Array.from(filePathsSet).slice(0, 1000); // Limit to 1000 files + + // Search through each file + const results: Array<{ path: string; line: number; text: string }> = []; + const regex = new RegExp(pattern, 'i'); // Case-insensitive search + + for (const filePath of filePaths) { + try { + const fullPath = path.resolve(searchPath, filePath); + const content = await fs.readFile(fullPath, 'utf-8'); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + results.push({ + path: filePath, + line: i + 1, + text: lines[i].trim().substring(0, 200), // Limit text length + }); + } + } + } catch { + // Skip files that can't be read + continue; + } + } + + return results; + } catch (error) { + logger.warn(`Grep failed: ${(error as Error).message}`); + return []; + } +} + +/** + * Safely resolve a file path relative to baseCwd + * For read operations, allows paths outside baseCwd (for context) + * For write operations, restricts to within baseCwd (for security) + * Enhanced with symlink checking and null byte detection + */ +async function safeResolvePath( + baseCwd: string, + filePath: string, + allowOutside: boolean = false +): Promise { + // Check for null byte injection attack + if (filePath.includes('\0')) { + throw new Error('Null bytes not allowed in paths'); + } + + // Remove leading slash if present (indicates absolute path on Unix) + // On Windows, this prevents paths like /home/workspace from becoming C:\home\workspace + let normalizedPath = filePath.replace(/^\/+/, ''); + + // If path starts with ./ or ../, resolve relative to baseCwd + // If path is absolute (after removing leading slashes), still treat as relative + const absolutePath = path.resolve(baseCwd, normalizedPath); + + // Security check: only enforce if allowOutside is false + if (!allowOutside) { + const relativePath = path.relative(baseCwd, absolutePath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + throw new Error(`Access denied: path "${filePath}" is outside working directory`); + } + + // Check for symlink escape attempts + // Use lstat instead of stat to detect symlinks (stat follows them, lstat doesn't) + try { + const stat = await fs.lstat(absolutePath); + if (stat.isSymbolicLink()) { + const targetPath = path.resolve( + path.dirname(absolutePath), + await fs.readlink(absolutePath) + ); + const targetRelative = path.relative(baseCwd, targetPath); + if (targetRelative.startsWith('..')) { + throw new Error(`Symlink targets outside working directory: ${filePath}`); + } + } + } catch { + // File doesn't exist yet - allow for new file creation + // But check parent directory for symlinks + // Use lstat to properly detect symlinks (stat follows them) + try { + const parentDir = path.dirname(absolutePath); + const parentStat = await fs.lstat(parentDir); + if (parentStat.isSymbolicLink()) { + const targetPath = path.resolve(path.dirname(parentDir), await fs.readlink(parentDir)); + const targetRelative = path.relative(baseCwd, targetPath); + if (targetRelative.startsWith('..')) { + throw new Error(`Parent directory symlink targets outside working directory`); + } + } + } catch { + // Parent doesn't exist either - will be created + } + } + } + + return absolutePath; +} + +/** + * Tool execution handlers + */ +async function executeToolCall( + toolName: string, + toolArgs: Record, + baseCwd: string +): Promise { + switch (toolName) { + case 'read_file': { + const filePath = toolArgs.filePath as string; + // Allow reading files outside worktree for context + const absolutePath = await safeResolvePath(baseCwd, filePath, true); + const content = (await secureFs.readFile(absolutePath, 'utf-8')) as string; + return content; + } + + case 'write_file': { + const filePath = toolArgs.filePath as string; + const content = toolArgs.content as string; + // Writes must be within worktree (security) + const absolutePath = await safeResolvePath(baseCwd, filePath, false); + // Ensure parent directory exists + const dir = path.dirname(absolutePath); + await secureFs.mkdir(dir, { recursive: true }); + await secureFs.writeFile(absolutePath, content, 'utf-8'); + return `Successfully wrote to ${filePath}`; + } + + case 'edit_file': { + const filePath = toolArgs.filePath as string; + const oldString = toolArgs.oldString as string; + const newString = toolArgs.newString as string; + // Edits must be within worktree (security) + const absolutePath = await safeResolvePath(baseCwd, filePath, false); + const content = (await secureFs.readFile(absolutePath, 'utf-8')) as string; + const newContent = content.replace(oldString, newString); + if (newContent === content) { + throw new Error(`Old string not found in ${filePath}`); + } + await secureFs.writeFile(absolutePath, newContent, 'utf-8'); + return `Successfully edited ${filePath}`; + } + + case 'glob_search': { + const pattern = toolArgs.pattern as string; + const searchCwd = (toolArgs.cwd as string) || baseCwd; + // Restrict to worktree for security + const safeSearchCwd = await safeResolvePath(baseCwd, searchCwd, false); + validatePath(safeSearchCwd); // Enforce ALLOWED_ROOT_DIRECTORY + const results = await glob(pattern, safeSearchCwd); + return results.join('\n'); + } + + case 'grep_search': { + const pattern = toolArgs.pattern as string; + const searchPath = (toolArgs.searchPath as string) || baseCwd; + // Restrict to worktree for security + const safeSearchPath = await safeResolvePath(baseCwd, searchPath, false); + validatePath(safeSearchPath); // Enforce ALLOWED_ROOT_DIRECTORY + const results = await grep(pattern, safeSearchPath); + return results.map((r) => `${r.path}:${r.line}:${r.text}`).join('\n'); + } + + case 'execute_command': { + const command = toolArgs.command as string; + const commandCwd = (toolArgs.cwd as string) || baseCwd; + + const buildSegmentString = (segment: SanitizedSegment): string => { + let cmd = segment.command; + if (segment.args.length > 0) { + cmd += ` ${segment.args.join(' ')}`; + } + if (segment.redirects.length > 0) { + cmd += ` ${segment.redirects.join(' ')}`; + } + return cmd; + }; + + // Resolve and validate cwd using platform guard + const resolvedCwd = path.isAbsolute(commandCwd) + ? commandCwd + : path.resolve(baseCwd, commandCwd); + validatePath(resolvedCwd); // Throws if outside ALLOWED_ROOT_DIRECTORY + + // Sanitize command with cwd for path validation + const sanitized = sanitizeCommand(command, resolvedCwd); + + try { + // Build command with sanitized arguments, redirects, and pipes/ampersands + let cmdString = buildSegmentString(sanitized); + for (const pipe of sanitized.pipes) { + cmdString += ` ${pipe.separator} ${buildSegmentString(pipe)}`; + } + + const { stdout, stderr } = await execAsync(cmdString, { + cwd: resolvedCwd, + maxBuffer: 1024 * 1024, // 1MB + env: { PATH: process.env.PATH }, // Minimal environment + timeout: 30000, // 30 second timeout + }); + + return stdout || stderr; + } catch (error) { + const errorMessage = (error as Error).message; + // Check if it's a timeout + if (errorMessage.includes('timedOut')) { + return `Command timed out after 30 seconds`; + } + return `Command failed: ${errorMessage}`; + } + } + + default: + return `Unknown tool: ${toolName}`; + } +} + +export class ZaiProvider extends BaseProvider { + constructor(config?: ProviderConfig) { + super(config); + } + + getName(): string { + return 'zai'; + } + + /** + * Create an abort signal that combines user abort controller with a timeout + * @param userController - Optional user-provided abort controller + * @returns AbortSignal that aborts on user cancel or timeout (5 minutes) + */ + private createAbortSignal(userController?: AbortController): AbortSignal { + const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + const timeoutController = new AbortController(); + const timeoutId = setTimeout(() => timeoutController.abort(), TIMEOUT_MS); + + // Store timeout ID and cleanup function for proper cleanup + (timeoutController as any)._timeoutId = timeoutId; + (timeoutController as any)._cleanup = () => { + clearTimeout(timeoutId); + }; + + // Use AbortSignal.any() if available (Node.js 20+), otherwise use fallback + if ('any' in AbortSignal && typeof AbortSignal.any === 'function') { + try { + if (userController) { + return AbortSignal.any([userController.signal, timeoutController.signal]); + } + return timeoutController.signal; + } catch (e) { + // Fallback if AbortSignal.any() fails unexpectedly + logger.warn('[Zai] AbortSignal.any() failed, using fallback'); + } + } + + // Fallback for Node.js < 20: prioritize user abort, use timeout as backup + return userController?.signal || timeoutController.signal; + } + + /** + * Get API key from credentials + */ + private getApiKey(): string { + // Try config first, then environment variable + if (this.config.apiKey) { + return this.config.apiKey; + } + if (process.env.ZAI_API_KEY) { + return process.env.ZAI_API_KEY; + } + throw new Error('ZAI_API_KEY not configured'); + } + + /** + * Fetch with retry logic and exponential backoff + * Retries on network errors, 5xx errors, and 429 rate limit + * Does NOT retry on 4xx client errors (except 429) + */ + private async fetchWithRetry( + url: string, + options: RequestInit, + maxRetries = 3 + ): Promise { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await fetch(url, options); + + // Don't retry on client errors (4xx) except 429 (rate limit) + if (response.ok || (!response.ok && response.status < 400)) { + return response; + } + + if (response.status === 429) { + // Rate limit - retry with backoff + if (attempt < maxRetries - 1) { + const backoffMs = Math.pow(2, attempt) * 1000; + logger.warn( + `[Zai] Rate limited, retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})` + ); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + continue; + } + } + + // Don't retry on other client errors or server errors after exhausting retries + return response; + } catch (error) { + // Retry only on network errors + if (attempt === maxRetries - 1) { + logger.error( + `[Zai] Fetch failed after ${maxRetries} attempts: ${(error as Error).message}` + ); + throw error; + } + + // Network error - retry with exponential backoff + const backoffMs = Math.pow(2, attempt) * 1000; + logger.warn( + `[Zai] Network error, retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries}): ${(error as Error).message}` + ); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } + } + + throw new Error('Max retries exceeded'); + } + + /** + * Execute a query using Z.ai API with tool calling support + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + const { + prompt, + model = ZAI_MODEL, + cwd, + systemPrompt, + maxTurns = 20, + allowedTools, + abortController, + conversationHistory = [], + sdkSessionId, + outputFormat, + } = options; + + const apiKey = this.getApiKey(); + + // Handle images for non-vision models + // If a Zai model that doesn't support vision is selected with images, + // use GLM-4.6v to describe the images and prepend the description to the prompt + let finalPrompt: string | Array<{ type: string; text?: string; source?: object }> = prompt; + const finalModel = model; + + if (!this.modelSupportsVision(model)) { + // Check if prompt contains images + const hasImages = + typeof prompt === 'object' && + Array.isArray(prompt) && + prompt.some((block) => block.type === 'image' || block.source); + + if (hasImages) { + logger.info(`Model ${model} doesn't support vision, using GLM-4.6v to describe images`); + + try { + // Filter images - handle both ContentBlock format and ImageAttachment format + const images = (Array.isArray(prompt) ? prompt : []).filter((block) => { + if (typeof block !== 'object' || block === null) return false; + // Handle ContentBlock format { type: 'image', source: {...} } + if ('type' in block && block.type === 'image') return true; + if ('source' in block && block.source) return true; + // Handle ImageAttachment format { data: string, mimeType: string } + if ('data' in block && 'mimeType' in block) return true; + return false; + }); + const textContent = (Array.isArray(prompt) ? prompt : []).filter((block) => { + if (typeof block !== 'object' || block === null) return false; + // ContentBlock format - exclude images + if ('type' in block && block.type === 'image') return false; + if ('source' in block && block.source) return false; + // ImageAttachment format - exclude images + if ('data' in block && 'mimeType' in block) return false; + return true; + }); + const textOnly = textContent.map((block) => block.text || '').join('\n'); + + // Describe images using GLM-4.6v + const imageDescription = await this.describeImages(images, textOnly); + + // Combine text with image description + finalPrompt = textOnly + (imageDescription ? '\n\n' + imageDescription : ''); + } catch (error) { + logger.warn(`Image description failed: ${(error as Error).message}`); + // Fall back to text-only content + if (Array.isArray(prompt)) { + finalPrompt = prompt.map((block) => block.text || '').join('\n'); + } else { + finalPrompt = prompt; // Already a string + } + } + } + } + + // Build messages array + // Zai supports reasoning_content for preserved thinking mode (API format) + const messages: Array<{ + role: 'system' | 'user' | 'assistant' | 'tool'; + content?: string | Array<{ type: string; text?: string; image_url?: { url: string } }>; + reasoning_content?: string; // Zai API format for thinking mode - preserved across turns + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; + tool_call_id?: string; + }> = []; + + // Add system prompt + if (systemPrompt) { + if (typeof systemPrompt === 'string') { + messages.push({ role: 'system', content: systemPrompt }); + } else if (systemPrompt.type === 'preset' && systemPrompt.preset === 'claude_code') { + messages.push({ + role: 'system', + content: + 'You are an AI programming assistant. You help users write code, debug issues, and build software. Use the available tools to read and edit files, search code, and execute commands.\n\n' + + 'IMPORTANT: Always use RELATIVE paths (e.g., "src/index.ts", "./config.json") for file operations. ' + + 'NEVER use absolute paths like "/home/user/file" or "C:\\Users\\file". ' + + 'All paths are relative to the current working directory.', + }); + } + } + + // Add base working directory info to system prompt for clarity + if (cwd) { + const dirInfo = `\n\nCurrent working directory: ${cwd}`; + if (messages.length > 0 && messages[0].role === 'system') { + const existingContent = messages[0].content; + messages[0].content = Array.isArray(existingContent) + ? existingContent + : typeof existingContent === 'string' + ? existingContent + dirInfo + : dirInfo; + } else { + messages.unshift({ role: 'system', content: dirInfo.slice(2) }); + } + } + + // When structured output is requested, add JSON output instruction + // Z.ai requires this when using response_format: { type: 'json_object' } + if (outputFormat) { + const jsonInstruction = + 'You must respond with valid JSON only. Do not include any explanatory text outside the JSON structure.'; + if (messages.length > 0 && messages[0].role === 'system') { + // Append to existing system prompt + const existingContent = messages[0].content; + messages[0].content = Array.isArray(existingContent) + ? existingContent + : typeof existingContent === 'string' + ? `${existingContent}\n\n${jsonInstruction}` + : jsonInstruction; + } else { + // Prepend new system prompt + messages.unshift({ role: 'system', content: jsonInstruction }); + } + } + + // Add conversation history + for (const msg of conversationHistory) { + if (msg.role === 'user') { + messages.push({ + role: 'user', + content: this.formatContent(msg.content, cwd, model), + }); + } else if (msg.role === 'assistant') { + messages.push({ + role: 'assistant', + content: this.formatContent(msg.content, '', model), + }); + } + } + + // Add current prompt + messages.push({ + role: 'user', + content: this.formatContent(finalPrompt, cwd, finalModel), + }); + + // Filter tools based on allowedTools + const toolsToUse = allowedTools + ? ZAI_TOOLS.filter((tool) => { + const toolName = tool.function.name; + // Map tool names to allowed tools + const toolMap: Record = { + read_file: 'Read', + write_file: 'Write', + edit_file: 'Edit', + glob_search: 'Glob', + grep_search: 'Grep', + execute_command: 'Bash', + }; + return allowedTools.includes(toolMap[toolName] || toolName); + }) + : ZAI_TOOLS; + + // Tool execution loop + let turnCount = 0; + // Use sdkSessionId if provided, otherwise generate a new one + let sessionId = sdkSessionId || this.generateSessionId(); + + while (turnCount < maxTurns) { + if (abortController?.signal.aborted) { + yield { + type: 'error', + error: 'Request aborted by user', + }; + return; + } + + turnCount++; + + try { + // Call Z.ai API with streaming enabled + const response = await this.fetchWithRetry(`${ZAI_API_BASE}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + messages, + tools: toolsToUse.length > 0 ? toolsToUse : undefined, + tool_choice: toolsToUse.length > 0 ? 'auto' : undefined, + stream: true, // Enable streaming for better UX + temperature: 0.7, + // Z.ai thinking mode support (GLM-4.7) + // Default to enabled thinking for GLM-4.7 if not explicitly disabled + thinking: options.thinking || { type: 'enabled', clear_thinking: false }, + // Z.ai structured output support + ...(outputFormat && { response_format: { type: 'json_object' } }), + }), + + // Combine user abort controller with timeout + signal: this.createAbortSignal(abortController), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Z.ai API error: ${response.status} - ${errorText}`); + } + + // Parse streaming response (Server-Sent Events format) + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No response body'); + } + + const decoder = new TextDecoder(); + let currentContent = ''; + let currentReasoning = ''; + let currentToolCalls: Map = + new Map(); + let finishReason = ''; + let sseBuffer = ''; // Buffer for incomplete SSE lines + let emptyIterations = 0; // Track iterations without new data to prevent infinite loop + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + // Track if we're getting new data (prevents infinite loop on stalled streams) + if (!value || value.length === 0) { + emptyIterations++; + // If we've received finish_reason but no new data for 10+ iterations, stop waiting + if (finishReason && emptyIterations > 10) { + logger.warn('[Zai] Stream stalled with incomplete data, breaking out'); + break; + } + // Safety: break after 100 empty iterations regardless + if (emptyIterations > 100) { + logger.warn('[Zai] Too many empty iterations, breaking out'); + break; + } + continue; // Skip processing of empty chunk + } else { + emptyIterations = 0; // Reset counter when we get data + } + + const chunk = decoder.decode(value, { stream: true }); + sseBuffer += chunk; + + // Split into lines, keeping the last incomplete line in buffer + const lines = sseBuffer.split('\n'); + if (!chunk.endsWith('\n')) { + // Last line is incomplete, keep it for next chunk + sseBuffer = lines.pop() || ''; + } else { + sseBuffer = ''; + } + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith('data: ')) continue; + + const data = trimmed.slice(6); // Remove 'data: ' prefix + if (data === '[DONE]') continue; + + try { + const parsed = JSON.parse(data) as ZaiSseResponse; + + const choice = parsed.choices?.[0]; + if (!choice) continue; + + // Handle content delta + if (choice.delta?.content) { + currentContent += choice.delta.content; + + // Yield text content incrementally + yield { + type: 'assistant', + session_id: sessionId, + message: { + role: 'assistant', + content: [{ type: 'text', text: choice.delta.content }], + }, + }; + } + + // Handle reasoning content (thinking mode) + if (choice.delta?.reasoning_content) { + currentReasoning += choice.delta.reasoning_content; + + // Yield thinking content incrementally (unified format) + yield { + type: 'assistant', + session_id: sessionId, + message: { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: choice.delta.reasoning_content, + }, + ], + }, + }; + } + + // Handle tool calls in streaming + if (choice.delta?.tool_calls) { + for (const toolCall of choice.delta.tool_calls) { + const index = toolCall.index; + + if (!currentToolCalls.has(index)) { + currentToolCalls.set(index, { + id: toolCall.id || '', + name: '', + arguments: '', + }); + } + + const current = currentToolCalls.get(index)!; + if (toolCall.id) current.id = toolCall.id; + if (toolCall.function?.name) current.name = toolCall.function.name; + if (toolCall.function?.arguments) { + current.arguments += toolCall.function.arguments; + } + } + } + + // Track finish reason + if (choice.finish_reason) { + finishReason = choice.finish_reason; + } + } catch (parseError) { + // Skip unparseable chunks + logger.debug(`Failed to parse SSE chunk: ${(parseError as Error).message}`); + } + } + + // Check for completion - only break if all content has been received + // For 'stop': all deltas should be complete + // For 'tool_calls': verify all tool calls are complete before breaking + if (finishReason === 'stop') { + break; + } + if (finishReason === 'tool_calls' && currentToolCalls.size > 0) { + // Verify all tool calls have complete data (id, name, and arguments) + const allComplete = Array.from(currentToolCalls.values()).every( + (tc) => tc.id && tc.name && tc.arguments + ); + if (allComplete) { + break; + } + // Otherwise continue waiting for complete tool call data + } + } // closes while loop + } finally { + // closes try block, starts finally + // Ensure reader is always released to prevent resource leaks + reader.releaseLock(); + } + + // Safety check: if stream ended with incomplete tool calls, warn and don't execute them + if (finishReason === 'tool_calls' && currentToolCalls.size > 0) { + const allComplete = Array.from(currentToolCalls.values()).every( + (tc) => tc.id && tc.name && tc.arguments + ); + if (!allComplete) { + logger.warn('[Zai] Stream ended with incomplete tool calls, skipping execution'); + // Clear incomplete tool calls to prevent execution errors + currentToolCalls.clear(); + } + } + + // Add assistant message to history + messages.push({ + role: 'assistant', + content: currentContent || undefined, + reasoning_content: currentReasoning || undefined, + tool_calls: + currentToolCalls.size > 0 + ? Array.from(currentToolCalls.values()) + .filter((tc) => { + // Validate that tool call arguments are valid JSON before including + try { + JSON.parse(tc.arguments); + return true; + } catch { + logger.warn( + `[Zai] Discarding invalid tool call: ${tc.name} with malformed arguments` + ); + return false; + } + }) + .map((tc) => ({ + id: tc.id, + type: 'function', + function: { + name: tc.name, + arguments: tc.arguments, + }, + })) + : undefined, + }); + + // Process tool calls after streaming is complete + // Only execute tools if we received a proper tool_calls finish reason + if (currentToolCalls.size > 0 && finishReason === 'tool_calls') { + for (const [index, toolCall] of currentToolCalls) { + // Validate JSON before processing + let toolArgs: Record; + try { + toolArgs = JSON.parse(toolCall.arguments); + } catch { + // Skip invalid tool calls (already filtered above, but double-check) + logger.warn( + `[Zai] Skipping tool ${toolCall.name} with invalid arguments: ${toolCall.arguments}` + ); + continue; + } + + const toolName = toolCall.name; + + // Yield tool_use message + yield { + type: 'assistant', + session_id: sessionId, + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: toolName, + input: toolArgs, + }, + ], + }, + }; + + // Execute tool + try { + const toolResult = await executeToolCall(toolName, toolArgs, cwd); + + // Add tool result to messages + messages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: toolResult, + }); + + // Yield tool result + yield { + type: 'result', + parent_tool_use_id: toolCall.id, + result: toolResult, + }; + } catch (toolError) { + const errorMsg = `Tool ${toolName} failed: ${(toolError as Error).message}`; + messages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: errorMsg, + }); + yield { + type: 'error', + error: errorMsg, + }; + } + } + + // Continue loop for another response after tool execution + continue; + } + + // If finish_reason is 'stop', we're done + if (finishReason === 'stop') { + yield { + type: 'result', + result: 'Conversation completed', + }; + break; + } + } catch (error) { + const errorMsg = (error as Error).message; + yield { + type: 'error', + error: errorMsg, + }; + throw error; + } + } + } + + /** + * Format content for API (handle images if present) + * For GLM-4.6v: formats images for multimodal content + * For non-vision models: filters to text (should already be handled by image description) + */ + private formatContent( + content: string | Array<{ type: string; text?: string; source?: object }>, + basePath = '', + model = '' + ): string | Array<{ type: string; text?: string; image_url?: { url: string } }> { + if (typeof content === 'string') { + return content; + } + + // Check if any content block is an image + const hasImage = content.some((block) => block.type === 'image' || block.source); + + if (!hasImage) { + return content.map((block) => block.text || '').join('\n'); + } + + // Check if model supports vision (only GLM-4.6v supports vision) + const supportsVision = this.modelSupportsVision(model); + + // For non-vision models, filter to text-only (fallback safety) + if (!supportsVision) { + return content.map((block) => block.text || '').join('\n'); + } + + // Format for multimodal content (GLM-4.6v supports vision) + return content.map((block) => { + if (block.type === 'image' && block.source) { + // Handle image - if it's a file path, convert to data URL + const source = block.source as { type?: string; media_type?: string; data?: string }; + if (source.type === 'base64') { + return { + type: 'image_url', + image_url: { + url: `data:${source.media_type || 'image/png'};base64,${source.data}`, + }, + }; + } + } + return { type: 'text', text: block.text || '' }; + }) as Array<{ type: string; text?: string; image_url?: { url: string } }>; + } + + /** + * Generate a session ID for conversation tracking + */ + private generateSessionId(): string { + return `zai_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + } + + /** + * Check if the given model supports vision + * Only GLM-4.6v supports vision among Zai models + */ + private modelSupportsVision(model: string): boolean { + return model === 'glm-4.6v'; + } + + /** + * Describe images using GLM-4.6v (the only vision-capable Zai model) + * Returns a text description of the images + */ + private async describeImages( + images: Array<{ type: string; source?: object }>, + originalPrompt: string + ): Promise { + // Validate image sources - only base64 is supported + for (const img of images) { + if (img.source && typeof img.source === 'object') { + const source = img.source as { type?: string; data?: string }; + // Reject file:// URLs or other URL schemes + if (source.data && typeof source.data === 'string' && source.data.startsWith('file://')) { + throw new Error('file:// URLs are not supported in image sources'); + } + // Reject any source type other than base64 + if (source.type && source.type !== 'base64') { + throw new Error( + `Unsupported image source type: ${source.type}. Only base64 is supported.` + ); + } + } + } + + const apiKey = this.getApiKey(); + + // Build image-only content for GLM-4.6v + const imageContent = images + .map((img) => { + if (img.type === 'image' && img.source) { + const source = img.source as { type?: string; media_type?: string; data?: string }; + if (source.type === 'base64') { + return { + type: 'image_url', + image_url: { + url: `data:${source.media_type || 'image/png'};base64,${source.data}`, + }, + }; + } + } + return null; + }) + .filter(Boolean); + + const promptForVision = `Please describe these images in the context of: "${originalPrompt.substring(0, 200)}...". Provide a concise description that would help someone understand the visual content.`; + + try { + const response = await fetch(`${ZAI_API_BASE}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: 'glm-4.6v', + messages: [ + { + role: 'user', + content: [{ type: 'text', text: promptForVision }, ...imageContent], + }, + ], + stream: false, + max_tokens: 500, + }), + }); + + if (!response.ok) { + logger.warn('Failed to describe images, continuing without descriptions'); + return ''; + } + + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const description = data.choices?.[0]?.message?.content || ''; + return `[Image Context: ${description}]`; + } catch (error) { + logger.warn(`Image description failed: ${(error as Error).message}`); + return ''; + } + } + + /** + * Detect if Z.ai API key is configured + */ + async detectInstallation(): Promise { + let apiKey: string; + try { + apiKey = this.getApiKey(); + } catch { + // No API key configured + return { + installed: true, + method: 'sdk', + hasApiKey: false, + authenticated: false, + error: 'ZAI_API_KEY not configured', + }; + } + + const hasKey = !!apiKey && apiKey.length > 0; + if (!hasKey) { + return { + installed: true, + method: 'sdk', + hasApiKey: false, + authenticated: false, + error: 'ZAI_API_KEY not configured', + }; + } + + // Try a simple API call to verify the key works + try { + const response = await fetch(`${ZAI_API_BASE}/models`, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (response.ok) { + return { + installed: true, + method: 'sdk', + hasApiKey: true, + authenticated: true, + }; + } else { + return { + installed: true, + method: 'sdk', + hasApiKey: true, + authenticated: false, + error: `API key validation failed: ${response.status}`, + }; + } + } catch (error) { + // Network error - consider as installed but NOT authenticated + // Don't assume API key is valid when network fails + return { + installed: true, + method: 'sdk', + hasApiKey: true, + authenticated: false, + error: `Network error during validation: ${(error as Error).message}`, + }; + } + } + + /** + * Get available GLM models + */ + getAvailableModels(): ModelDefinition[] { + return [ + { + id: 'glm-4.7', + name: 'GLM-4.7', + modelString: 'glm-4.7', + provider: 'zai', + description: 'Z.ai flagship model with strong reasoning capabilities and thinking mode', + contextWindow: 200000, + maxOutputTokens: 128000, + supportsVision: false, + supportsTools: true, + supportsExtendedThinking: true, // All GLM models support thinking mode + tier: 'premium', + default: true, + }, + { + id: 'glm-4.6v', + name: 'GLM-4.6v', + modelString: 'glm-4.6v', + provider: 'zai', + description: 'Multimodal model with vision support and thinking mode', + contextWindow: 128000, + maxOutputTokens: 96000, + supportsVision: true, + supportsTools: true, + supportsExtendedThinking: true, // All GLM models support thinking mode + tier: 'vision', + default: false, + }, + { + id: 'glm-4.6', + name: 'GLM-4.6', + modelString: 'glm-4.6', + provider: 'zai', + description: 'Balanced performance with strong reasoning and thinking mode', + contextWindow: 200000, + maxOutputTokens: 128000, + supportsVision: false, + supportsTools: true, + supportsExtendedThinking: true, // All GLM models support thinking mode + tier: 'standard', + default: false, + }, + { + id: 'glm-4.5-air', + name: 'GLM-4.5-Air', + modelString: 'glm-4.5-air', + provider: 'zai', + description: 'Fast and efficient for simple tasks with thinking mode', + contextWindow: 128000, + maxOutputTokens: 96000, + supportsVision: false, + supportsTools: true, + supportsExtendedThinking: true, // All GLM models support thinking mode + tier: 'basic', + default: false, + }, + ]; + } + + /** + * Check if the provider supports a specific feature + */ + supportsFeature(feature: ProviderFeature | string): boolean { + // Normalize legacy feature names for backward compatibility + const normalizedFeature = BaseProvider.normalizeFeatureName(feature); + + // Zai supports: tools, text, vision (via glm-4.6v), thinking (via all GLM models), structured output + // Zai does NOT support: mcp, browser (these are application-layer features) + const supportedFeatures: ProviderFeature[] = [ + 'tools', + 'text', + 'vision', + 'thinking', // Zai's thinking mode (GLM reasoning content) + 'structuredOutput', + ]; + return supportedFeatures.includes(normalizedFeature); + } + + /** + * Validate the provider configuration + */ + validateConfig(): { valid: boolean; errors: string[]; warnings?: string[] } { + const errors: string[] = []; + const warnings: string[] = []; + + try { + this.getApiKey(); + } catch { + errors.push('ZAI_API_KEY not configured'); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; + } +} diff --git a/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/apps/server/src/routes/app-spec/generate-features-from-spec.ts index e2b7124df..3a412fc87 100644 --- a/apps/server/src/routes/app-spec/generate-features-from-spec.ts +++ b/apps/server/src/routes/app-spec/generate-features-from-spec.ts @@ -1,17 +1,20 @@ /** * Generate features from existing app_spec.txt + * + * Refactored to use provider-agnostic query system, + * supporting both Claude and Z.ai (and future providers). */ -import { query } from '@anthropic-ai/claude-agent-sdk'; import * as secureFs from '../../lib/secure-fs.js'; import type { EventEmitter } from '../../lib/events.js'; import { createLogger } from '@automaker/utils'; -import { createFeatureGenerationOptions } from '../../lib/sdk-options.js'; +import { executeProviderQuery } from '../../lib/provider-query.js'; import { logAuthStatus } from './common.js'; import { parseAndCreateFeatures } from './parse-and-create-features.js'; import { getAppSpecPath } from '@automaker/platform'; import type { SettingsService } from '../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js'; +import type { ProviderMessage } from '../../providers/types.js'; const logger = createLogger('SpecRegeneration'); @@ -101,33 +104,38 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge '[FeatureGeneration]' ); - const options = createFeatureGenerationOptions({ - cwd: projectPath, - abortController, - autoLoadClaudeMd, - }); - - logger.debug('SDK Options:', JSON.stringify(options, null, 2)); - logger.info('Calling Claude Agent SDK query() for features...'); + logger.debug('[ProviderQuery] Calling provider for feature generation...'); - logAuthStatus('Right before SDK query() for features'); + logAuthStatus('Right before provider query for features'); - let stream; - try { - stream = query({ prompt, options }); - logger.debug('query() returned stream successfully'); - } catch (queryError) { - logger.error('❌ query() threw an exception:'); - logger.error('Error:', queryError); - throw queryError; + // Get credentials from settings service if available + let apiKeys: { anthropic?: string; zai?: string; google?: string; openai?: string } | undefined; + if (settingsService) { + try { + const credentials = await settingsService.getCredentials(); + apiKeys = credentials.apiKeys; + logger.info(`[FeatureGeneration] Loaded credentials from settings service`); + } catch (error) { + logger.warn(`[FeatureGeneration] Failed to load credentials:`, error); + } } let responseText = ''; let messageCount = 0; - logger.debug('Starting to iterate over feature stream...'); - try { + // Use provider-agnostic query + const stream = executeProviderQuery({ + cwd: projectPath, + prompt, + useCase: 'features', + maxTurns: 50, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + autoLoadClaudeMd, + apiKeys, + }); + for await (const msg of stream) { messageCount++; logger.debug( @@ -135,9 +143,11 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2) ); - if (msg.type === 'assistant' && msg.message.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { + if (msg.type === 'assistant' && (msg as ProviderMessage).message?.content) { + const content = (msg as ProviderMessage).message!.content!; + if (!content) continue; + for (const block of content) { + if (block.type === 'text' && block.text) { responseText += block.text; logger.debug(`Feature text block received (${block.text.length} chars)`); events.emit('spec-regeneration:event', { diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts index 0762bb905..3046d6b07 100644 --- a/apps/server/src/routes/app-spec/generate-spec.ts +++ b/apps/server/src/routes/app-spec/generate-spec.ts @@ -1,8 +1,10 @@ /** * Generate app_spec.txt from project overview + * + * Refactored to use provider-agnostic query system, + * supporting both Claude and Z.ai (and future providers). */ -import { query } from '@anthropic-ai/claude-agent-sdk'; import path from 'path'; import * as secureFs from '../../lib/secure-fs.js'; import type { EventEmitter } from '../../lib/events.js'; @@ -13,7 +15,7 @@ import { type SpecOutput, } from '../../lib/app-spec-format.js'; import { createLogger } from '@automaker/utils'; -import { createSpecGenerationOptions } from '../../lib/sdk-options.js'; +import { executeProviderQuery } from '../../lib/provider-query.js'; import { logAuthStatus } from './common.js'; import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; @@ -93,39 +95,44 @@ ${getStructuredSpecPromptInstruction()}`; '[SpecRegeneration]' ); - const options = createSpecGenerationOptions({ - cwd: projectPath, - abortController, - autoLoadClaudeMd, - outputFormat: { - type: 'json_schema', - schema: specOutputSchema, - }, - }); - - logger.debug('SDK Options:', JSON.stringify(options, null, 2)); - logger.info('Calling Claude Agent SDK query()...'); + logger.info('[ProviderQuery] Calling provider for spec generation...'); - // Log auth status right before the SDK call - logAuthStatus('Right before SDK query()'); + // Log auth status for debugging + logAuthStatus('Right before provider query'); - let stream; - try { - stream = query({ prompt, options }); - logger.debug('query() returned stream successfully'); - } catch (queryError) { - logger.error('❌ query() threw an exception:'); - logger.error('Error:', queryError); - throw queryError; + // Get credentials from settings service if available + let apiKeys: { anthropic?: string; zai?: string; google?: string; openai?: string } | undefined; + if (settingsService) { + try { + const credentials = await settingsService.getCredentials(); + apiKeys = credentials.apiKeys; + logger.info(`[SpecRegeneration] Loaded credentials from settings service`); + } catch (error) { + logger.warn(`[SpecRegeneration] Failed to load credentials:`, error); + } } let responseText = ''; let messageCount = 0; let structuredOutput: SpecOutput | null = null; - logger.info('Starting to iterate over stream...'); - try { + // Use provider-agnostic query + const stream = executeProviderQuery({ + cwd: projectPath, + prompt, + useCase: 'spec', + maxTurns: 250, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + autoLoadClaudeMd, + apiKeys, + outputFormat: { + type: 'json_schema', + schema: specOutputSchema, + }, + }); + for await (const msg of stream) { messageCount++; logger.info( @@ -204,8 +211,6 @@ ${getStructuredSpecPromptInstruction()}`; logger.info(`Generated XML from structured output: ${xmlContent.length} chars`); } else { // Fallback: Extract XML content from response text - // Claude might include conversational text before/after - // See: https://github.com/AutoMaker-Org/automaker/issues/149 logger.warn('⚠️ No structured output, falling back to text parsing'); logger.info('========== FINAL RESPONSE TEXT =========='); logger.info(responseText || '(empty)'); @@ -224,13 +229,7 @@ ${getStructuredSpecPromptInstruction()}`; logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`); } else { // No valid XML structure found in the response text - // This happens when structured output was expected but not received, and the agent - // output conversational text instead of XML (e.g., "The project directory appears to be empty...") - // We should NOT save this conversational text as it's not a valid spec logger.error('❌ Response does not contain valid XML structure'); - logger.error( - 'This typically happens when structured output failed and the agent produced conversational text instead of XML' - ); throw new Error( 'Failed to generate spec: No valid XML structure found in response. ' + 'The response contained conversational text but no tags. ' + diff --git a/apps/server/src/routes/app-spec/index.ts b/apps/server/src/routes/app-spec/index.ts index 342aecd7b..30871e136 100644 --- a/apps/server/src/routes/app-spec/index.ts +++ b/apps/server/src/routes/app-spec/index.ts @@ -17,7 +17,7 @@ export function createSpecRegenerationRoutes( ): Router { const router = Router(); - router.post('/create', createCreateHandler(events)); + router.post('/create', createCreateHandler(events, settingsService)); router.post('/generate', createGenerateHandler(events, settingsService)); router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService)); router.post('/stop', createStopHandler()); diff --git a/apps/server/src/routes/app-spec/parse-and-create-features.ts b/apps/server/src/routes/app-spec/parse-and-create-features.ts index 364f64ad0..f4a16a40a 100644 --- a/apps/server/src/routes/app-spec/parse-and-create-features.ts +++ b/apps/server/src/routes/app-spec/parse-and-create-features.ts @@ -10,6 +10,107 @@ import { getFeaturesDir } from '@automaker/platform'; const logger = createLogger('SpecRegeneration'); +/** + * Clean and sanitize LLM-generated JSON + * Handles common issues like trailing commas, comments, etc. + * Preserves comment-like patterns inside JSON strings. + */ +function cleanJson(jsonString: string): string { + let cleaned = jsonString.trim(); + + // Remove trailing commas before closing brackets/braces + cleaned = cleaned.replace(/,\s*([}\]])/g, '$1'); + + // Remove comments while preserving string contents + // This implementation properly handles strings that contain // or /* */ + cleaned = removeJsonComments(cleaned); + + return cleaned; +} + +/** + * Remove JSON comments (single-line // and multi-line block comments) + * while preserving string contents that contain these patterns. + * Uses a state machine approach to track whether we're inside a string. + */ +function removeJsonComments(json: string): string { + let result = ''; + let inString = false; + let inSingleLineComment = false; + let inMultiLineComment = false; + let escapeNext = false; + + for (let i = 0; i < json.length; i++) { + const char = json[i]; + const nextChar = json[i + 1] || ''; + + // Handle escape sequences + if (escapeNext) { + if (!inSingleLineComment && !inMultiLineComment) { + result += char; + } + escapeNext = false; + continue; + } + + if (char === '\\' && inString && !inSingleLineComment && !inMultiLineComment) { + escapeNext = true; + result += char; + continue; + } + + // Track string boundaries + if (!inSingleLineComment && !inMultiLineComment) { + if (char === '"') { + inString = !inString; + result += char; + continue; + } + } + + // If we're in a string, always add the character + if (inString) { + result += char; + continue; + } + + // Check for comment start + if (!inSingleLineComment && !inMultiLineComment) { + if (char === '/' && nextChar === '/') { + inSingleLineComment = true; + i++; // Skip next character + continue; + } + if (char === '/' && nextChar === '*') { + inMultiLineComment = true; + i++; // Skip next character + continue; + } + } + + // Check for comment end + if (inSingleLineComment && (char === '\n' || char === '\r')) { + inSingleLineComment = false; + // Preserve the newline for formatting + result += char; + continue; + } + + if (inMultiLineComment && char === '*' && nextChar === '/') { + inMultiLineComment = false; + i++; // Skip next character + continue; + } + + // If not in a comment, add the character + if (!inSingleLineComment && !inMultiLineComment) { + result += char; + } + } + + return result; +} + export async function parseAndCreateFeatures( projectPath: string, content: string, @@ -34,11 +135,14 @@ export async function parseAndCreateFeatures( } logger.info(`JSON match found (${jsonMatch[0].length} chars)`); - logger.info('========== MATCHED JSON =========='); - logger.info(jsonMatch[0]); + + // Clean the JSON to handle common LLM issues + const cleanedJson = cleanJson(jsonMatch[0]); + logger.info('========== MATCHED JSON (cleaned) =========='); + logger.info(cleanedJson.substring(0, 2000) + (cleanedJson.length > 2000 ? '...' : '')); logger.info('========== END MATCHED JSON =========='); - const parsed = JSON.parse(jsonMatch[0]); + const parsed = JSON.parse(cleanedJson); logger.info(`Parsed ${parsed.features?.length || 0} features`); logger.info('Parsed features:', JSON.stringify(parsed.features, null, 2)); diff --git a/apps/server/src/routes/app-spec/routes/create.ts b/apps/server/src/routes/app-spec/routes/create.ts index ed6f68f11..7728d017e 100644 --- a/apps/server/src/routes/app-spec/routes/create.ts +++ b/apps/server/src/routes/app-spec/routes/create.ts @@ -13,10 +13,11 @@ import { getErrorMessage, } from '../common.js'; import { generateSpec } from '../generate-spec.js'; +import type { SettingsService } from '../../../services/settings-service.js'; const logger = createLogger('SpecRegeneration'); -export function createCreateHandler(events: EventEmitter) { +export function createCreateHandler(events: EventEmitter, settingsService?: SettingsService) { return async (req: Request, res: Response): Promise => { logger.info('========== /create endpoint called =========='); logger.debug('Request body:', JSON.stringify(req.body, null, 2)); @@ -68,7 +69,8 @@ export function createCreateHandler(events: EventEmitter) { abortController, generateFeatures, analyzeProject, - maxFeatures + maxFeatures, + settingsService ) .catch((error) => { logError(error, 'Generation failed with error'); diff --git a/apps/server/src/routes/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts index 472cbb768..2d7a5a024 100644 --- a/apps/server/src/routes/context/routes/describe-file.ts +++ b/apps/server/src/routes/context/routes/describe-file.ts @@ -10,14 +10,13 @@ */ import type { Request, Response } from 'express'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; -import { CLAUDE_MODEL_MAP } from '@automaker/types'; +import { DEFAULT_MODELS } from '@automaker/types'; import { PathNotAllowedError } from '@automaker/platform'; -import { createCustomOptions } from '../../../lib/sdk-options.js'; +import { executeProviderQuery } from '../../../lib/provider-query.js'; import * as secureFs from '../../../lib/secure-fs.js'; import * as path from 'path'; -import type { SettingsService } from '../../../services/settings-service.js'; +import { SettingsService } from '../../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js'; const logger = createLogger('DescribeFile'); @@ -47,7 +46,7 @@ interface DescribeFileErrorResponse { } /** - * Extract text content from Claude SDK response messages + * Extract text content from provider response messages */ async function extractTextFromStream( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -65,6 +64,10 @@ async function extractTextFromStream( } } else if (msg.type === 'result' && msg.subtype === 'success') { responseText = msg.result || responseText; + } else if (msg.type === 'error') { + // Handle error messages from the stream + logger.error('[DescribeFile] Stream error:', msg.error); + throw new Error(msg.error || 'Stream error occurred'); } } @@ -160,12 +163,12 @@ export function createDescribeFileHandler( Respond with ONLY the description text, no additional formatting, preamble, or explanation. -File: ${fileName}${truncated ? ' (truncated)' : ''}`; +File: ${fileName}${truncated ? ' (truncated)' : ''} - const promptContent = [ - { type: 'text' as const, text: instructionText }, - { type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` }, - ]; +--- + +File Content: +${contentToAnalyze}`; // Use the file's directory as the working directory const cwd = path.dirname(resolvedPath); @@ -177,33 +180,25 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`; '[DescribeFile]' ); - // Use centralized SDK options with proper cwd validation - // No tools needed since we're passing file content directly - const sdkOptions = createCustomOptions({ + // Get API keys for provider authentication + const apiKeys = settingsService ? await settingsService.getApiKeys() : undefined; + + // Use provider-agnostic query - no tools needed since file content is passed directly + const stream = executeProviderQuery({ cwd, - model: CLAUDE_MODEL_MAP.haiku, + prompt: instructionText, + useCase: 'suggestions', // Use fast model (haiku or equivalent) maxTurns: 1, allowedTools: [], autoLoadClaudeMd, - sandbox: { enabled: true, autoAllowBashIfSandboxed: true }, + apiKeys, }); - const promptGenerator = (async function* () { - yield { - type: 'user' as const, - session_id: '', - message: { role: 'user' as const, content: promptContent }, - parent_tool_use_id: null, - }; - })(); - - const stream = query({ prompt: promptGenerator, options: sdkOptions }); - // Extract the description from the response const description = await extractTextFromStream(stream); if (!description || description.trim().length === 0) { - logger.warn('Received empty response from Claude'); + logger.warn('Received empty response from provider'); const response: DescribeFileErrorResponse = { success: false, error: 'Failed to generate description - empty response', diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index e4821b4ae..ad04cfda0 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -1,20 +1,20 @@ /** * POST /context/describe-image endpoint - Generate description for an image * - * Uses Claude Haiku to analyze an image and generate a concise description + * Uses AI providers (Claude, Zai, etc.) to analyze an image and generate a concise description * suitable for context file metadata. * * IMPORTANT: * The agent runner (chat/auto-mode) sends images as multi-part content blocks (base64 image blocks), - * not by asking Claude to use the Read tool to open files. This endpoint now mirrors that approach - * so it doesn't depend on Claude's filesystem tool access or working directory restrictions. + * not by asking providers to use the Read tool to open files. This endpoint mirrors that approach + * so it doesn't depend on filesystem tool access or working directory restrictions. */ import type { Request, Response } from 'express'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger, readImageAsBase64 } from '@automaker/utils'; -import { CLAUDE_MODEL_MAP } from '@automaker/types'; -import { createCustomOptions } from '../../../lib/sdk-options.js'; +import { DEFAULT_MODELS } from '@automaker/types'; +import { executeProviderQuery } from '../../../lib/provider-query.js'; +import { resolveModelString } from '@automaker/model-resolver'; import * as fs from 'fs'; import * as path from 'path'; import type { SettingsService } from '../../../services/settings-service.js'; @@ -104,6 +104,8 @@ function findActualFilePath(requestedPath: string): string | null { interface DescribeImageRequestBody { /** Path to the image file */ imagePath: string; + /** Optional model override (defaults to provider-aware model for suggestions use case) */ + model?: string; } /** @@ -124,7 +126,7 @@ interface DescribeImageErrorResponse { } /** - * Map SDK/CLI errors to a stable status + user-facing message. + * Map provider errors to a stable status + user-facing message. */ function mapDescribeImageError(rawMessage: string | undefined): { statusCode: number; @@ -137,23 +139,23 @@ function mapDescribeImageError(rawMessage: string | undefined): { if (!rawMessage) return baseResponse; - if (rawMessage.includes('Claude Code process exited')) { + if (rawMessage.includes('process exited')) { return { statusCode: 503, userMessage: - 'Claude exited unexpectedly while describing the image. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup so Claude can restart cleanly.', + 'The AI provider exited unexpectedly while describing the image. Try again. If it keeps happening, update your API key in Setup.', }; } if ( - rawMessage.includes('Failed to spawn Claude Code process') || - rawMessage.includes('Claude Code executable not found') || - rawMessage.includes('Claude Code native binary not found') + rawMessage.includes('Failed to spawn') || + rawMessage.includes('executable not found') || + rawMessage.includes('native binary not found') ) { return { statusCode: 503, userMessage: - 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, then try again.', + 'The AI provider could not be launched. Make sure the provider CLI is installed and available, then try again.', }; } @@ -213,6 +215,12 @@ async function extractTextFromStream( responseText = msg.result; } } + + if (msgType === 'error') { + // Handle error messages from the stream + logger.error(`[${requestId}] [Stream] error:`, msg.error); + throw new Error(msg.error || 'Stream error occurred'); + } } logger.info( @@ -245,7 +253,7 @@ export function createDescribeImageHandler( logger.info(`[${requestId}] body=${JSON.stringify(req.body)}`); try { - const { imagePath } = req.body as DescribeImageRequestBody; + const { imagePath, model: requestedModel } = req.body as DescribeImageRequestBody; // Validate required fields if (!imagePath || typeof imagePath !== 'string') { @@ -337,43 +345,39 @@ export function createDescribeImageHandler( '[DescribeImage]' ); - // Use the same centralized option builder used across the server (validates cwd) - const sdkOptions = createCustomOptions({ + // Get API keys for provider authentication + const apiKeys = settingsService ? await settingsService.getApiKeys() : undefined; + + // Resolve model (provider-aware, uses requested model or defaults) + const resolvedModel = resolveModelString(requestedModel, undefined, 'auto'); + + logger.info( + `[${requestId}] Using provider-agnostic query model=${resolvedModel} maxTurns=1 allowedTools=[]` + ); + + logger.info(`[${requestId}] Calling executeProviderQuery()...`); + const queryStart = Date.now(); + const stream = executeProviderQuery({ cwd, - model: CLAUDE_MODEL_MAP.haiku, + prompt: promptContent, + model: resolvedModel, + useCase: 'suggestions', // Use fast model maxTurns: 1, allowedTools: [], autoLoadClaudeMd, - sandbox: { enabled: true, autoAllowBashIfSandboxed: true }, + apiKeys, }); - logger.info( - `[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify( - sdkOptions.allowedTools - )} sandbox=${JSON.stringify(sdkOptions.sandbox)}` + `[${requestId}] executeProviderQuery() returned stream in ${Date.now() - queryStart}ms` ); - const promptGenerator = (async function* () { - yield { - type: 'user' as const, - session_id: '', - message: { role: 'user' as const, content: promptContent }, - parent_tool_use_id: null, - }; - })(); - - logger.info(`[${requestId}] Calling query()...`); - const queryStart = Date.now(); - const stream = query({ prompt: promptGenerator, options: sdkOptions }); - logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`); - // Extract the description from the response const extractStart = Date.now(); const description = await extractTextFromStream(stream, requestId); logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`); if (!description || description.trim().length === 0) { - logger.warn(`[${requestId}] Received empty response from Claude`); + logger.warn(`[${requestId}] Received empty response from provider`); const response: DescribeImageErrorResponse = { success: false, error: 'Failed to generate description - empty response', diff --git a/apps/server/src/routes/enhance-prompt/index.ts b/apps/server/src/routes/enhance-prompt/index.ts index 952bf3474..1307a316d 100644 --- a/apps/server/src/routes/enhance-prompt/index.ts +++ b/apps/server/src/routes/enhance-prompt/index.ts @@ -7,16 +7,18 @@ import { Router } from 'express'; import { createEnhanceHandler } from './routes/enhance.js'; +import type { SettingsService } from '../../services/settings-service.js'; /** * Create the enhance-prompt router * + * @param settingsService - Optional settings service for loading API keys * @returns Express router with enhance-prompt endpoints */ -export function createEnhancePromptRoutes(): Router { +export function createEnhancePromptRoutes(settingsService?: SettingsService): Router { const router = Router(); - router.post('/', createEnhanceHandler()); + router.post('/', createEnhanceHandler(settingsService)); return router; } diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index e0edd515f..9e9f2ffb6 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -1,15 +1,16 @@ /** * POST /enhance-prompt endpoint - Enhance user input text * - * Uses Claude AI to enhance text based on the specified enhancement mode. + * Uses AI providers (Claude, Zai, etc.) to enhance text based on the specified enhancement mode. * Supports modes: improve, technical, simplify, acceptance */ import type { Request, Response } from 'express'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; import { resolveModelString } from '@automaker/model-resolver'; -import { CLAUDE_MODEL_MAP } from '@automaker/types'; +import { DEFAULT_MODELS } from '@automaker/types'; +import { executeProviderQuery } from '../../../lib/provider-query.js'; +import { SettingsService } from '../../../services/settings-service.js'; import { getSystemPrompt, buildUserPrompt, @@ -48,7 +49,7 @@ interface EnhanceErrorResponse { } /** - * Extract text content from Claude SDK response messages + * Extract text content from provider response messages * * @param stream - The async iterable from the query function * @returns The extracted text content @@ -58,6 +59,7 @@ async function extractTextFromStream( type: string; subtype?: string; result?: string; + error?: string; message?: { content?: Array<{ type: string; text?: string }>; }; @@ -74,6 +76,10 @@ async function extractTextFromStream( } } else if (msg.type === 'result' && msg.subtype === 'success') { responseText = msg.result || responseText; + } else if (msg.type === 'error') { + // Handle error messages from the stream + logger.error('[EnhancePrompt] Stream error:', msg.error); + throw new Error(msg.error || 'Stream error occurred'); } } @@ -83,9 +89,12 @@ async function extractTextFromStream( /** * Create the enhance request handler * + * @param settingsService - Optional settings service for loading API keys * @returns Express request handler for text enhancement */ -export function createEnhanceHandler(): (req: Request, res: Response) => Promise { +export function createEnhanceHandler( + settingsService?: SettingsService +): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { const { originalText, enhancementMode, model } = req.body as EnhanceRequestBody; @@ -135,29 +144,31 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise // This helps the model understand this is text transformation, not a coding task const userPrompt = buildUserPrompt(validMode, trimmedText, true); - // Resolve the model - use the passed model, default to sonnet for quality - const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.sonnet); + // Resolve the model - use the passed model, auto-resolve provider + const resolvedModel = resolveModelString(model, undefined, 'auto'); logger.debug(`Using model: ${resolvedModel}`); - // Call Claude SDK with minimal configuration for text transformation + // Get API keys for provider authentication + const apiKeys = settingsService ? await settingsService.getApiKeys() : undefined; + + // Call provider-agnostic query with minimal configuration for text transformation // Key: no tools, just text completion - const stream = query({ + const stream = executeProviderQuery({ + cwd: process.cwd(), prompt: userPrompt, - options: { - model: resolvedModel, - systemPrompt, - maxTurns: 1, - allowedTools: [], - permissionMode: 'acceptEdits', - }, + useCase: 'chat', + model: resolvedModel, + apiKeys, + maxTurns: 1, + allowedTools: [], }); // Extract the enhanced text from the response const enhancedText = await extractTextFromStream(stream); if (!enhancedText || enhancedText.trim().length === 0) { - logger.warn('Received empty response from Claude'); + logger.warn('Received empty response from provider'); const response: EnhanceErrorResponse = { success: false, error: 'Failed to generate enhanced text - empty response', diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts index 5863c4d49..6fe66b793 100644 --- a/apps/server/src/routes/features/index.ts +++ b/apps/server/src/routes/features/index.ts @@ -12,8 +12,12 @@ import { createUpdateHandler } from './routes/update.js'; import { createDeleteHandler } from './routes/delete.js'; import { createAgentOutputHandler } from './routes/agent-output.js'; import { createGenerateTitleHandler } from './routes/generate-title.js'; +import type { SettingsService } from '../../services/settings-service.js'; -export function createFeaturesRoutes(featureLoader: FeatureLoader): Router { +export function createFeaturesRoutes( + featureLoader: FeatureLoader, + settingsService?: SettingsService +): Router { const router = Router(); router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader)); @@ -22,7 +26,7 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router { router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader)); router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader)); router.post('/agent-output', createAgentOutputHandler(featureLoader)); - router.post('/generate-title', createGenerateTitleHandler()); + router.post('/generate-title', createGenerateTitleHandler(settingsService)); return router; } diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index 1225a8256..8ff2b4fd3 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -1,18 +1,21 @@ /** * POST /features/generate-title endpoint - Generate a concise title from description * - * Uses Claude Haiku to generate a short, descriptive title from feature description. + * Uses AI providers (Claude, Zai, etc.) to generate a short, descriptive title from feature description. */ import type { Request, Response } from 'express'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; -import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; +import { DEFAULT_MODELS } from '@automaker/types'; +import { resolveModelString } from '@automaker/model-resolver'; +import { executeProviderQuery } from '../../../lib/provider-query.js'; +import { SettingsService } from '../../../services/settings-service.js'; const logger = createLogger('GenerateTitle'); interface GenerateTitleRequestBody { description: string; + model?: string; } interface GenerateTitleSuccessResponse { @@ -39,6 +42,7 @@ async function extractTextFromStream( type: string; subtype?: string; result?: string; + error?: string; message?: { content?: Array<{ type: string; text?: string }>; }; @@ -55,16 +59,22 @@ async function extractTextFromStream( } } else if (msg.type === 'result' && msg.subtype === 'success') { responseText = msg.result || responseText; + } else if (msg.type === 'error') { + // Handle error messages from the stream + logger.error('[GenerateTitle] Stream error:', msg.error); + throw new Error(msg.error || 'Stream error occurred'); } } return responseText; } -export function createGenerateTitleHandler(): (req: Request, res: Response) => Promise { +export function createGenerateTitleHandler( + settingsService?: SettingsService +): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { - const { description } = req.body as GenerateTitleRequestBody; + const { description, model: requestedModel } = req.body as GenerateTitleRequestBody; if (!description || typeof description !== 'string') { const response: GenerateTitleErrorResponse = { @@ -89,21 +99,27 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`; - const stream = query({ + // Get API keys for provider authentication + const apiKeys = settingsService ? await settingsService.getApiKeys() : undefined; + + // Resolve model (provider-aware, uses requested model or defaults) + const resolvedModel = resolveModelString(requestedModel, undefined, 'auto'); + + const stream = executeProviderQuery({ + cwd: process.cwd(), prompt: userPrompt, - options: { - model: CLAUDE_MODEL_MAP.haiku, - systemPrompt: SYSTEM_PROMPT, - maxTurns: 1, - allowedTools: [], - permissionMode: 'acceptEdits', - }, + model: resolvedModel, + useCase: 'suggestions', // Use fast model + maxTurns: 1, + allowedTools: [], + apiKeys, + systemPrompt: SYSTEM_PROMPT, }); const title = await extractTextFromStream(stream); if (!title || title.trim().length === 0) { - logger.warn('Received empty response from Claude'); + logger.warn('Received empty response from provider'); const response: GenerateTitleErrorResponse = { success: false, error: 'Failed to generate title - empty response', diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index c987453af..e3209842e 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -1,15 +1,14 @@ /** - * POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK (async) + * POST /validate-issue endpoint - Validate a GitHub issue using AI providers (async) * * Scans the codebase to determine if an issue is valid, invalid, or needs clarification. * Runs asynchronously and emits events for progress and completion. */ import type { Request, Response } from 'express'; -import { query } from '@anthropic-ai/claude-agent-sdk'; import type { EventEmitter } from '../../../lib/events.js'; import type { IssueValidationResult, IssueValidationEvent, AgentModel } from '@automaker/types'; -import { createSuggestionsOptions } from '../../../lib/sdk-options.js'; +import { executeProviderQuery } from '../../../lib/provider-query.js'; import { writeValidation } from '../../../lib/validation-storage.js'; import { issueValidationSchema, @@ -86,9 +85,13 @@ async function runValidation( '[ValidateIssue]' ); - // Create SDK options with structured output and abort controller - const options = createSuggestionsOptions({ + // Get API keys for provider authentication + const apiKeys = settingsService ? await settingsService.getApiKeys() : undefined; + + // Execute the query using provider-agnostic system + const stream = executeProviderQuery({ cwd: projectPath, + prompt, model, systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT, abortController, @@ -97,10 +100,8 @@ async function runValidation( type: 'json_schema', schema: issueValidationSchema as Record, }, + apiKeys, }); - - // Execute the query - const stream = query({ prompt, options }); let validationResult: IssueValidationResult | null = null; let responseText = ''; diff --git a/apps/server/src/routes/models/routes/available.ts b/apps/server/src/routes/models/routes/available.ts index 4ac4e0b18..fadd889c9 100644 --- a/apps/server/src/routes/models/routes/available.ts +++ b/apps/server/src/routes/models/routes/available.ts @@ -4,58 +4,14 @@ import type { Request, Response } from 'express'; import { getErrorMessage, logError } from '../common.js'; - -interface ModelDefinition { - id: string; - name: string; - provider: string; - contextWindow: number; - maxOutputTokens: number; - supportsVision: boolean; - supportsTools: boolean; -} +import { ProviderFactory } from '../../../providers/provider-factory.js'; +import type { ModelDefinition } from '../../../providers/types.js'; export function createAvailableHandler() { return async (_req: Request, res: Response): Promise => { try { - const models: ModelDefinition[] = [ - { - id: 'claude-opus-4-5-20251101', - name: 'Claude Opus 4.5', - provider: 'anthropic', - contextWindow: 200000, - maxOutputTokens: 16384, - supportsVision: true, - supportsTools: true, - }, - { - id: 'claude-sonnet-4-20250514', - name: 'Claude Sonnet 4', - provider: 'anthropic', - contextWindow: 200000, - maxOutputTokens: 16384, - supportsVision: true, - supportsTools: true, - }, - { - id: 'claude-3-5-sonnet-20241022', - name: 'Claude 3.5 Sonnet', - provider: 'anthropic', - contextWindow: 200000, - maxOutputTokens: 8192, - supportsVision: true, - supportsTools: true, - }, - { - id: 'claude-3-5-haiku-20241022', - name: 'Claude 3.5 Haiku', - provider: 'anthropic', - contextWindow: 200000, - maxOutputTokens: 8192, - supportsVision: true, - supportsTools: true, - }, - ]; + // Get models from all registered providers (single source of truth) + const models: ModelDefinition[] = ProviderFactory.getAllAvailableModels(); res.json({ success: true, models }); } catch (error) { diff --git a/apps/server/src/routes/models/routes/providers.ts b/apps/server/src/routes/models/routes/providers.ts index b7ef1b858..3e802d2f5 100644 --- a/apps/server/src/routes/models/routes/providers.ts +++ b/apps/server/src/routes/models/routes/providers.ts @@ -16,6 +16,12 @@ export function createProvidersHandler() { anthropic: { available: statuses.claude?.installed || false, hasApiKey: !!process.env.ANTHROPIC_API_KEY, + authenticated: statuses.claude?.authenticated || false, + }, + zai: { + available: statuses.zai?.installed || false, + hasApiKey: !!process.env.ZAI_API_KEY, + authenticated: statuses.zai?.authenticated || false, }, }; diff --git a/apps/server/src/routes/setup/common.ts b/apps/server/src/routes/setup/common.ts index 097d7a6c8..0b153b649 100644 --- a/apps/server/src/routes/setup/common.ts +++ b/apps/server/src/routes/setup/common.ts @@ -12,6 +12,52 @@ const logger = createLogger('Setup'); // Storage for API keys (in-memory cache) - private const apiKeys: Record = {}; +/** + * Provider registry - maps provider names to their environment variable keys + * Add new providers here to enable API key storage/retrieval + */ +interface ProviderConfig { + envKey: string; + aliases: string[]; +} + +export const PROVIDER_REGISTRY: Record = { + anthropic: { + envKey: 'ANTHROPIC_API_KEY', + aliases: ['anthropic_oauth_token'], // These map to the same env key + }, + zai: { + envKey: 'ZAI_API_KEY', + aliases: [], + }, +}; + +export type SupportedProvider = keyof typeof PROVIDER_REGISTRY; + +/** + * Check if a provider is supported + */ +export function isSupportedProvider(provider: string): provider is SupportedProvider { + return provider in PROVIDER_REGISTRY; +} + +/** + * Get the environment variable key for a provider + */ +export function getProviderEnvKey(provider: string): string | null { + // Check direct match + if (provider in PROVIDER_REGISTRY) { + return PROVIDER_REGISTRY[provider as SupportedProvider].envKey; + } + // Check aliases + for (const [name, config] of Object.entries(PROVIDER_REGISTRY)) { + if (config.aliases.includes(provider)) { + return PROVIDER_REGISTRY[name as SupportedProvider].envKey; + } + } + return null; +} + /** * Get an API key for a provider */ diff --git a/apps/server/src/routes/setup/index.ts b/apps/server/src/routes/setup/index.ts index 3681b2fc5..e8c744eb0 100644 --- a/apps/server/src/routes/setup/index.ts +++ b/apps/server/src/routes/setup/index.ts @@ -11,6 +11,7 @@ import { createDeleteApiKeyHandler } from './routes/delete-api-key.js'; import { createApiKeysHandler } from './routes/api-keys.js'; import { createPlatformHandler } from './routes/platform.js'; import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js'; +import { createVerifyZaiAuthHandler, zaiAuthRateLimit } from './routes/verify-zai-auth.js'; import { createGhStatusHandler } from './routes/gh-status.js'; export function createSetupRoutes(): Router { @@ -24,6 +25,7 @@ export function createSetupRoutes(): Router { router.get('/api-keys', createApiKeysHandler()); router.get('/platform', createPlatformHandler()); router.post('/verify-claude-auth', createVerifyClaudeAuthHandler()); + router.post('/verify-zai-auth', zaiAuthRateLimit, createVerifyZaiAuthHandler()); router.get('/gh-status', createGhStatusHandler()); return router; diff --git a/apps/server/src/routes/setup/routes/api-keys.ts b/apps/server/src/routes/setup/routes/api-keys.ts index d052c187f..51f9380e4 100644 --- a/apps/server/src/routes/setup/routes/api-keys.ts +++ b/apps/server/src/routes/setup/routes/api-keys.ts @@ -11,6 +11,8 @@ export function createApiKeysHandler() { res.json({ success: true, hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY, + hasZaiKey: !!getApiKey('zai') || !!process.env.ZAI_API_KEY, + hasGoogleKey: false, }); } catch (error) { logError(error, 'Get API keys failed'); diff --git a/apps/server/src/routes/setup/routes/delete-api-key.ts b/apps/server/src/routes/setup/routes/delete-api-key.ts index e64ff6b76..da74feaab 100644 --- a/apps/server/src/routes/setup/routes/delete-api-key.ts +++ b/apps/server/src/routes/setup/routes/delete-api-key.ts @@ -6,13 +6,10 @@ import type { Request, Response } from 'express'; import { createLogger } from '@automaker/utils'; import path from 'path'; import fs from 'fs/promises'; +import { setApiKey, getProviderEnvKey } from '../common.js'; const logger = createLogger('Setup'); -// In-memory storage reference (imported from common.ts pattern) -// We need to modify common.ts to export a deleteApiKey function -import { setApiKey } from '../common.js'; - /** * Remove an API key from the .env file */ @@ -61,16 +58,12 @@ export function createDeleteApiKeyHandler() { logger.info(`[Setup] Deleting API key for provider: ${provider}`); - // Map provider to env key name - const envKeyMap: Record = { - anthropic: 'ANTHROPIC_API_KEY', - }; - - const envKey = envKeyMap[provider]; + // Get env key from provider registry + const envKey = getProviderEnvKey(provider); if (!envKey) { res.status(400).json({ success: false, - error: `Unknown provider: ${provider}. Only anthropic is supported.`, + error: `Unknown provider: ${provider}. Supported providers: anthropic, zai.`, }); return; } diff --git a/apps/server/src/routes/setup/routes/store-api-key.ts b/apps/server/src/routes/setup/routes/store-api-key.ts index e77a697e8..aa94fb623 100644 --- a/apps/server/src/routes/setup/routes/store-api-key.ts +++ b/apps/server/src/routes/setup/routes/store-api-key.ts @@ -3,7 +3,14 @@ */ import type { Request, Response } from 'express'; -import { setApiKey, persistApiKeyToEnv, getErrorMessage, logError } from '../common.js'; +import { + setApiKey, + persistApiKeyToEnv, + getErrorMessage, + logError, + getProviderEnvKey, + isSupportedProvider, +} from '../common.js'; import { createLogger } from '@automaker/utils'; const logger = createLogger('Setup'); @@ -21,22 +28,23 @@ export function createStoreApiKeyHandler() { return; } - setApiKey(provider, apiKey); - - // Also set as environment variable and persist to .env - if (provider === 'anthropic' || provider === 'anthropic_oauth_token') { - // Both API key and OAuth token use ANTHROPIC_API_KEY - process.env.ANTHROPIC_API_KEY = apiKey; - await persistApiKeyToEnv('ANTHROPIC_API_KEY', apiKey); - logger.info('[Setup] Stored API key as ANTHROPIC_API_KEY'); - } else { + // Validate provider against registry + const envKey = getProviderEnvKey(provider); + if (!envKey) { res.status(400).json({ success: false, - error: `Unsupported provider: ${provider}. Only anthropic is supported.`, + error: `Unsupported provider: ${provider}. Supported providers: anthropic, zai.`, }); return; } + setApiKey(provider, apiKey); + + // Set as environment variable and persist to .env + process.env[envKey] = apiKey; + await persistApiKeyToEnv(envKey, apiKey); + logger.info(`[Setup] Stored API key as ${envKey}`); + res.json({ success: true }); } catch (error) { logError(error, 'Store API key failed'); diff --git a/apps/server/src/routes/setup/routes/verify-zai-auth.ts b/apps/server/src/routes/setup/routes/verify-zai-auth.ts new file mode 100644 index 000000000..8dbd54e38 --- /dev/null +++ b/apps/server/src/routes/setup/routes/verify-zai-auth.ts @@ -0,0 +1,158 @@ +/** + * POST /verify-zai-auth endpoint - Verify Z.ai authentication by running a test query + * + * Response format: + * - success: true if the endpoint executed without errors (HTTP 200) + * - authenticated: true if the Z.ai API key is valid and has sufficient credits + * - error: optional error message for display to the user + * + * Note: `success` indicates the HTTP request succeeded, while `authenticated` + * indicates whether the API key is valid. A response may have success=true but + * authenticated=false if the key is invalid or has billing issues. + */ + +import type { Request, Response } from 'express'; +import type { ProviderMessage, ContentBlock } from '../../../providers/types.js'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; +import { createLogger } from '@automaker/utils'; +import { getApiKey } from '../common.js'; +import rateLimit from 'express-rate-limit'; + +const logger = createLogger('Setup'); + +// Rate limit Zai auth verification to prevent abuse +export const zaiAuthRateLimit = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // Maximum 5 attempts + message: { + error: 'Too many verification attempts. Please try again in 15 minutes.', + }, + standardHeaders: true, // Return rate limit info in headers + legacyHeaders: false, + skipSuccessfulRequests: true, // Don't count successful verifications +}); + +const AUTH_ERROR_PATTERNS = ['unauthorized', 'invalid api key', 'invalid key', 'forbidden']; +const BILLING_ERROR_PATTERNS = ['insufficient balance', 'insufficient quota', 'credit']; +const RATE_LIMIT_PATTERNS = ['rate limit', 'limit reached', 'too many requests']; + +function includesAny(text: string, patterns: string[]): boolean { + const lower = text.toLowerCase(); + return patterns.some((pattern) => lower.includes(pattern.toLowerCase())); +} + +/** + * Detect Z.ai API errors from response text and return user-friendly message + * @returns Error message or null if no error detected + */ +function detectZaiError(text: string): string | null { + if (includesAny(text, BILLING_ERROR_PATTERNS)) { + return 'Credit balance is too low. Please add credits to your Z.ai account.'; + } + if (includesAny(text, RATE_LIMIT_PATTERNS)) { + return 'Rate limit reached. Please try again later.'; + } + if (includesAny(text, AUTH_ERROR_PATTERNS)) { + return 'API key is invalid or has been revoked.'; + } + return null; +} + +export function createVerifyZaiAuthHandler() { + return async (req: Request, res: Response): Promise => { + const { apiKey: apiKeyFromBody } = req.body as { apiKey?: string }; + // Priority: body key (being tested) > environment variable (source of truth) > in-memory cache (fallback) + const apiKey = apiKeyFromBody || process.env.ZAI_API_KEY || getApiKey('zai'); + + if (!apiKey) { + res.json({ + success: true, + authenticated: false, + error: 'No Z.ai API key configured. Please enter an API key first.', + }); + return; + } + + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), 30000); + + let receivedContent = false; + let errorMessage = ''; + + try { + const provider = ProviderFactory.getProviderForModel('glm-4.7', { apiKey }); + const stream = provider.executeQuery({ + prompt: "Reply with only the word 'ok'", + model: 'glm-4.7', + cwd: process.cwd(), + maxTurns: 1, + allowedTools: [], + abortController, + }); + + for await (const msg of stream) { + // Check for error message + if (msg.type === 'error' && msg.error) { + errorMessage = msg.error; + break; + } + + // Process assistant message content + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + // Check for API errors in response text + const error = detectZaiError(block.text); + if (error) { + errorMessage = error; + break; + } + receivedContent = true; + } else if (block.type === 'thinking' && block.thinking) { + // Check for API errors in thinking content as well + const error = detectZaiError(block.thinking); + if (error) { + errorMessage = error; + break; + } + receivedContent = true; + } + } + if (errorMessage) break; + } + + // Result message indicates successful completion + if (msg.type === 'result') { + // Stream completed successfully + break; + } + } + + // Determine final authentication state + if (!errorMessage && !receivedContent) { + errorMessage = 'No response received from Z.ai. Please check your API key.'; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('[Setup] Z.ai auth verification error:', message); + + // Detect error type from exception message + const detectedError = detectZaiError(message); + if (detectedError) { + errorMessage = detectedError; + } else if (message.toLowerCase().includes('abort')) { + errorMessage = 'Verification timed out. Please try again.'; + } else { + errorMessage = message || 'Authentication failed'; + } + } finally { + clearTimeout(timeoutId); + } + + res.json({ + success: true, + authenticated: !errorMessage && receivedContent, + error: errorMessage || undefined, + }); + }; +} diff --git a/apps/server/src/routes/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts index 2af01a424..a8099b612 100644 --- a/apps/server/src/routes/suggestions/generate-suggestions.ts +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -2,15 +2,14 @@ * Business logic for generating suggestions */ -import { query } from '@anthropic-ai/claude-agent-sdk'; import type { EventEmitter } from '../../lib/events.js'; import { createLogger } from '@automaker/utils'; -import { createSuggestionsOptions } from '../../lib/sdk-options.js'; 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 } from '../../lib/settings-helpers.js'; +import { executeProviderQuery } from '../../lib/provider-query.js'; const logger = createLogger('Suggestions'); @@ -164,22 +163,26 @@ The response will be automatically formatted as structured JSON.`; '[Suggestions]' ); - const options = createSuggestionsOptions({ + // Get API keys for provider authentication + const apiKeys = settingsService ? await settingsService.getApiKeys() : undefined; + + const stream = executeProviderQuery({ cwd: projectPath, + prompt, + useCase: 'suggestions', abortController, autoLoadClaudeMd, + apiKeys, outputFormat: { type: 'json_schema', schema: suggestionsSchema, }, }); - - const stream = query({ prompt, options }); let responseText = ''; let structuredOutput: { suggestions: Array> } | null = null; for await (const msg of stream) { - if (msg.type === 'assistant' && msg.message.content) { + if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { if (block.type === 'text') { responseText += block.text; diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 5afddcd96..96acb1c64 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -18,11 +18,13 @@ import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options. import { PathNotAllowedError } from '@automaker/platform'; import type { SettingsService } from './settings-service.js'; import { getAutoLoadClaudeMdSetting, filterClaudeMdFromContext } from '../lib/settings-helpers.js'; +import { resolveModelWithProviderAvailability } from '@automaker/model-resolver'; interface Message { id: string; role: 'user' | 'assistant'; content: string; + thinking?: string; // Extended thinking from AI provider images?: Array<{ data: string; mimeType: string; @@ -242,12 +244,65 @@ export class AgentService { }); // Extract model, maxTurns, and allowedTools from SDK options - const effectiveModel = sdkOptions.model!; + let effectiveModel = sdkOptions.model!; const maxTurns = sdkOptions.maxTurns; const allowedTools = sdkOptions.allowedTools as string[] | undefined; - // Get provider for this model - const provider = ProviderFactory.getProviderForModel(effectiveModel); + // Load credentials from SettingsService for API key + let apiKey: string | undefined; + if (this.settingsService) { + try { + const credentials = await this.settingsService.getCredentials(); + const lowerModel = effectiveModel.toLowerCase(); + + // Determine the appropriate API key for this model + if (lowerModel.startsWith('glm-') || lowerModel === 'glm') { + apiKey = credentials.apiKeys?.zai; + } else if ( + lowerModel.startsWith('claude-') || + ['haiku', 'sonnet', 'opus'].includes(lowerModel) + ) { + apiKey = credentials.apiKeys?.anthropic; + } + + if (apiKey) { + console.log(`[AgentService] Loaded API key from settings for model: ${effectiveModel}`); + } + } catch (error) { + console.warn(`[AgentService] Failed to load credentials:`, error); + } + } + + // Resolve model considering enabled providers + if (this.settingsService) { + try { + const globalSettings = await this.settingsService.getGlobalSettings(); + if (globalSettings.enabledProviders) { + const originalModel = effectiveModel; + const resolvedModel = resolveModelWithProviderAvailability( + effectiveModel, + globalSettings.enabledProviders, + model // Use explicit model as fallback if all providers disabled + ); + if (resolvedModel !== effectiveModel) { + console.log( + `[AgentService] Model substituted due to provider availability: ${effectiveModel} -> ${resolvedModel}` + ); + } + // Update effectiveModel for use in provider selection and execution + effectiveModel = resolvedModel; + sdkOptions.model = resolvedModel; + } + } catch (error) { + console.warn(`[AgentService] Failed to load enabled providers:`, error); + } + } + + // Get provider for this model with API key + const provider = ProviderFactory.getProviderForModel( + effectiveModel, + apiKey ? { apiKey } : undefined + ); console.log( `[AgentService] Using provider "${provider.getName()}" for model "${effectiveModel}"` @@ -318,6 +373,29 @@ export class AgentService { content: responseText, isComplete: false, }); + } else if (block.type === 'thinking' && block.thinking) { + // Handle extended thinking content from providers (Zai, Claude) + if (!currentAssistantMessage) { + currentAssistantMessage = { + id: this.generateId(), + role: 'assistant', + content: '', + thinking: block.thinking, + timestamp: new Date().toISOString(), + }; + session.messages.push(currentAssistantMessage); + } else { + currentAssistantMessage.thinking = block.thinking; + } + + // Emit thinking update to UI + this.emitAgentEvent(sessionId, { + type: 'stream', + messageId: currentAssistantMessage.id, + content: responseText, + thinking: block.thinking, + isComplete: false, + }); } else if (block.type === 'tool_use') { const toolUse = { name: block.name || 'unknown', diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index bcdb92a84..ed03be37d 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -17,7 +17,12 @@ import { classifyError, loadContextFiles, } from '@automaker/utils'; -import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; +import { + resolveModelString, + getModelForUseCase, + DEFAULT_MODELS, + resolveModelWithProviderAvailability, +} from '@automaker/model-resolver'; import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; import { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform'; import { exec } from 'child_process'; @@ -604,8 +609,9 @@ export class AutoModeService { typeof img === 'string' ? img : img.path ); - // Get model from feature - const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); + // Get model for auto mode - env var takes precedence over feature model + // This allows overriding models via AUTOMAKER_MODEL_AUTO + const model = getModelForUseCase('auto'); console.log(`[AutoMode] Executing feature ${featureId} with model: ${model} in ${workDir}`); // Run the agent with the feature's model and images @@ -1121,7 +1127,30 @@ Format your response as a structured markdown document.`; try { // Use default Claude model for analysis (can be overridden in the future) - const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude); + let analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude); + + // Resolve model considering enabled providers + if (this.settingsService) { + try { + const globalSettings = await this.settingsService.getGlobalSettings(); + if (globalSettings.enabledProviders) { + const resolvedModel = resolveModelWithProviderAvailability( + analysisModel, + globalSettings.enabledProviders, + undefined // No explicit model to fall back to + ); + if (resolvedModel !== analysisModel) { + console.log( + `[AutoMode] Analysis model substituted due to provider availability: ${analysisModel} -> ${resolvedModel}` + ); + } + analysisModel = resolvedModel; + } + } catch (error) { + console.warn(`[AutoMode] Failed to load enabled providers for analysis:`, error); + } + } + const provider = ProviderFactory.getProviderForModel(analysisModel); // Load autoLoadClaudeMd setting @@ -1842,16 +1871,67 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. }); // Extract model, maxTurns, and allowedTools from SDK options - const finalModel = sdkOptions.model!; + let finalModel = sdkOptions.model!; const maxTurns = sdkOptions.maxTurns; const allowedTools = sdkOptions.allowedTools as string[] | undefined; + // Resolve model considering enabled providers + if (this.settingsService) { + try { + const globalSettings = await this.settingsService.getGlobalSettings(); + if (globalSettings.enabledProviders) { + const originalModel = finalModel; + const resolvedModel = resolveModelWithProviderAvailability( + finalModel, + globalSettings.enabledProviders, + model // Use explicit model as fallback if all providers disabled + ); + if (resolvedModel !== finalModel) { + console.log( + `[AutoMode] Model substituted due to provider availability: ${finalModel} -> ${resolvedModel}` + ); + } + finalModel = resolvedModel; + } + } catch (error) { + console.warn(`[AutoMode] Failed to load enabled providers:`, error); + } + } + console.log( `[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}, planningMode: ${planningMode}, requiresApproval: ${requiresApproval}` ); - // Get provider for this model - const provider = ProviderFactory.getProviderForModel(finalModel); + // Load credentials from SettingsService + let apiKey: string | undefined; + if (this.settingsService) { + try { + const credentials = await this.settingsService.getCredentials(); + const lowerModel = finalModel.toLowerCase(); + + // Determine the appropriate API key for this model + if (lowerModel.startsWith('glm-') || lowerModel === 'glm') { + apiKey = credentials.apiKeys?.zai; + } else if ( + lowerModel.startsWith('claude-') || + ['haiku', 'sonnet', 'opus'].includes(lowerModel) + ) { + apiKey = credentials.apiKeys?.anthropic; + } + + if (apiKey) { + console.log(`[AutoMode] Loaded API key from settings for model: ${finalModel}`); + } + } catch (error) { + console.warn(`[AutoMode] Failed to load credentials:`, error); + } + } + + // Get provider for this model with API key + const provider = ProviderFactory.getProviderForModel( + finalModel, + apiKey ? { apiKey } : undefined + ); console.log(`[AutoMode] Using provider "${provider.getName()}" for model "${finalModel}"`); diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 288bde186..2993d3e48 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -260,6 +260,9 @@ export class SettingsService { */ async getMaskedCredentials(): Promise<{ anthropic: { configured: boolean; masked: string }; + google: { configured: boolean; masked: string }; + openai: { configured: boolean; masked: string }; + zai: { configured: boolean; masked: string }; }> { const credentials = await this.getCredentials(); @@ -273,6 +276,18 @@ export class SettingsService { configured: !!credentials.apiKeys.anthropic, masked: maskKey(credentials.apiKeys.anthropic), }, + google: { + configured: !!credentials.apiKeys.google, + masked: maskKey(credentials.apiKeys.google), + }, + openai: { + configured: !!credentials.apiKeys.openai, + masked: maskKey(credentials.apiKeys.openai), + }, + zai: { + configured: !!credentials.apiKeys.zai, + masked: maskKey(credentials.apiKeys.zai), + }, }; } @@ -288,6 +303,19 @@ export class SettingsService { return fileExists(credentialsPath); } + /** + * Get API keys for provider authentication + * + * Convenience method that returns just the apiKeys portion of credentials. + * Used by routes to authenticate with AI providers. + * + * @returns Promise resolving to apiKeys object with provider credentials + */ + async getApiKeys(): Promise { + const credentials = await this.getCredentials(); + return credentials.apiKeys; + } + // ============================================================================ // Project Settings // ============================================================================ @@ -472,12 +500,14 @@ export class SettingsService { anthropic?: string; google?: string; openai?: string; + zai?: string; }; await this.updateCredentials({ apiKeys: { anthropic: apiKeys.anthropic || '', google: apiKeys.google || '', openai: apiKeys.openai || '', + zai: apiKeys.zai || '', }, }); migratedCredentials = true; diff --git a/apps/server/tests/unit/lib/provider-query.test.ts b/apps/server/tests/unit/lib/provider-query.test.ts new file mode 100644 index 000000000..27d3b29b0 --- /dev/null +++ b/apps/server/tests/unit/lib/provider-query.test.ts @@ -0,0 +1,354 @@ +/** + * Tests for provider-query utility + * Tests provider-agnostic query execution, structured output parsing, and JSON handling + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + buildStructuredOutputPrompt, + parseJsonFromText, + executeProviderQuery, +} from '../../../../src/lib/provider-query.js'; + +// Mock secureFs +vi.mock('../../../../src/services/secure-fs.js', () => ({ + default: { + readFile: vi.fn(), + writeFile: vi.fn(), + glob: vi.fn(), + }, +})); + +// Mock validatePath +vi.mock('../../../../src/libs/platform/src/security.js', () => ({ + validatePath: vi.fn(() => true), + isPathAllowed: vi.fn(() => true), + ALLOWED_ROOT_DIRECTORY: '/test', +})); + +// Mock provider-factory to allow importing provider-query +vi.mock('../../../../src/providers/provider-factory.js', () => ({ + ProviderFactory: { + getProviderForModel: vi.fn(), + registerProvider: vi.fn(), + unregisterProvider: vi.fn(), + getAllProviders: vi.fn(), + checkAllProviders: vi.fn(), + getProviderByName: vi.fn(), + getAllAvailableModels: vi.fn(), + }, +})); + +describe('provider-query', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('buildStructuredOutputPrompt', () => { + it('should append JSON instructions to the base prompt', () => { + const basePrompt = 'Generate a user profile'; + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name'], + }; + + const result = buildStructuredOutputPrompt(basePrompt, schema); + + expect(result).toContain(basePrompt); + expect(result).toContain('IMPORTANT: You must respond with valid JSON'); + expect(result).toContain('name: string (required)'); + expect(result).toContain('age: number'); + }); + + it('should describe nested object properties', () => { + const schema = { + type: 'object', + properties: { + address: { + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' }, + }, + }, + }, + }; + + const result = buildStructuredOutputPrompt('test', schema); + + expect(result).toContain('address:'); + expect(result).toContain('street:'); + expect(result).toContain('city:'); + }); + + it('should describe array items', () => { + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + }, + }, + }; + + const result = buildStructuredOutputPrompt('test', schema); + + expect(result).toContain('Array of:'); + }); + }); + + describe('parseJsonFromText - ReDoS-safe implementation', () => { + it('should parse pure JSON text', () => { + const text = '{"name": "test", "value": 123}'; + const result = parseJsonFromText(text); + + expect(result).toEqual({ name: 'test', value: 123 }); + }); + + it('should parse pure JSON array', () => { + const text = '[1, 2, 3]'; + const result = parseJsonFromText(text); + + expect(result).toEqual([1, 2, 3]); + }); + + it('should extract JSON from conversational text', () => { + const text = + 'Here is the result: {"name": "test", "value": 123}. Let me know if you need anything else.'; + const result = parseJsonFromText(text); + + expect(result).toEqual({ name: 'test', value: 123 }); + }); + + it('should extract JSON array from conversational text', () => { + const text = 'The items are: [1, 2, 3]. Enjoy!'; + const result = parseJsonFromText(text); + + expect(result).toEqual([1, 2, 3]); + }); + + it('should handle nested objects correctly', () => { + const text = '{"user": {"name": "John", "address": {"city": "NYC"}}}'; + const result = parseJsonFromText(text); + + expect(result).toEqual({ + user: { name: 'John', address: { city: 'NYC' } }, + }); + }); + + it('should handle nested arrays correctly', () => { + const text = '{"items": [[1, 2], [3, 4]]}'; + const result = parseJsonFromText(text); + + expect(result).toEqual({ + items: [ + [1, 2], + [3, 4], + ], + }); + }); + + it('should handle strings with escaped quotes', () => { + const text = '{"message": "He said \\"hello\\""}'; + const result = parseJsonFromText(text); + + expect(result).toEqual({ message: 'He said "hello"' }); + }); + + it('should return null for invalid JSON', () => { + const text = 'This is not JSON at all'; + const result = parseJsonFromText(text); + + expect(result).toBeNull(); + }); + + it('should return null for malformed JSON', () => { + const text = '{"name": "test", }'; + const result = parseJsonFromText(text); + + expect(result).toBeNull(); + }); + + it('should handle unbalanced braces gracefully', () => { + const text = '{"name": "test"'; + const result = parseJsonFromText(text); + + expect(result).toBeNull(); + }); + + it('should validate against schema when provided', () => { + const text = '{"name": "test", "age": 25}'; + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name'], + }; + + const result = parseJsonFromText(text, schema); + + expect(result).toEqual({ name: 'test', age: 25 }); + }); + + it('should return null when schema validation fails', () => { + const text = '{"name": "test"}'; // Missing required 'age' + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name', 'age'], + }; + + const result = parseJsonFromText(text, schema); + + expect(result).toBeNull(); + }); + + it('should validate enum values', () => { + const text = '{"status": "active"}'; + const schema = { + type: 'object', + properties: { + status: { type: 'string', enum: ['active', 'inactive'] }, + }, + }; + + const result = parseJsonFromText(text, schema); + + expect(result).toEqual({ status: 'active' }); + }); + + it('should reject invalid enum values', () => { + const text = '{"status": "pending"}'; + const schema = { + type: 'object', + properties: { + status: { type: 'string', enum: ['active', 'inactive'] }, + }, + }; + + const result = parseJsonFromText(text, schema); + + expect(result).toBeNull(); + }); + + // ReDoS resistance test - this should complete quickly + it('should be resistant to ReDoS attacks', () => { + // This input could cause catastrophic backtracking with naive regex + const text = '{"data": "' + '{'.repeat(100) + 'test' + '}'.repeat(100) + '"}'; + const start = Date.now(); + const result = parseJsonFromText(text); + const duration = Date.now() - start; + + // Should complete in reasonable time (< 1 second) + expect(duration).toBeLessThan(1000); + // Result may be null due to malformed JSON, but shouldn't hang + }); + }); + + // Integration tests for executeProviderQuery + describe('executeProviderQuery', () => { + // Create a mock provider + const createMockProvider = (name: string) => ({ + getName: () => name, + executeQuery: vi.fn(async function* () { + yield { + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Test response' }] }, + }; + yield { type: 'result' }; + }), + }); + + beforeEach(async () => { + const { ProviderFactory } = await import('../../../../src/providers/provider-factory.js'); + vi.mocked(ProviderFactory.getProviderForModel).mockImplementation((model: string) => { + if (model.startsWith('glm') || model === 'glm') { + return createMockProvider('zai'); + } + return createMockProvider('claude'); + }); + }); + + it('should route to Zai provider for glm models', async () => { + const results: unknown[] = []; + for await (const result of executeProviderQuery({ + prompt: 'Test prompt', + model: 'glm-4.7', + cwd: '/test', + apiKeys: { zai: 'test-key', anthropic: 'test-key' }, + })) { + results.push(result); + } + + // Should receive messages from the provider + expect(results.length).toBeGreaterThan(0); + expect(results[0]).toHaveProperty('type'); + }); + + it('should route to Claude provider for claude models', async () => { + const results: unknown[] = []; + for await (const result of executeProviderQuery({ + prompt: 'Test prompt', + model: 'claude-opus-4-5-20251101', + cwd: '/test', + apiKeys: { zai: 'test-key', anthropic: 'test-key' }, + })) { + results.push(result); + } + + // Should receive messages from the provider + expect(results.length).toBeGreaterThan(0); + }); + + it('should add structured output prompt for non-Claude providers', async () => { + // Create a mock provider that returns the prompt + let capturedPrompt = ''; + const mockZaiProvider = { + getName: () => 'zai', + executeQuery: vi.fn(async function* (options: { prompt: string }) { + capturedPrompt = options.prompt; + yield { + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: '{"result": "test"}' }] }, + }; + yield { type: 'result' }; + }), + }; + + const { ProviderFactory } = await import('../../../../src/providers/provider-factory.js'); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockZaiProvider as never); + + const results: unknown[] = []; + for await (const result of executeProviderQuery({ + prompt: 'Generate a user profile', + model: 'glm-4.7', + cwd: '/test', + outputFormat: { + type: 'json_schema', + schema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }, + apiKeys: { zai: 'test-key' }, + })) { + results.push(result); + } + + // Verify that structured output instructions were added to the prompt + expect(capturedPrompt).toContain('IMPORTANT: You must respond with valid JSON'); + expect(capturedPrompt).toContain('name: string (required)'); + }); + }); +}); diff --git a/apps/server/tests/unit/providers/provider-factory.test.ts b/apps/server/tests/unit/providers/provider-factory.test.ts index 069fbf860..3b8ef223f 100644 --- a/apps/server/tests/unit/providers/provider-factory.test.ts +++ b/apps/server/tests/unit/providers/provider-factory.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ProviderFactory } from '@/providers/provider-factory.js'; import { ClaudeProvider } from '@/providers/claude-provider.js'; +import { ZaiProvider } from '@/providers/zai-provider.js'; describe('provider-factory.ts', () => { let consoleSpy: any; @@ -65,6 +66,45 @@ describe('provider-factory.ts', () => { }); }); + describe('Zai GLM models (glm-* prefix)', () => { + it('should return ZaiProvider for glm-4.7', () => { + const provider = ProviderFactory.getProviderForModel('glm-4.7'); + expect(provider).toBeInstanceOf(ZaiProvider); + }); + + it('should return ZaiProvider for glm-4.6v', () => { + const provider = ProviderFactory.getProviderForModel('glm-4.6v'); + expect(provider).toBeInstanceOf(ZaiProvider); + }); + + it('should return ZaiProvider for glm-4.6', () => { + const provider = ProviderFactory.getProviderForModel('glm-4.6'); + expect(provider).toBeInstanceOf(ZaiProvider); + }); + + it('should return ZaiProvider for glm-4.5-air', () => { + const provider = ProviderFactory.getProviderForModel('glm-4.5-air'); + expect(provider).toBeInstanceOf(ZaiProvider); + }); + + it('should be case-insensitive for glm models', () => { + const provider = ProviderFactory.getProviderForModel('GLM-4.7'); + expect(provider).toBeInstanceOf(ZaiProvider); + }); + }); + + describe('Zai GLM aliases', () => { + it("should return ZaiProvider for 'glm' alias", () => { + const provider = ProviderFactory.getProviderForModel('glm'); + expect(provider).toBeInstanceOf(ZaiProvider); + }); + + it('should be case-insensitive for glm alias', () => { + const provider = ProviderFactory.getProviderForModel('GLM'); + expect(provider).toBeInstanceOf(ZaiProvider); + }); + }); + describe('Unknown models', () => { it('should default to ClaudeProvider for unknown model', () => { const provider = ProviderFactory.getProviderForModel('unknown-model-123'); @@ -73,9 +113,7 @@ describe('provider-factory.ts', () => { it('should warn when defaulting to Claude', () => { ProviderFactory.getProviderForModel('random-model'); - expect(consoleSpy.warn).toHaveBeenCalledWith( - expect.stringContaining('Unknown model prefix') - ); + expect(consoleSpy.warn).toHaveBeenCalledWith(expect.stringContaining('Unknown model')); expect(consoleSpy.warn).toHaveBeenCalledWith(expect.stringContaining('random-model')); expect(consoleSpy.warn).toHaveBeenCalledWith( expect.stringContaining('defaulting to Claude') @@ -114,9 +152,15 @@ describe('provider-factory.ts', () => { expect(hasClaudeProvider).toBe(true); }); - it('should return exactly 1 provider', () => { + it('should include ZaiProvider', () => { + const providers = ProviderFactory.getAllProviders(); + const hasZaiProvider = providers.some((p) => p instanceof ZaiProvider); + expect(hasZaiProvider).toBe(true); + }); + + it('should return exactly 2 providers', () => { const providers = ProviderFactory.getAllProviders(); - expect(providers).toHaveLength(1); + expect(providers).toHaveLength(2); }); it('should create new instances each time', () => { @@ -124,6 +168,7 @@ describe('provider-factory.ts', () => { const providers2 = ProviderFactory.getAllProviders(); expect(providers1[0]).not.toBe(providers2[0]); + expect(providers1[1]).not.toBe(providers2[1]); }); }); @@ -132,12 +177,14 @@ describe('provider-factory.ts', () => { const statuses = await ProviderFactory.checkAllProviders(); expect(statuses).toHaveProperty('claude'); + expect(statuses).toHaveProperty('zai'); }); it('should call detectInstallation on each provider', async () => { const statuses = await ProviderFactory.checkAllProviders(); expect(statuses.claude).toHaveProperty('installed'); + expect(statuses.zai).toHaveProperty('installed'); }); it('should return correct provider names as keys', async () => { @@ -145,7 +192,8 @@ describe('provider-factory.ts', () => { const keys = Object.keys(statuses); expect(keys).toContain('claude'); - expect(keys).toHaveLength(1); + expect(keys).toContain('zai'); + expect(keys).toHaveLength(2); }); }); @@ -160,12 +208,19 @@ describe('provider-factory.ts', () => { expect(provider).toBeInstanceOf(ClaudeProvider); }); + it("should return ZaiProvider for 'zai'", () => { + const provider = ProviderFactory.getProviderByName('zai'); + expect(provider).toBeInstanceOf(ZaiProvider); + }); + it('should be case-insensitive', () => { const provider1 = ProviderFactory.getProviderByName('CLAUDE'); const provider2 = ProviderFactory.getProviderByName('ANTHROPIC'); + const provider3 = ProviderFactory.getProviderByName('ZAI'); expect(provider1).toBeInstanceOf(ClaudeProvider); expect(provider2).toBeInstanceOf(ClaudeProvider); + expect(provider3).toBeInstanceOf(ZaiProvider); }); it('should return null for unknown provider', () => { @@ -218,5 +273,24 @@ describe('provider-factory.ts', () => { expect(hasClaudeModels).toBe(true); }); + + it('should include GLM models', () => { + const models = ProviderFactory.getAllAvailableModels(); + + // GLM models should include glm- in their IDs + const hasGlmModels = models.some((m) => m.id.toLowerCase().includes('glm')); + + expect(hasGlmModels).toBe(true); + }); + + it('should include models from both Claude and Zai providers', () => { + const models = ProviderFactory.getAllAvailableModels(); + + const claudeModels = models.filter((m) => m.provider === 'claude'); + const zaiModels = models.filter((m) => m.provider === 'zai'); + + expect(claudeModels.length).toBeGreaterThan(0); + expect(zaiModels.length).toBeGreaterThan(0); + }); }); }); diff --git a/apps/server/tests/unit/providers/zai-provider.test.ts b/apps/server/tests/unit/providers/zai-provider.test.ts new file mode 100644 index 000000000..bfa0226f3 --- /dev/null +++ b/apps/server/tests/unit/providers/zai-provider.test.ts @@ -0,0 +1,986 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ZaiProvider } from '@/providers/zai-provider.js'; +import { collectAsyncGenerator } from '../../utils/helpers.js'; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch as unknown as typeof fetch; + +// Proper type definitions for test results +interface StreamingTestResult { + type: 'assistant' | 'user' | 'error' | 'result'; + subtype?: 'success' | 'error'; + session_id?: string; + message?: { + role: 'user' | 'assistant'; + content: Array<{ type: string; text?: string; name?: string; input?: unknown }>; + }; + result?: string; + error?: string; + parent_tool_use_id?: string | null; +} + +// Mock secureFs +vi.mock('@automaker/platform', () => ({ + secureFs: { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + }, + validatePath: vi.fn((path: string) => path), + isPathAllowed: vi.fn(() => true), +})); + +/** + * Helper to create a mock streaming response + * Simulates Server-Sent Events (SSE) format from streaming APIs + * Creates a fresh stream on each call + */ +function createMockStreamResponse(chunks: string[]): Response { + const encoder = new TextEncoder(); + + const readableStream = new ReadableStream({ + async start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + // Small delay to simulate real streaming + await new Promise((resolve) => setTimeout(resolve, 1)); + } + controller.close(); + }, + }); + + return { + ok: true, + status: 200, + body: readableStream, + } as Response; +} + +describe('zai-provider.ts', () => { + let provider: ZaiProvider; + + beforeEach(() => { + mockFetch.mockReset(); + mockFetch.mockClear(); + provider = new ZaiProvider({ apiKey: 'test-zai-key' }); + delete process.env.ZAI_API_KEY; + }); + + describe('getName', () => { + it("should return 'zai' as provider name", () => { + expect(provider.getName()).toBe('zai'); + }); + }); + + describe('executeQuery', () => { + it('should execute simple text query with streaming', async () => { + // Mock streaming SSE response + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"! How can I help you today?"}}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Hello', + cwd: '/test', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + expect(mockFetch).toHaveBeenCalled(); + expect(results.length).toBeGreaterThan(0); + + const textResult = results.find((r: StreamingTestResult) => r.type === 'assistant'); + expect(textResult).toBeDefined(); + }); + + it('should pass correct model to API', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"content":"Response"}}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Test prompt', + cwd: '/test/dir', + model: 'glm-4.6', + }); + + await collectAsyncGenerator(generator); + + const fetchCall = mockFetch.mock.calls[0]; + const body = JSON.parse(fetchCall[1].body); + expect(body.model).toBe('glm-4.6'); + expect(body.stream).toBe(true); + }); + + it('should include system prompt if provided', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"content":"Response"}}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + model: 'glm-4.7', + systemPrompt: 'You are a helpful assistant', + }); + + await collectAsyncGenerator(generator); + + const fetchCall = mockFetch.mock.calls[0]; + const body = JSON.parse(fetchCall[1].body); + expect(body.messages[0].role).toBe('system'); + expect(body.messages[0].content).toContain('You are a helpful assistant'); + }); + + it('should handle thinking content from thinking mode', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"reasoning_content":"Let me think about this..."}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"Here is my response"}}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Solve this problem', + cwd: '/test', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should have thinking block + const thinkingResult = results.find( + (r: StreamingTestResult) => + r.type === 'assistant' && r.message?.content?.[0]?.type === 'thinking' + ); + expect(thinkingResult).toBeDefined(); + + // Should have text block + const textResult = results.find( + (r: StreamingTestResult) => + r.type === 'assistant' && r.message?.content?.[0]?.type === 'text' + ); + expect(textResult).toBeDefined(); + }); + + it('should handle tool calls', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","function":{"name":"read_file","arguments":"{\\"filePath\\": \\"test.txt\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const { secureFs } = await import('@automaker/platform'); + vi.mocked(secureFs.readFile).mockResolvedValue('file content'); + + const generator = provider.executeQuery({ + prompt: 'Read test.txt', + cwd: '/test', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should have tool_use message + const toolUseResult = results.find( + (r: StreamingTestResult) => + r.type === 'assistant' && r.message?.content?.[0]?.type === 'tool_use' + ); + expect(toolUseResult).toBeDefined(); + expect((toolUseResult as any).message?.content?.[0]?.name).toBe('read_file'); + + // Should have result message + const resultMsg = results.find((r: StreamingTestResult) => r.type === 'result'); + expect(resultMsg).toBeDefined(); + }); + + it('should filter tools based on allowedTools', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"content":"Response"}}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + allowedTools: ['Read', 'Write'], + }); + + await collectAsyncGenerator(generator); + + const fetchCall = mockFetch.mock.calls[0]; + const body = JSON.parse(fetchCall[1].body); + expect(body.tools).toBeDefined(); + expect(body.tools.length).toBe(2); + expect(body.tools[0].function.name).toBe('read_file'); + expect(body.tools[1].function.name).toBe('write_file'); + }); + + it('should use sdkSessionId when provided', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"content":"Response"}}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + sdkSessionId: 'custom-session-123', + }); + + const results = await collectAsyncGenerator(generator); + + // Should use the provided session ID + const sessionResult = results.find( + (r: StreamingTestResult) => r.session_id === 'custom-session-123' + ); + expect(sessionResult).toBeDefined(); + }); + + it('should handle structured output requests', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"content":"{\\"result\\": \\"value\\"}"}}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Generate JSON', + cwd: '/test', + outputFormat: { + type: 'json_schema', + schema: { + type: 'object', + properties: { result: { type: 'string' } }, + }, + }, + }); + + await collectAsyncGenerator(generator); + + const fetchCall = mockFetch.mock.calls[0]; + const body = JSON.parse(fetchCall[1].body); + expect(body.response_format).toEqual({ type: 'json_object' }); + }); + + it('should handle API errors', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + text: async () => 'Unauthorized', + }); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }); + + await expect(collectAsyncGenerator(generator)).rejects.toThrow(); + }); + + it('should respect abortController', async () => { + const abortController = new AbortController(); + abortController.abort(); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + abortController, + }); + + const results = await collectAsyncGenerator(generator); + + // Should yield error and stop + const errorResult = results.find((r: StreamingTestResult) => r.type === 'error'); + expect(errorResult).toBeDefined(); + expect((errorResult as any).error).toContain('aborted'); + }); + }); + + describe('detectInstallation', () => { + it('should return installed with SDK method', async () => { + mockFetch.mockResolvedValue({ + ok: true, + }); + + const result = await provider.detectInstallation(); + + expect(result.installed).toBe(true); + expect(result.method).toBe('sdk'); + }); + + it('should detect ZAI_API_KEY', async () => { + mockFetch.mockResolvedValue({ + ok: true, + }); + + provider.setConfig({ apiKey: 'test-key' }); + const result = await provider.detectInstallation(); + + expect(result.hasApiKey).toBe(true); + expect(result.authenticated).toBe(true); + }); + + it('should return hasApiKey false when no key present', async () => { + const noKeyProvider = new ZaiProvider(); + const result = await noKeyProvider.detectInstallation(); + + expect(result.hasApiKey).toBe(false); + expect(result.authenticated).toBe(false); + }); + + it('should return authenticated false on API error', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + }); + + provider.setConfig({ apiKey: 'invalid-key' }); + const result = await provider.detectInstallation(); + + expect(result.hasApiKey).toBe(true); + expect(result.authenticated).toBe(false); + }); + + it('should return authenticated true on network error (forgiving)', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + provider.setConfig({ apiKey: 'test-key' }); + const result = await provider.detectInstallation(); + + expect(result.hasApiKey).toBe(true); + expect(result.authenticated).toBe(true); // Forgiving on network errors + }); + }); + + describe('getAvailableModels', () => { + it('should return 4 GLM models', () => { + const models = provider.getAvailableModels(); + + expect(models).toHaveLength(4); + }); + + it('should include GLM-4.7', () => { + const models = provider.getAvailableModels(); + + const model = models.find((m) => m.id === 'glm-4.7'); + expect(model).toBeDefined(); + expect(model?.name).toBe('GLM-4.7'); + expect(model?.provider).toBe('zai'); + }); + + it('should include GLM-4.6v with vision support', () => { + const models = provider.getAvailableModels(); + + const model = models.find((m) => m.id === 'glm-4.6v'); + expect(model).toBeDefined(); + expect(model?.name).toBe('GLM-4.6v'); + expect(model?.supportsVision).toBe(true); + }); + + it('should include GLM-4.6', () => { + const models = provider.getAvailableModels(); + + const model = models.find((m) => m.id === 'glm-4.6'); + expect(model).toBeDefined(); + expect(model?.name).toBe('GLM-4.6'); + }); + + it('should include GLM-4.5-Air', () => { + const models = provider.getAvailableModels(); + + const model = models.find((m) => m.id === 'glm-4.5-air'); + expect(model).toBeDefined(); + expect(model?.name).toBe('GLM-4.5-Air'); + }); + + it('should mark GLM-4.7 as default', () => { + const models = provider.getAvailableModels(); + + const model = models.find((m) => m.id === 'glm-4.7'); + expect(model?.default).toBe(true); + }); + + it('should all support tools and extended thinking', () => { + const models = provider.getAvailableModels(); + + models.forEach((model) => { + expect(model.supportsTools).toBe(true); + expect(model.supportsExtendedThinking).toBe(true); + }); + }); + + it('should have correct context windows', () => { + const models = provider.getAvailableModels(); + + // GLM-4.7 and GLM-4.6 have 200k context, GLM-4.6v and GLM-4.5-Air have 128k + const glm4_7 = models.find((m) => m.id === 'glm-4.7'); + const glm4_6 = models.find((m) => m.id === 'glm-4.6'); + const glm4_6v = models.find((m) => m.id === 'glm-4.6v'); + const glm4_5_air = models.find((m) => m.id === 'glm-4.5-air'); + + expect(glm4_7?.contextWindow).toBe(200000); + expect(glm4_6?.contextWindow).toBe(200000); + expect(glm4_6v?.contextWindow).toBe(128000); + expect(glm4_5_air?.contextWindow).toBe(128000); + }); + + it('should have modelString field matching id', () => { + const models = provider.getAvailableModels(); + + models.forEach((model) => { + expect(model.modelString).toBe(model.id); + }); + }); + }); + + describe('supportsFeature', () => { + it("should support 'tools' feature", () => { + expect(provider.supportsFeature('tools')).toBe(true); + }); + + it("should support 'text' feature", () => { + expect(provider.supportsFeature('text')).toBe(true); + }); + + it("should support 'vision' feature", () => { + expect(provider.supportsFeature('vision')).toBe(true); + }); + + it("should support 'extendedThinking' feature (Zai's thinking mode)", () => { + expect(provider.supportsFeature('extendedThinking')).toBe(true); + }); + + it("should not support 'mcp' feature", () => { + expect(provider.supportsFeature('mcp')).toBe(false); + }); + + it("should not support 'browser' feature", () => { + expect(provider.supportsFeature('browser')).toBe(false); + }); + + it('should not support unknown features', () => { + expect(provider.supportsFeature('unknown')).toBe(false); + }); + }); + + describe('validateConfig', () => { + it('should pass validation with API key', () => { + provider.setConfig({ apiKey: 'test-key' }); + const result = provider.validateConfig(); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should fail validation without API key', () => { + const testProvider = new ZaiProvider(); + const result = testProvider.validateConfig(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('ZAI_API_KEY not configured'); + }); + }); + + describe('config management', () => { + it('should get and set config', () => { + provider.setConfig({ apiKey: 'test-key' }); + + const config = provider.getConfig(); + expect(config.apiKey).toBe('test-key'); + }); + + it('should merge config updates', () => { + provider.setConfig({ apiKey: 'key1' }); + provider.setConfig({ model: 'model1' }); + + const config = provider.getConfig(); + expect(config.apiKey).toBe('key1'); + expect(config.model).toBe('model1'); + }); + }); + + describe('command sanitization security', () => { + beforeEach(async () => { + // Set ALLOWED_ROOT_DIRECTORY to allow test paths + process.env.ALLOWED_ROOT_DIRECTORY = '/test/project'; + + // Update the validatePath mock to throw for absolute paths outside root + const { validatePath } = await import('@automaker/platform'); + vi.mocked(validatePath).mockImplementation((path: string) => { + // Allow paths within /test/project (including Windows variants) + if ( + path.startsWith('/test/project') || + path.startsWith('\\test\\project') || + path.includes('test\\project') || + path.includes('test/project') + ) { + return path; + } + // Reject other absolute paths + if (path.startsWith('/') && !path.startsWith('/test')) { + throw new Error(`Path not allowed: ${path}`); + } + if (path.match(/^[A-Za-z]:/) && !path.includes('test')) { + throw new Error(`Path not allowed: ${path}`); + } + // Allow relative paths + return path; + }); + }); + + afterEach(() => { + delete process.env.ALLOWED_ROOT_DIRECTORY; + }); + + it('should reject commands with .. for path traversal', async () => { + // Note: This tests the internal sanitizeCommand function through tool execution + // In actual usage, commands with .. will be blocked + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","function":{"name":"execute_command","arguments":"{\\"command\\": \\"cat ../../../etc/passwd\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Read a file', + cwd: '/test/project', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should get an error about path traversal + const errorResult = results.find((r: StreamingTestResult) => r.type === 'error'); + expect(errorResult).toBeDefined(); + expect((errorResult as any).error).toContain('Path traversal'); + }); + + it('should reject absolute paths in command arguments', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","function":{"name":"execute_command","arguments":"{\\"command\\": \\"cat /etc/passwd\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Read a file', + cwd: '/test/project', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should get an error + const errorResult = results.find((r: StreamingTestResult) => r.type === 'error'); + expect(errorResult).toBeDefined(); + // The error could be from path validation or path traversal + expect((errorResult as any).error).toMatch(/Path|not allowed/); + }); + + it('should allow recursive flags like -r, -R, -a (no longer blocked)', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","function":{"name":"execute_command","arguments":"{\\"command\\": \\"rm -r test_dir\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Remove directory', + cwd: '/test/project', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should NOT have an error about recursive flag (no longer blocked) + const errorResult = results.find((r: StreamingTestResult) => r.type === 'error'); + expect(errorResult).toBeUndefined(); + }); + + it('should reject commands in blocklist (format)', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","function":{"name":"execute_command","arguments":"{\\"command\\": \\"format c:\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Format disk', + cwd: '/test/project', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should get an error about command being blocked + const errorResult = results.find((r: StreamingTestResult) => r.type === 'error'); + expect(errorResult).toBeDefined(); + expect((errorResult as any).error).toContain('blocked'); + }); + + it('should reject commands in blocklist (userdel)', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","function":{"name":"execute_command","arguments":"{\\"command\\": \\"userdel testuser\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Delete user', + cwd: '/test/project', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should get an error about command being blocked + const errorResult = results.find((r: StreamingTestResult) => r.type === 'error'); + expect(errorResult).toBeDefined(); + expect((errorResult as any).error).toContain('blocked for security'); + }); + + it('should reject dangerous pattern (rm -rf /)', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","function":{"name":"execute_command","arguments":"{\\"command\\": \\"rm -rf /\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Delete everything', + cwd: '/test/project', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should get an error about dangerous pattern + const errorResult = results.find((r: StreamingTestResult) => r.type === 'error'); + expect(errorResult).toBeDefined(); + expect((errorResult as any).error).toContain('Dangerous command pattern blocked'); + }); + + it('should allow previously blocked commands like wget', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","function":{"name":"execute_command","arguments":"{\\"command\\": \\"wget http://example.com\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Fetch URL', + cwd: '/test/project', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should NOT have an error (wget is no longer in a whitelist) + const errorResult = results.find((r: StreamingTestResult) => r.type === 'error'); + expect(errorResult).toBeUndefined(); + }); + + it('should allow safe mutating commands within project root', async () => { + // Mock successful file operations for mkdir/rm + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","function":{"name":"execute_command","arguments":"{\\"command\\": \\"mkdir test_dir\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Create directory', + cwd: '/test/project', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should have tool_use, no error from sanitization + const toolUseResult = results.find( + (r: StreamingTestResult) => + r.type === 'assistant' && r.message?.content?.[0]?.type === 'tool_use' + ); + expect(toolUseResult).toBeDefined(); + + // The tool result may have an error from actual execution (expected in test env) + // but sanitization should not block the command + const toolResult = results.find((r: StreamingTestResult) => r.type === 'result'); + expect(toolResult).toBeDefined(); + }); + + it('should allow rm command for files within project root', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","function":{"name":"execute_command","arguments":"{\\"command\\": \\"rm test_file.txt\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Remove file', + cwd: '/test/project', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should succeed without error + const errorResult = results.find((r: StreamingTestResult) => r.type === 'error'); + expect(errorResult).toBeUndefined(); + + const toolUseResult = results.find( + (r: StreamingTestResult) => + r.type === 'assistant' && r.message?.content?.[0]?.type === 'tool_use' + ); + expect(toolUseResult).toBeDefined(); + }); + }); + + describe('executeToolCall - write_file', () => { + it('should handle write_file tool call', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","function":{"name":"write_file","arguments":"{\\"path\\": \\"test.txt\\", \\"content\\": \\"hello world\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Write a file', + cwd: '/test/project', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should have tool_use message for write_file + const toolUseResult = results.find( + (r: StreamingTestResult) => + r.type === 'assistant' && + r.message?.content?.[0]?.type === 'tool_use' && + r.message?.content?.[0]?.name === 'write_file' + ); + expect(toolUseResult).toBeDefined(); + }); + }); + + describe('executeToolCall - edit_file', () => { + it('should handle edit_file tool call', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_456","function":{"name":"edit_file","arguments":"{\\"path\\": \\"test.txt\\", \\"edits\\": [{\\"oldText\\": \\"foo\\", \\"newText\\": \\"bar\\"}]}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Edit a file', + cwd: '/test/project', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should have tool_use message for edit_file + const toolUseResult = results.find( + (r: StreamingTestResult) => + r.type === 'assistant' && + r.message?.content?.[0]?.type === 'tool_use' && + r.message?.content?.[0]?.name === 'edit_file' + ); + expect(toolUseResult).toBeDefined(); + }); + }); + + describe('executeToolCall - glob_search', () => { + it('should handle glob_search tool call', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_789","function":{"name":"glob_search","arguments":"{\\"pattern\\": \\"**/*.ts\\", \\"path\\": \\".\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Find TypeScript files', + cwd: '/test/project', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should have tool_use message for glob_search + const toolUseResult = results.find( + (r: StreamingTestResult) => + r.type === 'assistant' && + r.message?.content?.[0]?.type === 'tool_use' && + r.message?.content?.[0]?.name === 'glob_search' + ); + expect(toolUseResult).toBeDefined(); + }); + }); + + describe('executeToolCall - grep_search', () => { + it('should handle grep_search tool call', async () => { + mockFetch.mockImplementation(() => + createMockStreamResponse([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_101","function":{"name":"grep_search","arguments":"{\\"pattern\\": \\"TODO\\", \\"path\\": \\".\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]) + ); + + const generator = provider.executeQuery({ + prompt: 'Search for TODO comments', + cwd: '/test/project', + model: 'glm-4.7', + }); + + const results = await collectAsyncGenerator(generator); + + // Should have tool_use message for grep_search + const toolUseResult = results.find( + (r: StreamingTestResult) => + r.type === 'assistant' && + r.message?.content?.[0]?.type === 'tool_use' && + r.message?.content?.[0]?.name === 'grep_search' + ); + expect(toolUseResult).toBeDefined(); + }); + }); + + describe('image handling', () => { + beforeEach(() => { + provider = new ZaiProvider({ apiKey: 'test-key' }); + }); + + it('should detect vision support correctly for glm-4.6v', () => { + // @ts-expect-error - testing private method + expect(provider.modelSupportsVision('glm-4.6v')).toBe(true); + // @ts-expect-error - testing private method + expect(provider.modelSupportsVision('glm-4.7')).toBe(false); + // @ts-expect-error - testing private method + expect(provider.modelSupportsVision('glm-4.6')).toBe(false); + // @ts-expect-error - testing private method + expect(provider.modelSupportsVision('glm-4.5-air')).toBe(false); + }); + + it('should reject file:// URLs in image sources', async () => { + const images = [ + { type: 'image', source: { type: 'base64', data: 'fake-base64-data' } }, + { type: 'image', source: { type: 'base64', data: 'file://malicious-path' } }, + ]; + + await expect( + // @ts-expect-error - testing private method + provider.describeImages(images, 'test prompt') + ).rejects.toThrow('file:// URLs are not supported'); + }); + + it('should reject non-base64 image source types', async () => { + const images = [ + { type: 'image', source: { type: 'url', data: 'http://example.com/image.png' } }, + ]; + + await expect( + // @ts-expect-error - testing private method + provider.describeImages(images, 'test prompt') + ).rejects.toThrow('Unsupported image source type: url'); + }); + + it('should handle empty images array gracefully', async () => { + // @ts-expect-error - testing private method + const result = await provider.describeImages([], 'test prompt'); + expect(result).toBe(''); + }); + + it('should include image context in prompt when images are provided', async () => { + const images = [ + { + type: 'image', + source: { + type: 'base64', + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + }, + }, + ]; + + // Mock successful vision API response + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + choices: [ + { + message: { + content: 'A 1x1 red pixel image.', + }, + }, + ], + }), + }); + + // @ts-expect-error - testing private method + const result = await provider.describeImages(images, 'What do you see?'); + + // Verify fetch was called + expect(mockFetch).toHaveBeenCalledTimes(1); + const fetchArgs = mockFetch.mock.calls[0]; + + // Verify URL is Zai API endpoint + expect(fetchArgs[0]).toContain('api.z.ai'); + + // Verify options object has correct method and contains model in body + expect(fetchArgs[1]).toMatchObject({ + method: 'POST', + }); + const bodyStr = fetchArgs[1]?.body; + expect(bodyStr).toContain('"model":"glm-4.6v"'); + + // Result should contain the image description (with prefix added by the method) + expect(result).toContain('A 1x1 red pixel image.'); + }); + }); +}); diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index 50380095d..85571772e 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'; import { RouterProvider } from '@tanstack/react-router'; import { router } from './utils/router'; import { SplashScreen } from './components/splash-screen'; -import { useSettingsMigration } from './hooks/use-settings-migration'; +import { useSettingsMigration, useEnabledProvidersLoader } from './hooks/use-settings-migration'; import './styles/global.css'; import './styles/theme-imports'; @@ -21,6 +21,9 @@ export default function App() { console.log('[App] Settings migrated to file storage'); } + // Load enabledProviders from backend on startup + useEnabledProvidersLoader(); + const handleSplashComplete = useCallback(() => { sessionStorage.setItem('automaker-splash-shown', 'true'); setShowSplash(false); diff --git a/apps/ui/src/components/views/agent-view.tsx b/apps/ui/src/components/views/agent-view.tsx index 950d77de5..1821c99c6 100644 --- a/apps/ui/src/components/views/agent-view.tsx +++ b/apps/ui/src/components/views/agent-view.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; -import { useAppStore, type AgentModel } from '@/store/app-store'; +import { useAppStore } from '@/store/app-store'; +import type { AgentModel } from '@automaker/types'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ImageDropZone } from '@/components/ui/image-drop-zone'; @@ -19,6 +20,8 @@ import { FileText, Square, ListOrdered, + Brain, + ChevronRight, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useElectronAgent } from '@/hooks/use-electron-agent'; @@ -50,10 +53,20 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants'; +import { + ALL_MODELS, + getFilteredModels, +} from '@/components/views/board-view/shared/model-constants'; +import { autoSwitchModelIfDisabled } from '@/utils/model-utils'; export function AgentView() { - const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore(); + const { + currentProject, + setLastSelectedSession, + getLastSelectedSession, + showReasoningByDefault, + enabledProviders, + } = useAppStore(); const shortcuts = useKeyboardShortcutsConfig(); const [input, setInput] = useState(''); const [selectedImages, setSelectedImages] = useState([]); @@ -440,6 +453,14 @@ export function AgentView() { } }, [currentSessionId]); + // Auto-switch model if current model's provider is disabled + useEffect(() => { + const newModel = autoSwitchModelIfDisabled(selectedModel, enabledProviders); + if (newModel !== selectedModel) { + setSelectedModel(newModel); + } + }, [enabledProviders]); + // Keyboard shortcuts for agent view const agentShortcuts: KeyboardShortcut[] = useMemo(() => { const shortcutsList: KeyboardShortcut[] = []; @@ -630,6 +651,23 @@ export function AgentView() { : 'bg-card border border-border' )} > + {message.role === 'assistant' && message.thinking && ( +
+
+ + Thinking +
+
+ + + {showReasoningByDefault ? 'Hide' : 'Show'} thinking + +
+ {message.thinking} +
+
+
+ )} {message.role === 'assistant' ? ( {message.content} @@ -923,15 +961,13 @@ export function AgentView() { className="h-11 gap-1 text-xs font-medium rounded-xl border-border px-2.5" data-testid="model-selector" > - {CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace( - 'Claude ', - '' - ) || 'Sonnet'} + {ALL_MODELS.find((m) => m.id === selectedModel)?.label.replace('Claude ', '') || + 'Sonnet'} - {CLAUDE_MODELS.map((model) => ( + {getFilteredModels(enabledProviders).map((model) => ( setSelectedModel(model.id)} diff --git a/apps/ui/src/components/views/board-view/shared/model-constants.ts b/apps/ui/src/components/views/board-view/shared/model-constants.ts index d08a9d21d..371a1bda8 100644 --- a/apps/ui/src/components/views/board-view/shared/model-constants.ts +++ b/apps/ui/src/components/views/board-view/shared/model-constants.ts @@ -6,7 +6,7 @@ export type ModelOption = { label: string; description: string; badge?: string; - provider: 'claude'; + provider: 'claude' | 'zai'; }; export const CLAUDE_MODELS: ModelOption[] = [ @@ -33,6 +33,54 @@ export const CLAUDE_MODELS: ModelOption[] = [ }, ]; +export const ZAI_MODELS: ModelOption[] = [ + { + id: 'glm-4.7', + label: 'GLM-4.7', + description: 'Flagship model with strong reasoning.', + badge: 'Premium', + provider: 'zai', + }, + { + id: 'glm-4.6v', + label: 'GLM-4.6v', + description: 'Multimodal model with vision support.', + badge: 'Vision', + provider: 'zai', + }, + { + id: 'glm-4.6', + label: 'GLM-4.6', + description: 'Balanced performance with good reasoning.', + badge: 'Balanced', + provider: 'zai', + }, + { + id: 'glm-4.5-air', + label: 'GLM-4.5-Air', + description: 'Fast and efficient for simple tasks.', + badge: 'Speed', + provider: 'zai', + }, +]; + +export const ALL_MODELS: ModelOption[] = [...CLAUDE_MODELS, ...ZAI_MODELS]; + +/** + * Filter models by enabled providers + * Returns only models from providers that are currently enabled + */ +export function getFilteredModels(enabledProviders: { + claude: boolean; + zai: boolean; +}): ModelOption[] { + return ALL_MODELS.filter( + (model) => + (model.provider === 'claude' && enabledProviders.claude) || + (model.provider === 'zai' && enabledProviders.zai) + ); +} + export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink']; export const THINKING_LEVEL_LABELS: Record = { diff --git a/apps/ui/src/components/views/board-view/shared/model-selector.tsx b/apps/ui/src/components/views/board-view/shared/model-selector.tsx index 55a0fe83b..e7fd0efda 100644 --- a/apps/ui/src/components/views/board-view/shared/model-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/model-selector.tsx @@ -1,8 +1,8 @@ import { Label } from '@/components/ui/label'; import { Brain } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { AgentModel } from '@/store/app-store'; -import { CLAUDE_MODELS, ModelOption } from './model-constants'; +import { AgentModel, useAppStore } from '@/store/app-store'; +import { getFilteredModels, ModelOption } from './model-constants'; interface ModelSelectorProps { selectedModel: AgentModel; @@ -15,21 +15,24 @@ export function ModelSelector({ onModelSelect, testIdPrefix = 'model-select', }: ModelSelectorProps) { + const enabledProviders = useAppStore((state) => state.enabledProviders); + const filteredModels = getFilteredModels(enabledProviders); + return (
Native
- {CLAUDE_MODELS.map((option) => { + {filteredModels.map((option) => { const isSelected = selectedModel === option.id; - const shortName = option.label.replace('Claude ', ''); + const shortName = option.label.replace('Claude ', '').replace('GLM-', 'GLM '); return (
- {profiles.slice(0, 6).map((profile) => { + {filteredProfiles.slice(0, 6).map((profile) => { const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain; const isSelected = selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel; diff --git a/apps/ui/src/components/views/profiles-view/components/profile-form.tsx b/apps/ui/src/components/views/profiles-view/components/profile-form.tsx index 5cc8c4b16..ec9ae725a 100644 --- a/apps/ui/src/components/views/profiles-view/components/profile-form.tsx +++ b/apps/ui/src/components/views/profiles-view/components/profile-form.tsx @@ -8,9 +8,12 @@ import { cn, modelSupportsThinking } from '@/lib/utils'; import { DialogFooter } from '@/components/ui/dialog'; import { Brain } from 'lucide-react'; import { toast } from 'sonner'; -import type { AIProfile, AgentModel, ThinkingLevel } from '@/store/app-store'; -import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants'; +import type { AIProfile, AgentModel, ThinkingLevel } from '@automaker/types'; +import { ALL_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants'; import { getProviderFromModel } from '../utils'; +import { useAppStore } from '@/store/app-store'; +import { autoSwitchModelIfDisabled } from '@/utils/model-utils'; +import { useEffect } from 'react'; interface ProfileFormProps { profile: Partial; @@ -27,6 +30,7 @@ export function ProfileForm({ isEditing, hotkeyActive, }: ProfileFormProps) { + const enabledProviders = useAppStore((s) => s.enabledProviders); const [formData, setFormData] = useState({ name: profile.name || '', description: profile.description || '', @@ -35,7 +39,21 @@ export function ProfileForm({ icon: profile.icon || 'Brain', }); + // Auto-switch model if current model's provider is disabled + useEffect(() => { + const newModel = autoSwitchModelIfDisabled(formData.model, enabledProviders); + if (newModel !== formData.model) { + setFormData({ ...formData, model: newModel }); + } + }, [enabledProviders]); + const provider = getProviderFromModel(formData.model); + + // Filter models based on enabled providers + const filteredModels = ALL_MODELS.filter((model) => { + const modelProvider = getProviderFromModel(model.id); + return modelProvider === 'claude' ? enabledProviders.claude : enabledProviders.zai; + }); const supportsThinking = modelSupportsThinking(formData.model); const handleModelChange = (model: AgentModel) => { @@ -120,7 +138,7 @@ export function ProfileForm({ Model
- {CLAUDE_MODELS.map(({ id, label }) => ( + {filteredModels.map(({ id, label }) => (
+
- {/* API Key Fields */} - {providerConfigs.map((provider) => ( - - ))} + {/* Provider Cards */} +
+ {providers.map((provider) => ( + + ))} +
{/* Authentication Status Display */}
); diff --git a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts index cd0a4c9cb..0e941e419 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts +++ b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts @@ -11,6 +11,7 @@ interface TestResult { interface ApiKeyStatus { hasAnthropicKey: boolean; hasGoogleKey: boolean; + hasZaiKey: boolean; } /** @@ -23,16 +24,20 @@ export function useApiKeyManagement() { // API key values const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic); const [googleKey, setGoogleKey] = useState(apiKeys.google); + const [zaiKey, setZaiKey] = useState(apiKeys.zai || ''); // Visibility toggles const [showAnthropicKey, setShowAnthropicKey] = useState(false); const [showGoogleKey, setShowGoogleKey] = useState(false); + const [showZaiKey, setShowZaiKey] = useState(false); // Test connection states const [testingConnection, setTestingConnection] = useState(false); const [testResult, setTestResult] = useState(null); const [testingGeminiConnection, setTestingGeminiConnection] = useState(false); const [geminiTestResult, setGeminiTestResult] = useState(null); + const [testingZaiConnection, setTestingZaiConnection] = useState(false); + const [zaiTestResult, setZaiTestResult] = useState(null); // API key status from environment const [apiKeyStatus, setApiKeyStatus] = useState(null); @@ -44,6 +49,7 @@ export function useApiKeyManagement() { useEffect(() => { setAnthropicKey(apiKeys.anthropic); setGoogleKey(apiKeys.google); + setZaiKey(apiKeys.zai || ''); }, [apiKeys]); // Check API key status from environment on mount @@ -57,6 +63,7 @@ export function useApiKeyManagement() { setApiKeyStatus({ hasAnthropicKey: status.hasAnthropicKey, hasGoogleKey: status.hasGoogleKey, + hasZaiKey: status.hasZaiKey, }); } } catch (error) { @@ -65,7 +72,7 @@ export function useApiKeyManagement() { } }; checkApiKeyStatus(); - }, []); + }, [apiKeys.zai]); // Test Anthropic/Claude connection const handleTestAnthropicConnection = async () => { @@ -122,14 +129,74 @@ export function useApiKeyManagement() { setTestingGeminiConnection(false); }; + // Test Z.ai connection + const handleTestZaiConnection = async () => { + setTestingZaiConnection(true); + setZaiTestResult(null); + + // Basic validation - check key format + if (!zaiKey || zaiKey.trim().length < 10) { + setZaiTestResult({ + success: false, + message: 'Please enter a valid API key.', + }); + setTestingZaiConnection(false); + return; + } + + // Try to make a simple API call to verify the key + try { + const api = getElectronAPI(); + const data = await api.setup.verifyZaiAuth?.(zaiKey); + + if (data?.success && data.authenticated) { + setZaiTestResult({ + success: true, + message: 'Connection successful! Z.ai responded.', + }); + } else { + setZaiTestResult({ + success: false, + message: data?.error || 'Failed to connect to Z.ai API.', + }); + } + } catch (error) { + setZaiTestResult({ + success: false, + message: error instanceof Error ? error.message : 'Network error. Please check your connection.', + }); + } finally { + setTestingZaiConnection(false); + } + }; + // Save API keys - const handleSave = () => { - setApiKeys({ - anthropic: anthropicKey, - google: googleKey, - }); - setSaved(true); - setTimeout(() => setSaved(false), 2000); + const handleSave = async () => { + try { + const api = getElectronAPI(); + // Update local store first + setApiKeys({ + anthropic: anthropicKey, + google: googleKey, + zai: zaiKey, + }); + + // Also save to backend credentials.json + if (api?.settings?.updateCredentials) { + await api.settings.updateCredentials({ + apiKeys: { + anthropic: anthropicKey, + google: googleKey, + zai: zaiKey, + }, + }); + } + + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } catch (error) { + console.error('Failed to save API keys:', error); + } }; // Build provider config params for buildProviderConfigs @@ -153,6 +220,15 @@ export function useApiKeyManagement() { onTest: handleTestGeminiConnection, result: geminiTestResult, }, + zai: { + value: zaiKey, + setValue: setZaiKey, + show: showZaiKey, + setShow: setShowZaiKey, + testing: testingZaiConnection, + onTest: handleTestZaiConnection, + result: zaiTestResult, + }, }; return { diff --git a/apps/ui/src/components/views/settings-view/api-keys/provider-card.tsx b/apps/ui/src/components/views/settings-view/api-keys/provider-card.tsx new file mode 100644 index 000000000..2eb1abf40 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/api-keys/provider-card.tsx @@ -0,0 +1,211 @@ +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Zap } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { ProviderKey } from '@/config/api-providers'; + +interface ProviderCardProps { + provider: ProviderKey; + label: string; + icon: React.ReactNode; + description: string; + apiKeyLink: string; + apiKeyLinkText: string; + enabled: boolean; + onToggle: () => void; + apiKeyValue: string; + onApiKeyChange: (value: string) => void; + showApiKey: boolean; + onToggleApiKeyVisibility: () => void; + hasStoredKey: string | null | undefined; + isTesting: boolean; + onTest: () => void; + testResult: { success: boolean; message: string } | null; + inputTestId: string; + toggleTestId: string; + testButtonTestId: string; + resultTestId: string; + resultMessageTestId: string; +} + +const providerIcons: Record = { + anthropic: ( + + + + ), + zai: ( + + + + ), + google: ( + + + + + + + ), +}; + +export function ProviderCard({ + provider, + label, + icon, + description, + apiKeyLink, + apiKeyLinkText, + enabled, + onToggle, + apiKeyValue, + onApiKeyChange, + showApiKey, + onToggleApiKeyVisibility, + hasStoredKey, + isTesting, + onTest, + testResult, + inputTestId, + toggleTestId, + testButtonTestId, + resultTestId, + resultMessageTestId, +}: ProviderCardProps) { + const providerIcon = icon || providerIcons[provider]; + + const isKeyReady = Boolean(hasStoredKey && (enabled || testResult?.success)); + + return ( +
+
+ {/* Header with name and toggle */} +
+
+
+ {providerIcon} +
+
+
+

{label}

+ {hasStoredKey && } +
+

+ {enabled ? 'Enabled' : 'Disabled'} +

+
+
+ +
+ + {/* API Key Input */} +
+ +
+
+ onApiKeyChange(e.target.value)} + placeholder={provider === 'anthropic' ? 'sk-ant-...' : 'Your API key'} + className="pr-10 bg-input border-border text-foreground placeholder:text-muted-foreground" + data-testid={inputTestId} + /> + +
+ +
+

+ {description}{' '} + + {apiKeyLinkText} + +

+
+ + {/* Test Result */} + {testResult && ( +
+ {testResult.success ? ( + + ) : ( + + )} + + {testResult.message} + +
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx index d924c6761..cbbb274e5 100644 --- a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx +++ b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx @@ -22,8 +22,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import type { AIProfile } from '@/store/app-store'; -import type { AgentModel } from '@automaker/types'; +import type { AIProfile, AgentModel } from '@automaker/types'; type PlanningMode = 'skip' | 'lite' | 'spec' | 'full'; @@ -37,6 +36,10 @@ interface FeatureDefaultsSectionProps { defaultAIProfileId: string | null; aiProfiles: AIProfile[]; validationModel: AgentModel; + enabledProviders: { + claude: boolean; + zai: boolean; + }; onShowProfilesOnlyChange: (value: boolean) => void; onDefaultSkipTestsChange: (value: boolean) => void; onEnableDependencyBlockingChange: (value: boolean) => void; @@ -49,6 +52,7 @@ interface FeatureDefaultsSectionProps { export function FeatureDefaultsSection({ showProfilesOnly, + enabledProviders, defaultSkipTests, enableDependencyBlocking, useWorktrees, @@ -249,16 +253,36 @@ export function FeatureDefaultsSection({ - - Opus - (Default) - - - Sonnet - - - Haiku - + {enabledProviders.claude && ( + <> + + Opus + (Default) + + + Sonnet + + + Haiku + + + )} + {enabledProviders.zai && ( + <> + + GLM-4.7 + (Z.ai) + + + GLM-4.6 + (Balanced) + + + GLM-4.5-Air + (Fast) + + + )}
diff --git a/apps/ui/src/components/views/setup-view.tsx b/apps/ui/src/components/views/setup-view.tsx index 051bcd124..5097b5b90 100644 --- a/apps/ui/src/components/views/setup-view.tsx +++ b/apps/ui/src/components/views/setup-view.tsx @@ -6,23 +6,29 @@ import { CompleteStep, ClaudeSetupStep, GitHubSetupStep, + ProviderSelectionStep, + ZaiSetupStep, } from './setup-view/steps'; import { useNavigate } from '@tanstack/react-router'; // Main Setup View export function SetupView() { - const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup } = useSetupStore(); + const { currentStep, setCurrentStep, completeSetup, selectedProvider } = useSetupStore(); const navigate = useNavigate(); - const steps = ['welcome', 'theme', 'claude', 'github', 'complete'] as const; + // Steps for the progress indicator + const steps = ['welcome', 'theme', 'provider', 'setup', 'github', 'complete'] as const; type StepName = (typeof steps)[number]; + const getStepName = (): StepName => { - if (currentStep === 'claude_detect' || currentStep === 'claude_auth') return 'claude'; if (currentStep === 'welcome') return 'welcome'; if (currentStep === 'theme') return 'theme'; + if (currentStep === 'provider_selection') return 'provider'; + if (currentStep === 'claude_setup' || currentStep === 'zai_setup') return 'setup'; if (currentStep === 'github') return 'github'; return 'complete'; }; + const currentIndex = steps.indexOf(getStepName()); const handleNext = (from: string) => { @@ -33,10 +39,21 @@ export function SetupView() { setCurrentStep('theme'); break; case 'theme': - console.log('[Setup Flow] Moving to claude_detect step'); - setCurrentStep('claude_detect'); + console.log('[Setup Flow] Moving to provider_selection step'); + setCurrentStep('provider_selection'); + break; + case 'provider_selection': + // Move to the appropriate setup step based on selected provider + if (selectedProvider === 'claude') { + console.log('[Setup Flow] Moving to claude_setup step'); + setCurrentStep('claude_setup'); + } else if (selectedProvider === 'zai') { + console.log('[Setup Flow] Moving to zai_setup step'); + setCurrentStep('zai_setup'); + } break; - case 'claude': + case 'claude_setup': + case 'zai_setup': console.log('[Setup Flow] Moving to github step'); setCurrentStep('github'); break; @@ -53,18 +70,26 @@ export function SetupView() { case 'theme': setCurrentStep('welcome'); break; - case 'claude': + case 'provider_selection': setCurrentStep('theme'); break; + case 'claude_setup': + case 'zai_setup': + setCurrentStep('provider_selection'); + break; case 'github': - setCurrentStep('claude_detect'); + // Go back to the setup step that was selected + if (selectedProvider === 'claude') { + setCurrentStep('claude_setup'); + } else { + setCurrentStep('zai_setup'); + } break; } }; - const handleSkipClaude = () => { - console.log('[Setup Flow] Skipping Claude setup'); - setSkipClaudeSetup(true); + const handleSkipProvider = () => { + console.log('[Setup Flow] Skipping provider setup'); setCurrentStep('github'); }; @@ -106,11 +131,27 @@ export function SetupView() { handleNext('theme')} onBack={() => handleBack('theme')} /> )} - {(currentStep === 'claude_detect' || currentStep === 'claude_auth') && ( + {currentStep === 'provider_selection' && ( + handleNext('provider_selection')} + onBack={() => handleBack('provider_selection')} + onSkip={handleSkipProvider} + /> + )} + + {currentStep === 'claude_setup' && ( handleNext('claude')} - onBack={() => handleBack('claude')} - onSkip={handleSkipClaude} + onNext={() => handleNext('claude_setup')} + onBack={() => handleBack('claude_setup')} + onSkip={handleSkipProvider} + /> + )} + + {currentStep === 'zai_setup' && ( + handleNext('zai_setup')} + onBack={() => handleBack('zai_setup')} + onSkip={handleSkipProvider} /> )} diff --git a/apps/ui/src/components/views/setup-view/steps/index.ts b/apps/ui/src/components/views/setup-view/steps/index.ts index 28bf064cb..1d6faa8ad 100644 --- a/apps/ui/src/components/views/setup-view/steps/index.ts +++ b/apps/ui/src/components/views/setup-view/steps/index.ts @@ -4,3 +4,5 @@ export { ThemeStep } from './theme-step'; export { CompleteStep } from './complete-step'; export { ClaudeSetupStep } from './claude-setup-step'; export { GitHubSetupStep } from './github-setup-step'; +export { ProviderSelectionStep } from './provider-selection-step'; +export { ZaiSetupStep } from './zai-setup-step'; diff --git a/apps/ui/src/components/views/setup-view/steps/provider-selection-step.tsx b/apps/ui/src/components/views/setup-view/steps/provider-selection-step.tsx new file mode 100644 index 000000000..6a1704130 --- /dev/null +++ b/apps/ui/src/components/views/setup-view/steps/provider-selection-step.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { CheckCircle2, Terminal, Key, ArrowRight, ArrowLeft } from 'lucide-react'; +import { useSetupStore, SelectedProvider } from '@/store/setup-store'; +import { cn } from '@/lib/utils'; + +interface ProviderSelectionStepProps { + onNext: () => void; + onBack: () => void; + onSkip: () => void; +} + +// Provider options displayed to users +const PROVIDER_OPTIONS = [ + { + id: 'claude' as SelectedProvider, + name: 'Claude (Anthropic)', + description: 'Industry-leading AI with strong reasoning and code generation capabilities.', + features: [ + 'CLI support with authentication', + 'Multiple models: Haiku (speed), Sonnet (balanced), Opus (premium)', + 'Extended thinking for complex tasks', + 'Vision support', + ], + icon: Terminal, + color: 'from-orange-500/20 to-orange-600/10', + borderColor: 'border-orange-500/30', + textColor: 'text-orange-500', + }, + { + id: 'zai' as SelectedProvider, + name: 'Z.ai (GLM)', + description: 'Powerful Chinese AI models with excellent multilingual capabilities.', + features: [ + 'Flagship GLM-4.7 model', + 'Multiple models: GLM-4.7, GLM-4.6v (vision), GLM-4.6, GLM-4.5-Air', + 'Extended thinking mode', + 'Cost-effective alternative', + ], + icon: Key, + color: 'from-blue-500/20 to-blue-600/10', + borderColor: 'border-blue-500/30', + textColor: 'text-blue-500', + }, +]; + +export function ProviderSelectionStep({ onNext, onBack, onSkip }: ProviderSelectionStepProps) { + const { selectedProvider, setSelectedProvider } = useSetupStore(); + const [hoveredProvider, setHoveredProvider] = useState(null); + + const handleProviderSelect = (providerId: SelectedProvider) => { + setSelectedProvider(providerId); + }; + + const isReady = selectedProvider !== null; + + return ( +
+
+
+ +
+

Choose Your AI Provider

+

+ Select the AI provider you want to use for code generation +

+
+ + {/* Provider Options */} +
+ {PROVIDER_OPTIONS.map((provider) => { + const Icon = provider.icon; + const isSelected = selectedProvider === provider.id; + const isHovered = hoveredProvider === provider.id; + + return ( + handleProviderSelect(provider.id)} + onMouseEnter={() => setHoveredProvider(provider.id)} + onMouseLeave={() => setHoveredProvider(null)} + > + +
+
+ +
+
+
+

{provider.name}

+ {isSelected && } +
+

{provider.description}

+
    + {provider.features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+
+
+
+
+ ); + })} +
+ + {/* Info Box */} +
+

+ Tip: You can add additional providers later + from the Settings view. Both providers can be enabled simultaneously for different use + cases. +

+
+ + {/* Navigation */} +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/setup-view/steps/zai-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/zai-setup-step.tsx new file mode 100644 index 000000000..a97e9bce1 --- /dev/null +++ b/apps/ui/src/components/views/setup-view/steps/zai-setup-step.tsx @@ -0,0 +1,387 @@ +import { useState, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useAppStore } from '@/store/app-store'; +import { getElectronAPI } from '@/lib/electron'; +import { + CheckCircle2, + Loader2, + Key, + ArrowRight, + ArrowLeft, + ExternalLink, + XCircle, + AlertCircle, + Trash2, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { useTokenSave } from '../hooks'; + +interface ZaiSetupStepProps { + onNext: () => void; + onBack: () => void; + onSkip: () => void; +} + +type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error'; + +export function ZaiSetupStep({ onNext, onBack, onSkip }: ZaiSetupStepProps) { + const { setApiKeys, apiKeys } = useAppStore(); + + const [apiKey, setApiKey] = useState(''); + + // API Key Verification state + const [verificationStatus, setVerificationStatus] = useState('idle'); + const [verificationError, setVerificationError] = useState(null); + const [isDeletingApiKey, setIsDeletingApiKey] = useState(false); + + // Delete API Key + const deleteApiKey = useCallback(async () => { + setIsDeletingApiKey(true); + try { + const api = getElectronAPI(); + if (!api.setup?.deleteApiKey) { + toast.error('Delete API not available'); + return; + } + const result = await api.setup.deleteApiKey('zai'); + if (result.success) { + setApiKey(''); + setApiKeys({ ...apiKeys, zai: '' }); + setVerificationStatus('idle'); + setVerificationError(null); + toast.success('API key deleted successfully'); + } else { + toast.error(result.error || 'Failed to delete API key'); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete API key'); + } finally { + setIsDeletingApiKey(false); + } + }, [apiKeys, setApiKeys]); + + // Save API Key state + const { isSaving, saveToken } = useTokenSave({ + provider: 'zai', + onSuccess: () => { + setApiKeys({ ...apiKeys, zai: apiKey }); + toast.success('Z.ai API key saved successfully!'); + }, + }); + + // Validate API key format + const isValidApiKey = (key: string): boolean => { + const trimmed = key.trim(); + return trimmed.length >= 20; // Z.ai API keys are typically 20+ characters + }; + + // Verify Zai API Key + const verifyApiKey = useCallback(async () => { + if (!apiKey || !isValidApiKey(apiKey)) { + setVerificationStatus('error'); + setVerificationError('Please enter a valid API key (at least 20 characters).'); + return; + } + + setVerificationStatus('verifying'); + setVerificationError(null); + + try { + const api = getElectronAPI(); + if (!api.setup?.verifyZaiAuth) { + setVerificationStatus('error'); + setVerificationError('Verification API not available'); + return; + } + + const result = await api.setup.verifyZaiAuth(apiKey); + + if (result.success && result.authenticated) { + setVerificationStatus('verified'); + setVerificationError(null); + toast.success('Z.ai API key verified successfully!'); + } else { + setVerificationStatus('error'); + setVerificationError(result.error || 'Authentication failed'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Verification failed'; + setVerificationStatus('error'); + setVerificationError(errorMessage); + } + }, [apiKey]); + + // Handle save and verify + const handleSaveAndVerify = async () => { + // First verify the API key before saving + if (!apiKey || !isValidApiKey(apiKey)) { + setVerificationStatus('error'); + setVerificationError('Please enter a valid API key (at least 20 characters).'); + return; + } + + setVerificationStatus('verifying'); + setVerificationError(null); + + try { + const api = getElectronAPI(); + if (!api.setup?.verifyZaiAuth) { + setVerificationStatus('error'); + setVerificationError('Verification API not available'); + return; + } + + const result = await api.setup.verifyZaiAuth(apiKey); + + if (result.success && result.authenticated) { + // Only save if verification succeeded + const saved = await saveToken(apiKey); + if (saved) { + setVerificationStatus('verified'); + setVerificationError(null); + toast.success('Z.ai API key verified and saved!'); + } else { + setVerificationStatus('error'); + setVerificationError('Failed to save API key after verification'); + } + } else { + setVerificationStatus('error'); + setVerificationError(result.error || 'Authentication failed'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Verification failed'; + setVerificationStatus('error'); + setVerificationError(errorMessage); + } + }; + + // Check if user is ready to proceed + const hasApiKey = !!apiKeys.zai; + const isVerified = verificationStatus === 'verified'; + const isReady = hasApiKey && isVerified; + + // Get status badge + const getStatusBadge = () => { + if (verificationStatus === 'verified') { + return ( +
+ + Verified +
+ ); + } + if (verificationStatus === 'error') { + return ( +
+ + Error +
+ ); + } + if (verificationStatus === 'verifying') { + return ( +
+ + Verifying... +
+ ); + } + if (hasApiKey) { + return ( +
+ + Unverified +
+ ); + } + return ( +
+ Not Set +
+ ); + }; + + return ( +
+
+
+ +
+

Z.ai API Key Setup

+

+ Configure your Z.ai (GLM) API key for code generation +

+
+ + {/* API Key Input Card */} + + +
+
+ Z.ai API Key + Get your API key from the ZhipuAI developer console +
+ {getStatusBadge()} +
+
+ + {/* API Key Input */} +
+ + setApiKey(e.target.value)} + className="bg-input border-border text-foreground" + data-testid="zai-api-key-input" + /> +

+ Don't have an API key?{' '} + + Get one from open.bigmodel.cn + + +

+
+ + {/* Save Button */} + + + {/* Verification Status Messages */} + {verificationStatus === 'verifying' && ( +
+ +
+

Verifying API key...

+

Testing connection to Z.ai API

+
+
+ )} + + {verificationStatus === 'verified' && ( +
+ +
+

API Key verified!

+

+ Your Z.ai API key is working correctly. +

+
+
+ )} + + {verificationStatus === 'error' && verificationError && ( +
+ +
+

Verification failed

+

{verificationError}

+
+
+ )} + + {/* Verify Button (if key is saved but not verified) */} + {hasApiKey && verificationStatus !== 'verified' && verificationStatus !== 'verifying' && ( +
+ + +
+ )} + + {/* Delete button for verified key */} + {hasApiKey && verificationStatus === 'verified' && ( + + )} +
+
+ + {/* Navigation */} +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/ui/src/config/api-providers.ts b/apps/ui/src/config/api-providers.ts index b72af74c0..a910d9884 100644 --- a/apps/ui/src/config/api-providers.ts +++ b/apps/ui/src/config/api-providers.ts @@ -1,7 +1,7 @@ import type { Dispatch, SetStateAction } from 'react'; import type { ApiKeys } from '@/store/app-store'; -export type ProviderKey = 'anthropic' | 'google'; +export type ProviderKey = 'anthropic' | 'google' | 'zai'; export interface ProviderConfig { key: ProviderKey; @@ -50,11 +50,21 @@ export interface ProviderConfigParams { onTest: () => Promise; result: { success: boolean; message: string } | null; }; + zai: { + value: string; + setValue: Dispatch>; + show: boolean; + setShow: Dispatch>; + testing: boolean; + onTest: () => Promise; + result: { success: boolean; message: string } | null; + }; } export const buildProviderConfigs = ({ apiKeys, anthropic, + zai, }: ProviderConfigParams): ProviderConfig[] => [ { key: 'anthropic', @@ -82,6 +92,32 @@ export const buildProviderConfigs = ({ descriptionLinkText: 'console.anthropic.com', descriptionSuffix: '.', }, + { + key: 'zai', + label: 'Z.ai API Key', + inputId: 'zai-key', + placeholder: 'Your Z.ai API key', + value: zai.value, + setValue: zai.setValue, + showValue: zai.show, + setShowValue: zai.setShow, + hasStoredKey: apiKeys.zai, + inputTestId: 'zai-api-key-input', + toggleTestId: 'toggle-zai-visibility', + testButton: { + onClick: zai.onTest, + disabled: !zai.value || zai.testing, + loading: zai.testing, + testId: 'test-zai-connection', + }, + result: zai.result, + resultTestId: 'zai-test-connection-result', + resultMessageTestId: 'zai-test-connection-message', + descriptionPrefix: 'Used for GLM-4.7 AI features. Get your key at', + descriptionLinkHref: 'https://open.bigmodel.cn/usercenter/apikeys', + descriptionLinkText: 'open.bigmodel.cn', + descriptionSuffix: '.', + }, // { // key: "google", // label: "Google API Key (Gemini)", diff --git a/apps/ui/src/hooks/use-electron-agent.ts b/apps/ui/src/hooks/use-electron-agent.ts index 603bcc8ec..99ecdb336 100644 --- a/apps/ui/src/hooks/use-electron-agent.ts +++ b/apps/ui/src/hooks/use-electron-agent.ts @@ -267,7 +267,9 @@ export function useElectronAgent({ // Final update setMessages((prev) => prev.map((msg) => - msg.id === event.messageId ? { ...msg, content: event.content } : msg + msg.id === event.messageId + ? { ...msg, content: event.content, thinking: event.thinking } + : msg ) ); currentMessageRef.current = null; @@ -278,7 +280,9 @@ export function useElectronAgent({ if (existingIndex >= 0) { // Update existing message return prev.map((msg) => - msg.id === event.messageId ? { ...msg, content: event.content } : msg + msg.id === event.messageId + ? { ...msg, content: event.content, thinking: event.thinking } + : msg ); } else { // Create new message @@ -286,6 +290,7 @@ export function useElectronAgent({ id: event.messageId, role: 'assistant', content: event.content, + thinking: event.thinking, timestamp: new Date().toISOString(), }; currentMessageRef.current = newMessage; diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 2bca750b4..f97eff1e1 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -21,6 +21,7 @@ import { useEffect, useState, useRef } from 'react'; import { getHttpApiClient } from '@/lib/http-api-client'; import { isElectron } from '@/lib/electron'; import { getItem, removeItem } from '@/lib/storage'; +import { useAppStore } from '@/store/app-store'; /** * State returned by useSettingsMigration hook @@ -315,3 +316,46 @@ export async function syncProjectSettingsToServer( return false; } } + +/** + * Load enabledProviders from backend global settings + * + * Fetches the current enabledProviders state from the server and updates + * the app store. This should be called on app startup to restore the + * user's provider preferences. + * + * Only functions in Electron mode. Silently fails in web mode. + */ +export async function loadEnabledProviders(): Promise { + if (!isElectron()) return; + + try { + const api = getHttpApiClient(); + const result = await api.settings.getGlobal(); + + if (result.success && result.settings?.enabledProviders) { + const { enabledProviders } = result.settings; + useAppStore.getState().setEnabledProviders(enabledProviders); + console.log('[Settings] Loaded enabledProviders from backend:', enabledProviders); + } + } catch (error) { + console.error('[Settings] Failed to load enabledProviders:', error); + } +} + +/** + * Hook to load enabledProviders from backend on mount + * + * Loads the user's provider preferences from file-based storage on app startup. + * This ensures provider toggle state persists across app restarts. + */ +export function useEnabledProvidersLoader(): void { + const loaded = useRef(false); + + useEffect(() => { + if (loaded.current) return; + loaded.current = true; + + loadEnabledProviders(); + }, []); +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 698f915ed..a9df0442f 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -527,6 +527,7 @@ export interface ElectronAPI { success: boolean; hasAnthropicKey: boolean; hasGoogleKey: boolean; + hasZaiKey: boolean; }>; getPlatform: () => Promise<{ success: boolean; @@ -542,6 +543,11 @@ export interface ElectronAPI { authenticated: boolean; error?: string; }>; + verifyZaiAuth: (apiKey?: string) => Promise<{ + success: boolean; + authenticated: boolean; + error?: string; + }>; getGhStatus?: () => Promise<{ success: boolean; installed: boolean; @@ -1110,6 +1116,11 @@ interface SetupAPI { authenticated: boolean; error?: string; }>; + verifyZaiAuth: (apiKey?: string) => Promise<{ + success: boolean; + authenticated: boolean; + error?: string; + }>; getGhStatus?: () => Promise<{ success: boolean; installed: boolean; @@ -1175,6 +1186,7 @@ function createMockSetupAPI(): SetupAPI { success: true, hasAnthropicKey: false, hasGoogleKey: false, + hasZaiKey: false, }; }, @@ -1205,6 +1217,15 @@ function createMockSetupAPI(): SetupAPI { }; }, + verifyZaiAuth: async (_apiKey?: string) => { + console.log('[Mock] Verifying Z.ai auth'); + return { + success: true, + authenticated: false, + error: 'Mock environment - authentication not available', + }; + }, + getGhStatus: async () => { console.log('[Mock] Getting GitHub CLI status'); return { diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 13def2c97..092971995 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -478,6 +478,7 @@ export class HttpApiClient implements ElectronAPI { success: boolean; hasAnthropicKey: boolean; hasGoogleKey: boolean; + hasZaiKey: boolean; }> => this.get('/api/setup/api-keys'), getPlatform: (): Promise<{ @@ -498,6 +499,14 @@ export class HttpApiClient implements ElectronAPI { error?: string; }> => this.post('/api/setup/verify-claude-auth', { authMethod }), + verifyZaiAuth: ( + apiKey?: string + ): Promise<{ + success: boolean; + authenticated: boolean; + error?: string; + }> => this.post('/api/setup/verify-zai-auth', apiKey ? { apiKey } : {}), + getGhStatus: (): Promise<{ success: boolean; installed: boolean; @@ -926,6 +935,10 @@ export class HttpApiClient implements ElectronAPI { recentFolders: string[]; worktreePanelCollapsed: boolean; lastSelectedSessionByProject: Record; + enabledProviders?: { + claude: boolean; + zai: boolean; + }; }; error?: string; }> => this.get('/api/settings/global'), @@ -950,13 +963,14 @@ export class HttpApiClient implements ElectronAPI { }> => this.get('/api/settings/credentials'), updateCredentials: (updates: { - apiKeys?: { anthropic?: string; google?: string; openai?: string }; + apiKeys?: { anthropic?: string; google?: string; openai?: string; zai?: string }; }): Promise<{ success: boolean; credentials?: { anthropic: { configured: boolean; masked: string }; google: { configured: boolean; masked: string }; openai: { configured: boolean; masked: string }; + zai: { configured: boolean; masked: string }; }; error?: string; }> => this.put('/api/settings/credentials', updates), diff --git a/apps/ui/src/lib/utils.ts b/apps/ui/src/lib/utils.ts index 82ad7452d..b749925bd 100644 --- a/apps/ui/src/lib/utils.ts +++ b/apps/ui/src/lib/utils.ts @@ -9,9 +9,18 @@ export function cn(...inputs: ClassValue[]) { /** * Determine if the current model supports extended thinking controls */ -export function modelSupportsThinking(_model?: AgentModel | string): boolean { - // All Claude models support thinking - return true; +export function modelSupportsThinking(model?: AgentModel | string): boolean { + if (!model) return true; // Default for safety + const modelStr = model.toString(); + // Claude models support extended thinking + const claudeModels = ['haiku', 'sonnet', 'opus', 'claude-haiku', 'claude-sonnet', 'claude-opus']; + // All Zai GLM models support thinking mode (glm-4.5-air, glm-4.6, glm-4.6v, glm-4.7) + const zaiModels = ['glm', 'glm-4.5-air', 'glm-4.6', 'glm-4.6v', 'glm-4.7']; + return ( + claudeModels.some((m) => modelStr.includes(m)) || + zaiModels.includes(modelStr) || + zaiModels.some((m) => modelStr.includes(m)) + ); } /** @@ -22,6 +31,11 @@ export function getModelDisplayName(model: AgentModel | string): string { haiku: 'Claude Haiku', sonnet: 'Claude Sonnet', opus: 'Claude Opus', + 'glm-4.7': 'GLM-4.7', + 'glm-4.6v': 'GLM-4.6v', + 'glm-4.6': 'GLM-4.6', + 'glm-4.5-air': 'GLM-4.5-Air', + glm: 'GLM-4.5-Air', }; return displayNames[model] || model; } diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 874e1a6df..9ee768485 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -1,6 +1,8 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { Project, TrashedProject } from '@/lib/electron'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { isElectron } from '@/lib/electron'; import type { Feature as BaseFeature, FeatureImagePath, @@ -53,6 +55,7 @@ export interface ApiKeys { anthropic: string; google: string; openai: string; + zai: string; } // Keyboard Shortcut with optional modifiers @@ -419,6 +422,17 @@ export interface AppState { // API Keys apiKeys: ApiKeys; + // Provider Configuration + enabledProviders: { + claude: boolean; + zai: boolean; + }; + // Track if user manually touched provider toggle (to avoid auto-enabling after manual disable) + providerToggleTouched: { + claude: boolean; + zai: boolean; + }; + // Chat Sessions chatSessions: ChatSession[]; currentChatSession: ChatSession | null; @@ -480,6 +494,7 @@ export interface AppState { // Claude Agent SDK Settings autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option + showReasoningByDefault: boolean; // Show extended thinking/reasoning content expanded by default // Project Analysis projectAnalysis: ProjectAnalysis | null; @@ -681,6 +696,11 @@ export interface AppActions { // API Keys actions setApiKeys: (keys: Partial) => void; + // Provider Configuration actions + setEnabledProviders: (providers: Partial<{ claude: boolean; zai: boolean }>) => void; + toggleProvider: (provider: 'claude' | 'zai') => Promise; + markProviderTouched: (provider: 'claude' | 'zai') => void; + // Chat Session actions createChatSession: (title?: string) => ChatSession; updateChatSession: (sessionId: string, updates: Partial) => void; @@ -909,6 +929,15 @@ const initialState: AppState = { anthropic: '', google: '', openai: '', + zai: '', + }, + enabledProviders: { + claude: false, + zai: false, + }, + providerToggleTouched: { + claude: false, + zai: false, }, chatSessions: [], currentChatSession: null, @@ -929,6 +958,7 @@ const initialState: AppState = { enhancementModel: 'sonnet', // Default to sonnet for feature enhancement validationModel: 'opus', // Default to opus for GitHub issue validation autoLoadClaudeMd: false, // Default to disabled (user must opt-in) + showReasoningByDefault: false, // Default to collapsed (user can expand) aiProfiles: DEFAULT_AI_PROFILES, projectAnalysis: null, isAnalyzing: false, @@ -1276,6 +1306,57 @@ export const useAppStore = create()( // API Keys actions setApiKeys: (keys) => set({ apiKeys: { ...get().apiKeys, ...keys } }), + // Provider Configuration actions + setEnabledProviders: (providers) => + set({ enabledProviders: { ...get().enabledProviders, ...providers } }), + toggleProvider: async (provider) => { + // Optimistically update local state first + const newValue = !get().enabledProviders[provider]; + set((state) => ({ + enabledProviders: { + ...state.enabledProviders, + [provider]: newValue, + }, + providerToggleTouched: { + ...state.providerToggleTouched, + [provider]: true, + }, + })); + + // Sync to backend if in Electron mode + if (isElectron()) { + try { + const api = getHttpApiClient(); + const result = await api.settings.updateGlobal({ + enabledProviders: { ...get().enabledProviders, [provider]: newValue }, + }); + if (!result.success) { + // Revert on failure + set((state) => ({ + enabledProviders: { + ...state.enabledProviders, + [provider]: !newValue, + }, + })); + console.error('[Provider Toggle] Failed to sync to backend:', result.error); + } + } catch (error) { + // Revert on error + set((state) => ({ + enabledProviders: { + ...state.enabledProviders, + [provider]: !newValue, + }, + })); + console.error('[Provider Toggle] Failed to sync to backend:', error); + } + } + }, + markProviderTouched: (provider) => + set((state) => ({ + providerToggleTouched: { ...state.providerToggleTouched, [provider]: true }, + })), + // Chat Session actions createChatSession: (title) => { const currentProject = get().currentProject; @@ -1562,6 +1643,8 @@ export const useAppStore = create()( await syncSettingsToServer(); }, + setShowReasoningByDefault: (enabled) => set({ showReasoningByDefault: enabled }), + // AI Profile actions addAIProfile: (profile) => { const id = `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -2692,6 +2775,8 @@ export const useAppStore = create()( boardViewMode: state.boardViewMode, // Settings apiKeys: state.apiKeys, + enabledProviders: state.enabledProviders, + providerToggleTouched: state.providerToggleTouched, maxConcurrency: state.maxConcurrency, // Note: autoModeByProject is intentionally NOT persisted // Auto-mode should always default to OFF on app refresh @@ -2706,6 +2791,7 @@ export const useAppStore = create()( enhancementModel: state.enhancementModel, validationModel: state.validationModel, autoLoadClaudeMd: state.autoLoadClaudeMd, + showReasoningByDefault: state.showReasoningByDefault, // Profiles and sessions aiProfiles: state.aiProfiles, chatSessions: state.chatSessions, diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index e345ac917..43ebc74c4 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -54,17 +54,23 @@ export interface InstallProgress { export type SetupStep = | 'welcome' | 'theme' - | 'claude_detect' - | 'claude_auth' + | 'provider_selection' + | 'claude_setup' + | 'zai_setup' | 'github' | 'complete'; +export type SelectedProvider = 'claude' | 'zai'; + export interface SetupState { // Setup wizard state isFirstRun: boolean; setupComplete: boolean; currentStep: SetupStep; + // Provider selection + selectedProvider: SelectedProvider | null; + // Claude CLI state claudeCliStatus: CliStatus | null; claudeAuthStatus: ClaudeAuthStatus | null; @@ -96,6 +102,7 @@ export interface SetupActions { // Preferences setSkipClaudeSetup: (skip: boolean) => void; + setSelectedProvider: (provider: SelectedProvider | null) => void; } const initialInstallProgress: InstallProgress = { @@ -113,6 +120,8 @@ const initialState: SetupState = { setupComplete: shouldSkipSetup, currentStep: shouldSkipSetup ? 'complete' : 'welcome', + selectedProvider: null, + claudeCliStatus: null, claudeAuthStatus: null, claudeInstallProgress: { ...initialInstallProgress }, @@ -169,12 +178,15 @@ export const useSetupStore = create()( // Preferences setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), + setSelectedProvider: (provider) => set({ selectedProvider: provider }), }), { name: 'automaker-setup', partialize: (state) => ({ isFirstRun: state.isFirstRun, setupComplete: state.setupComplete, + selectedProvider: state.selectedProvider, + currentStep: state.currentStep, skipClaudeSetup: state.skipClaudeSetup, }), } diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index e9f15cf9c..bf20ca3ab 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -14,6 +14,7 @@ export interface Message { id: string; role: 'user' | 'assistant'; content: string; + thinking?: string; // Extended thinking from AI provider (unified for Claude and Zai) timestamp: string; isError?: boolean; images?: ImageAttachment[]; @@ -35,6 +36,7 @@ export type StreamEvent = sessionId: string; messageId: string; content: string; + thinking?: string; // Extended thinking content (unified for Claude and Zai) isComplete: boolean; } | { diff --git a/apps/ui/src/utils/model-utils.ts b/apps/ui/src/utils/model-utils.ts new file mode 100644 index 000000000..3c23d635a --- /dev/null +++ b/apps/ui/src/utils/model-utils.ts @@ -0,0 +1,63 @@ +import type { AgentModel } from '@automaker/types'; + +export function autoSwitchModelIfDisabled( + currentModel: AgentModel | null | undefined, + enabledProviders: { claude: boolean; zai: boolean } +): AgentModel { + // If no model selected, default to sonnet + if (!currentModel) return 'sonnet'; + + // Get all models from model-constants + const CLAUDE_MODELS: Array<{ id: AgentModel; badge?: string }> = [ + { id: 'haiku', badge: 'Speed' }, + { id: 'sonnet', badge: 'Balanced' }, + { id: 'opus', badge: 'Premium' }, + ]; + + const ZAI_MODELS: Array<{ id: AgentModel; badge?: string }> = [ + { id: 'glm-4.7', badge: 'Premium' }, + { id: 'glm-4.6v', badge: 'Vision' }, + { id: 'glm-4.6', badge: 'Balanced' }, + { id: 'glm-4.5-air', badge: 'Speed' }, + ]; + + const ALL_MODELS_INFO = [...CLAUDE_MODELS, ...ZAI_MODELS]; + + // Get provider of current model + const currentModelInfo = ALL_MODELS_INFO.find((m) => m.id === currentModel); + if (!currentModelInfo) return 'sonnet'; + + // Check if model provider is enabled + const isClaudeModel = CLAUDE_MODELS.some((m) => m.id === currentModel); + const isZaiModel = ZAI_MODELS.some((m) => m.id === currentModel); + + const providerEnabled = isClaudeModel ? enabledProviders.claude : enabledProviders.zai; + + // If provider is enabled, keep current model + if (providerEnabled) return currentModel; + + // Find equivalent model from enabled provider + const equivalentModel = ALL_MODELS_INFO.find( + (m) => + // Different provider + (isClaudeModel ? ZAI_MODELS : CLAUDE_MODELS).includes(m) && + // Same badge tier + m.badge === currentModelInfo.badge && + // Provider is enabled + (isClaudeModel ? enabledProviders.zai : enabledProviders.claude) + ); + + // If equivalent model found, use it + if (equivalentModel) return equivalentModel.id; + + // Fallback to any enabled provider model + const fallbackModel = ALL_MODELS_INFO.find((m) => { + const modelIsClaude = CLAUDE_MODELS.some((cm) => cm.id === m.id); + return modelIsClaude ? enabledProviders.claude : enabledProviders.zai; + }); + + if (fallbackModel) return fallbackModel.id; + + // Ultimate fallback + return 'sonnet'; +} diff --git a/docs/server/providers.md b/docs/server/providers.md index 757ecab1a..05cd2a27e 100644 --- a/docs/server/providers.md +++ b/docs/server/providers.md @@ -338,6 +338,100 @@ enabled_tools = ["UpdateFeatureStatus"] --- +## Zai Provider + +**Location**: `apps/server/src/providers/zai-provider.ts` + +Integrates with Z.ai's GLM models (glm-4.x series). + +### Authentication + +- **Method**: API key only +- **Environment Variable**: `ZAI_API_KEY` +- **Setup Flow**: `/setup/zai-setup-step` +- **Key Validation**: Minimum 20 characters +- **Verification Endpoint**: `/setup/verify-zai-auth` + +### Supported Models + +| Model | Tier | Vision | Context | Output | Features | +| ----------- | -------- | ------ | ------- | ------ | ----------------------- | +| glm-4.7 | Premium | No | 200K | 128K | Tools, Thinking | +| glm-4.6v | Vision | Yes | 128K | 96K | Tools, Vision, Thinking | +| glm-4.6 | Standard | No | 200K | 128K | Tools, Thinking | +| glm-4.5-air | Basic | No | 128K | 96K | Tools, Thinking | + +### Features + +- ✅ Tool execution (all 6 tools: read_file, write_file, edit_file, glob_search, grep_search, execute_command) +- ✅ Text generation +- ✅ Vision (glm-4.6v only) +- ✅ Extended thinking (reasoning_content) +- ❌ MCP servers (not supported) +- ❌ Browser use (not supported) + +### Provider Aliases + +- `zai` +- `zhipuai` (Chinese company name) +- `zhipu` (Alternative) +- `glm` (Model family) + +### Implementation Notes + +**Streaming**: Custom SSE parsing (no SDK) + +```typescript +// ZaiProvider implements streaming manually +async *executeQuery(options: ExecuteOptions): AsyncGenerator +``` + +**Tool Execution**: Native implementation with security hardening + +- Command allowlist: ~270+ commands (Unix and Windows variants) +- Path traversal protection (blocks `..`) +- Recursive flag blocking (`-r`, `-R`, `-a`) +- Docker disabled by default (`ZAI_ALLOW_DOCKER=1` to enable) +- Shell metacharacter blocking + +**Timeouts**: + +- Query timeout: 5 minutes (auto-abort) +- Tool execution: 30 seconds (all tools) +- Verification: 30 seconds + +**Error Handling**: + +- Retry logic: 3 attempts with exponential backoff (1s, 2s, 4s) +- Rate limiting: 5 attempts per 15 minutes on verification +- Graceful fallback for non-vision models with images + +**Session Management**: + +- Manual session ID handling (via `sdkSessionId` option) +- No automatic session persistence + +### Testing + +- 53 unit tests (978 lines) +- Security tests: 10 dedicated tests +- Tool execution tests: 4 tools covered +- SSE parsing tests +- Image handling tests + +### Differences from Claude Provider + +| Aspect | Claude | Zai | +| -------------- | ----------------------------------- | ---------------------------------- | +| SDK | Uses @anthropic-ai/claude-agent-sdk | Custom fetch + SSE parsing | +| Streaming | SDK handles | Manual implementation | +| Session | SDK manages | Manual session ID | +| Security | SDK handles | Custom allowlist and validation | +| Tool Execution | SDK handles | Native implementation | +| Retry | SDK built-in | Manual implementation (3 attempts) | + +--- + ## Provider Factory **Location**: `apps/server/src/providers/provider-factory.ts` diff --git a/libs/model-resolver/src/index.ts b/libs/model-resolver/src/index.ts index d6db5a276..c67c1160e 100644 --- a/libs/model-resolver/src/index.ts +++ b/libs/model-resolver/src/index.ts @@ -4,7 +4,21 @@ */ // Re-export constants from types -export { CLAUDE_MODEL_MAP, DEFAULT_MODELS, type ModelAlias } from '@automaker/types'; +export { + CLAUDE_MODEL_MAP, + DEFAULT_MODELS, + MODEL_EQUIVALENCE, + getProviderForModel, + type ModelAlias, +} from '@automaker/types'; // Export resolver functions -export { resolveModelString, getEffectiveModel } from './resolver.js'; +export { + resolveModelString, + getEffectiveModel, + getModelForUseCase, + resolveModelWithProviderAvailability, + type EnabledProviders, + type ModelUseCase, + type ProviderHint, +} from './resolver.js'; diff --git a/libs/model-resolver/src/resolver.ts b/libs/model-resolver/src/resolver.ts index fbed3a65a..254b14020 100644 --- a/libs/model-resolver/src/resolver.ts +++ b/libs/model-resolver/src/resolver.ts @@ -3,26 +3,70 @@ * * Provides centralized model resolution logic: * - Maps Claude model aliases to full model strings + * - Maps ZAI model aliases to full model strings * - Provides default models per provider * - Handles multiple model sources with priority */ -import { CLAUDE_MODEL_MAP, DEFAULT_MODELS } from '@automaker/types'; +import { + CLAUDE_MODEL_MAP, + ZAI_MODEL_MAP, + ALL_MODEL_MAPS, + DEFAULT_MODELS, + MODEL_EQUIVALENCE, + getProviderForModel, +} from '@automaker/types'; + +/** + * Use case types for model selection + */ +export type ModelUseCase = 'spec' | 'features' | 'suggestions' | 'chat' | 'auto' | 'default'; + +/** + * Environment variable names for each use case + */ +const USE_CASE_ENV_VARS: Record = { + spec: 'AUTOMAKER_MODEL_SPEC', + features: 'AUTOMAKER_MODEL_FEATURES', + suggestions: 'AUTOMAKER_MODEL_SUGGESTIONS', + chat: 'AUTOMAKER_MODEL_CHAT', + auto: 'AUTOMAKER_MODEL_AUTO', + default: 'AUTOMAKER_MODEL_DEFAULT', +}; + +/** + * Provider hint for default model selection + */ +export type ProviderHint = 'claude' | 'zai' | 'auto'; /** * Resolve a model key/alias to a full model string * - * @param modelKey - Model key (e.g., "opus", "gpt-5.2", "claude-sonnet-4-20250514") - * @param defaultModel - Fallback model if modelKey is undefined + * @param modelKey - Model key (e.g., "opus", "glm", "claude-opus-4-20250514") + * @param defaultModel - Fallback model if modelKey is undefined (overrides providerHint) + * @param providerHint - Provider preference for default selection (claude, zai, or auto) * @returns Full model string */ export function resolveModelString( modelKey?: string, - defaultModel: string = DEFAULT_MODELS.claude + defaultModel?: string, + providerHint: ProviderHint = 'auto' ): string { - // No model specified - use default + // No model specified - use default based on provider hint if (!modelKey) { - return defaultModel; + if (defaultModel) { + return defaultModel; + } + // Provider-aware default selection + switch (providerHint) { + case 'zai': + return DEFAULT_MODELS.zai; + case 'claude': + return DEFAULT_MODELS.claude; + case 'auto': + default: + return DEFAULT_MODELS.claude; // Default to Claude for backward compatibility + } } // Full Claude model string - pass through unchanged @@ -31,16 +75,54 @@ export function resolveModelString( return modelKey; } + // Full ZAI model string (glm-*) - pass through unchanged + if (modelKey.startsWith('glm-')) { + console.log(`[ModelResolver] Using full GLM model string: ${modelKey}`); + return modelKey; + } + // Look up Claude model alias - const resolved = CLAUDE_MODEL_MAP[modelKey]; - if (resolved) { - console.log(`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`); - return resolved; + const claudeResolved = CLAUDE_MODEL_MAP[modelKey]; + if (claudeResolved) { + console.log( + `[ModelResolver] Resolved Claude model alias: "${modelKey}" -> "${claudeResolved}"` + ); + return claudeResolved; + } + + // Look up ZAI model alias + const zaiResolved = ZAI_MODEL_MAP[modelKey]; + if (zaiResolved) { + console.log(`[ModelResolver] Resolved ZAI model alias: "${modelKey}" -> "${zaiResolved}"`); + return zaiResolved; } - // Unknown model key - use default - console.warn(`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`); - return defaultModel; + // Check combined model map (includes both providers) + const allResolved = ALL_MODEL_MAPS[modelKey]; + if (allResolved) { + console.log(`[ModelResolver] Resolved model alias: "${modelKey}" -> "${allResolved}"`); + return allResolved; + } + + // Unknown model key - use default or provider-aware fallback + if (defaultModel) { + console.warn( + `[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"` + ); + return defaultModel; + } + + // Use provider-aware fallback + console.warn(`[ModelResolver] Unknown model key "${modelKey}", using provider-aware default`); + switch (providerHint) { + case 'zai': + return DEFAULT_MODELS.zai; + case 'claude': + return DEFAULT_MODELS.claude; + case 'auto': + default: + return DEFAULT_MODELS.claude; + } } /** @@ -59,3 +141,129 @@ export function getEffectiveModel( ): string { return resolveModelString(explicitModel || sessionModel, defaultModel); } + +/** + * Get model for a specific use case from environment variable + * + * Checks the appropriate AUTOMAKER_MODEL_* environment variable based on use case. + * Falls back to AUTOMAKER_MODEL_DEFAULT, then to provider defaults. + * + * @param useCase - The use case (spec, features, suggestions, chat, auto, default) + * @returns Resolved model string from environment or defaults + */ +export function getModelForUseCase(useCase: ModelUseCase = 'default'): string { + const envVar = USE_CASE_ENV_VARS[useCase]; + const envModel = process.env[envVar]; + + if (envModel) { + console.log(`[ModelResolver] Using model from ${envVar}: ${envModel}`); + return resolveModelString(envModel); + } + + // Fall back to AUTOMAKER_MODEL_DEFAULT + if (useCase !== 'default') { + const defaultModel = process.env[USE_CASE_ENV_VARS.default]; + if (defaultModel) { + console.log(`[ModelResolver] Using model from ${USE_CASE_ENV_VARS.default}: ${defaultModel}`); + return resolveModelString(defaultModel); + } + } + + // Final fallback to provider defaults + const fallback = DEFAULT_MODELS.claude; + console.log( + `[ModelResolver] No model env var for use case "${useCase}", using default: ${fallback}` + ); + return fallback; +} + +/** + * Provider availability configuration + */ +export interface EnabledProviders { + claude: boolean; + zai: boolean; +} + +/** + * Resolve a model with provider availability checking + * + * This function checks if the requested model's provider is enabled. + * If not, it attempts to find an equivalent model from an enabled provider. + * + * @param model - The model to resolve + * @param enabledProviders - Which providers are enabled + * @param defaultModel - Optional fallback if no equivalent is found + * @returns The resolved model string (original, equivalent, or default) + * + * @example + * ```typescript + * // Claude disabled, Zai enabled + * resolveModelWithProviderAvailability('claude-opus-4-5-20251101', { claude: false, zai: true }) + * // Returns: 'glm-4.7' + * + * // Both disabled, with default + * resolveModelWithProviderAvailability('opus', { claude: false, zai: false }, 'haiku') + * // Returns: 'haiku' (or default from enabled provider if available) + * ``` + */ +export function resolveModelWithProviderAvailability( + model: string, + enabledProviders: EnabledProviders, + defaultModel?: string +): string { + // First resolve the model string to get the full model + const resolvedModel = resolveModelString(model, defaultModel); + + // Get the provider for this model + const provider = getProviderForModel(resolvedModel); + + // If we can't determine the provider, return as-is + if (!provider) { + console.warn(`[ModelResolver] Unknown provider for model: ${resolvedModel}`); + return resolvedModel; + } + + // Check if the provider is enabled + if (enabledProviders[provider]) { + // Provider is enabled, use the model as-is + return resolvedModel; + } + + // Provider is disabled, try to find an equivalent model + console.log( + `[ModelResolver] Provider "${provider}" is disabled for model "${resolvedModel}", looking for equivalent...` + ); + + const equivalent = MODEL_EQUIVALENCE[resolvedModel]; + if (equivalent) { + // Check if the equivalent's provider is enabled + if (enabledProviders[equivalent.provider]) { + console.log( + `[ModelResolver] Using equivalent model: "${resolvedModel}" -> "${equivalent.model}" (${equivalent.provider})` + ); + return equivalent.model; + } else { + console.log(`[ModelResolver] Equivalent provider "${equivalent.provider}" is also disabled`); + } + } + + // No equivalent found or equivalent's provider is also disabled + // Find any enabled provider and use its default model + if (enabledProviders.claude) { + console.log(`[ModelResolver] Falling back to Claude default: ${DEFAULT_MODELS.claude}`); + return DEFAULT_MODELS.claude; + } + + if (enabledProviders.zai) { + console.log(`[ModelResolver] Falling back to Zai default: ${DEFAULT_MODELS.zai}`); + return DEFAULT_MODELS.zai; + } + + // All providers disabled - this is an error condition + console.error(`[ModelResolver] No providers are enabled! Cannot resolve model: ${resolvedModel}`); + + // Return the defaultModel if provided, otherwise return the resolved model + // (calling code should handle this error case) + return defaultModel || resolvedModel; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 64962d2ae..7ed926d39 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -7,8 +7,15 @@ export type { ProviderConfig, ConversationMessage, + SystemPromptPreset, + ThinkingConfig, ExecuteOptions, + TextBlock, + ToolUseBlock, + ThinkingBlock, + ToolResultBlock, ContentBlock, + LegacyContentBlock, ProviderMessage, InstallationStatus, ValidationResult, @@ -33,7 +40,17 @@ export type { ErrorType, ErrorInfo } from './error.js'; export type { ImageData, ImageContentBlock } from './image.js'; // Model types and constants -export { CLAUDE_MODEL_MAP, DEFAULT_MODELS, type ModelAlias, type AgentModel } from './model.js'; +export { + CLAUDE_MODEL_MAP, + ZAI_MODEL_MAP, + ALL_MODEL_MAPS, + DEFAULT_MODELS, + MODEL_EQUIVALENCE, + PROVIDER_TO_API_KEY_NAME, + getProviderForModel, + type ModelAlias, + type AgentModel, +} from './model.js'; // Event types export type { EventType, EventCallback } from './event.js'; @@ -77,6 +94,7 @@ export { export type { ModelOption, ThinkingLevelOption } from './model-display.js'; export { CLAUDE_MODELS, + ZAI_MODELS, THINKING_LEVELS, THINKING_LEVEL_LABELS, getModelDisplayName, diff --git a/libs/types/src/model-display.ts b/libs/types/src/model-display.ts index e7604fd18..874a1ff85 100644 --- a/libs/types/src/model-display.ts +++ b/libs/types/src/model-display.ts @@ -19,8 +19,8 @@ export interface ModelOption { description: string; /** Optional badge text (e.g., "Speed", "Balanced", "Premium") */ badge?: string; - /** AI provider (currently only "claude") */ - provider: 'claude'; + /** AI provider */ + provider: 'claude' | 'zai'; } /** @@ -62,6 +62,40 @@ export const CLAUDE_MODELS: ModelOption[] = [ }, ]; +/** + * Z.ai model options with full metadata for UI display + */ +export const ZAI_MODELS: ModelOption[] = [ + { + id: 'glm-4.7', + label: 'GLM-4.7', + description: 'Flagship model with strong reasoning.', + badge: 'Premium', + provider: 'zai', + }, + { + id: 'glm-4.6v', + label: 'GLM-4.6v', + description: 'Multimodal model with vision support.', + badge: 'Vision', + provider: 'zai', + }, + { + id: 'glm-4.6', + label: 'GLM-4.6', + description: 'Balanced performance with good reasoning.', + badge: 'Balanced', + provider: 'zai', + }, + { + id: 'glm-4.5-air', + label: 'GLM-4.5-Air', + description: 'Fast and efficient for simple tasks.', + badge: 'Speed', + provider: 'zai', + }, +]; + /** * Thinking level options with display labels * @@ -106,6 +140,11 @@ export function getModelDisplayName(model: AgentModel | string): string { haiku: 'Claude Haiku', sonnet: 'Claude Sonnet', opus: 'Claude Opus', + glm: 'GLM-4.5-Air', + 'glm-4.5-air': 'GLM-4.5-Air', + 'glm-4.6': 'GLM-4.6', + 'glm-4.6v': 'GLM-4.6v', + 'glm-4.7': 'GLM-4.7', }; return displayNames[model] || model; } diff --git a/libs/types/src/model.ts b/libs/types/src/model.ts index ab186bafd..00cf400b8 100644 --- a/libs/types/src/model.ts +++ b/libs/types/src/model.ts @@ -7,17 +7,110 @@ export const CLAUDE_MODEL_MAP: Record = { opus: 'claude-opus-4-5-20251101', } as const; +/** + * Model alias mapping for Z.ai (ZhipuAI) models + * + * Note: ZhipuAI's coding API (api/coding/paas/v4) uses different model names. + * glm-4.6v is the only model that supports vision. + */ +export const ZAI_MODEL_MAP: Record = { + // GLM-4.7 - flagship model + 'glm-4.7': 'glm-4.7', + // GLM-4.6v - multimodal model with vision support + 'glm-4.6v': 'glm-4.6v', + // GLM-4.6 + 'glm-4.6': 'glm-4.6', + // GLM-4.5-Air + 'glm-4.5-air': 'glm-4.5-air', + // Short alias (maps to glm-4.5-air as default) + glm: 'glm-4.5-air', +} as const; + /** * Default models per provider */ export const DEFAULT_MODELS = { claude: 'claude-opus-4-5-20251101', + zai: 'glm-4.7', } as const; -export type ModelAlias = keyof typeof CLAUDE_MODEL_MAP; +export type ModelAlias = keyof typeof CLAUDE_MODEL_MAP | keyof typeof ZAI_MODEL_MAP; /** * AgentModel - Alias for ModelAlias for backward compatibility - * Represents available Claude models: "opus" | "sonnet" | "haiku" + * Represents available models: "opus" | "sonnet" | "haiku" | "glm" */ export type AgentModel = ModelAlias; + +/** + * Combined model map for all providers + */ +export const ALL_MODEL_MAPS = { + ...CLAUDE_MODEL_MAP, + ...ZAI_MODEL_MAP, +} as const; + +/** + * Model equivalence mapping for cross-provider fallback + * + * Maps each model to its equivalent in the other provider. + * Used when a provider is disabled but an equivalent model is available. + * + * Mapping hierarchy: + * - Claude Opus (premium) ↔ Zai GLM-4.7 (premium) + * - Claude Sonnet (balanced) ↔ Zai GLM-4.6v (balanced, with vision) + * - Claude Haiku (speed) ↔ Zai GLM-4.5-Air (speed) + * + * NOTE: GLM-4.6 (non-vision) also exists and maps to Claude Sonnet, + * but Claude Sonnet maps to GLM-4.6v to preserve vision capability. + */ +export const MODEL_EQUIVALENCE: Record = { + // Claude models → Zai equivalents + 'claude-opus-4-5-20251101': { provider: 'zai', model: 'glm-4.7' }, + 'claude-sonnet-4-5-20250929': { provider: 'zai', model: 'glm-4.6v' }, + 'claude-haiku-4-5-20251001': { provider: 'zai', model: 'glm-4.5-air' }, + // Zai models → Claude equivalents + 'glm-4.7': { provider: 'claude', model: 'claude-opus-4-5-20251101' }, + 'glm-4.6v': { provider: 'claude', model: 'claude-sonnet-4-5-20250929' }, + 'glm-4.6': { provider: 'claude', model: 'claude-sonnet-4-5-20250929' }, + 'glm-4.5-air': { provider: 'claude', model: 'claude-haiku-4-5-20251001' }, +} as const; + +/** + * Provider name to API key name mapping + * + * Maps internal provider identifiers to user-facing API key names. + * Users see the company name (Anthropic, ZhipuAI) while internally + * we use the model family name (claude, zai). + */ +export const PROVIDER_TO_API_KEY_NAME: Record = { + claude: 'anthropic', + zai: 'zai', +} as const; + +/** + * Get the provider for a given model string + * + * @param model - Model identifier or full model string + * @returns Provider name ('claude' | 'zai') or undefined if unknown + */ +export function getProviderForModel(model: string): 'claude' | 'zai' | undefined { + const lowerModel = model.toLowerCase(); + + // Check Claude models + if ( + lowerModel.includes('claude-') || + lowerModel === 'haiku' || + lowerModel === 'sonnet' || + lowerModel === 'opus' + ) { + return 'claude'; + } + + // Check Zai models + if (lowerModel.startsWith('glm-') || lowerModel === 'glm') { + return 'zai'; + } + + return undefined; +} diff --git a/libs/types/src/provider.ts b/libs/types/src/provider.ts index 53c927176..cccecc803 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -1,5 +1,6 @@ /** * Shared types for AI model providers + * This is the source of truth for provider types across the codebase. */ /** @@ -28,36 +29,161 @@ export interface SystemPromptPreset { append?: string; } +/** + * Thinking configuration for providers that support extended thinking + * Used by Zai (GLM-4.7) and similar providers + */ +export interface ThinkingConfig { + /** Enable or disable thinking mode */ + type: 'enabled' | 'disabled'; + /** Whether to clear previous thinking content (false = preserved thinking) */ + clear_thinking?: boolean; +} + /** * Options for executing a query via a provider + * + * This interface supports all providers through a common set of options, + * with provider-specific fields noted below. */ export interface ExecuteOptions { + // === Required for all providers === + + /** + * The prompt to send to the model + * Can be a simple string or an array of content blocks (for multimodal input) + */ prompt: string | Array<{ type: string; text?: string; source?: object }>; + + /** + * Model identifier (e.g., "claude-opus-4-5-20251101", "glm-4.7") + * The provider is determined automatically from the model prefix + */ model: string; + + /** + * Current working directory for file operations + * Used by tools like Read, Write, Glob, Grep, and Bash + */ cwd: string; - systemPrompt?: string | SystemPromptPreset; + + // === Common optional fields === + + /** + * System prompt to guide the model's behavior + * Can be a simple string or a preset configuration + */ + systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string }; + + /** + * Maximum number of conversation turns (tool call cycles) + * Default: 20 + */ maxTurns?: number; + + /** + * Whitelist of tools the model is allowed to use + * Common tools: Read, Write, Edit, Glob, Grep, Bash + * Claude also supports: WebSearch, WebFetch, MCP tools + */ allowedTools?: string[]; - mcpServers?: Record; + + /** + * Abort controller for cancelling the request + */ abortController?: AbortController; - conversationHistory?: ConversationMessage[]; // Previous messages for context - sdkSessionId?: string; // Claude SDK session ID for resuming conversations - settingSources?: Array<'user' | 'project' | 'local'>; // Sources for CLAUDE.md loading + + /** + * Previous messages for context in multi-turn conversations + */ + conversationHistory?: ConversationMessage[]; + + /** + * Structured output configuration + * When provided, the model will respond with JSON matching the schema + * - Claude: Native support via SDK + * - Zai: Via prompt engineering + JSON response format + */ + outputFormat?: { + type: 'json_schema'; + schema: Record; + }; + + // === Claude-specific fields === + + /** + * [Claude] SDK session ID for resuming conversations + * Enables conversation continuity across requests + */ + sdkSessionId?: string; + + /** + * [Claude] Filesystem setting sources to load + * Controls which CLAUDE.md files are loaded (user, project, local) + */ + settingSources?: Array<'user' | 'project' | 'local'>; + + /** + * [Claude] MCP servers to connect to + * Maps server name to server configuration + */ + mcpServers?: Record; + + // === Zai-specific fields === + + /** + * [Zai] Thinking mode configuration for GLM models + * Enables extended reasoning with preserved context across turns + * - type: 'enabled' | 'disabled' + * - clear_thinking: false preserves thinking, true clears it + */ + thinking?: ThinkingConfig; + + // === Legacy/compatibility fields === + + /** @deprecated Use systemPrompt with preset type instead */ + autoLoadClaudeMd?: boolean; + + /** @deprecated Use mcpServers instead */ + useMcp?: boolean; } /** * Content block in a provider message (matches Claude SDK format) + * Using discriminated unions for type safety */ -export interface ContentBlock { - type: 'text' | 'tool_use' | 'thinking' | 'tool_result'; - text?: string; - thinking?: string; - name?: string; - input?: unknown; - tool_use_id?: string; - content?: string; + +/** Text content block */ +export interface TextBlock { + type: 'text'; + text: string; +} + +/** Tool use content block */ +export interface ToolUseBlock { + type: 'tool_use'; + id?: string; + name: string; + input: unknown; +} + +/** Thinking content block (unified for Claude and Zai) */ +export interface ThinkingBlock { + type: 'thinking'; + thinking: string; +} + +/** Tool result content block */ +export interface ToolResultBlock { + type: 'tool_result'; + tool_use_id: string; + content: string; + is_error?: boolean; } +/** Union of all content block types */ +export type ContentBlock = TextBlock | ToolUseBlock | ThinkingBlock | ToolResultBlock; + /** * Message returned by a provider (matches Claude SDK streaming format) */ @@ -109,6 +235,22 @@ export interface ModelDefinition { maxOutputTokens?: number; supportsVision?: boolean; supportsTools?: boolean; - tier?: 'basic' | 'standard' | 'premium'; + /** @deprecated Use provider-specific feature checks via supportsFeature() */ + supportsExtendedThinking?: boolean; // Claude: extended thinking, Zai: thinking mode + tier?: 'basic' | 'standard' | 'premium' | 'vision'; default?: boolean; } + +/** + * Re-export legacy content block type for backward compatibility + * @deprecated Use the discriminated union types (TextBlock, ToolUseBlock, etc.) instead + */ +export interface LegacyContentBlock { + type: 'text' | 'tool_use' | 'thinking' | 'tool_result'; + text?: string; + thinking?: string; + name?: string; + input?: unknown; + tool_use_id?: string; + content?: string; +} diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index e73e72697..156b9ab26 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -68,7 +68,7 @@ export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full'; export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink'; /** ModelProvider - AI model provider for credentials and API key management */ -export type ModelProvider = 'claude'; +export type ModelProvider = 'claude' | 'zai'; /** * WindowBounds - Electron window position and size for persistence @@ -264,6 +264,15 @@ export interface GlobalSettings { /** Which model to use for GitHub issue validation */ validationModel: AgentModel; + // Provider Configuration + /** Enabled AI providers (controls which providers can be used) */ + enabledProviders: { + /** Claude provider (Anthropic) */ + claude: boolean; + /** Zai provider (ZhipuAI GLM models) */ + zai: boolean; + }; + // Input Configuration /** User's keyboard shortcut bindings */ keyboardShortcuts: KeyboardShortcuts; @@ -320,6 +329,8 @@ export interface Credentials { google: string; /** OpenAI API key (for compatibility or alternative providers) */ openai: string; + /** Z.ai API key (for GLM models) */ + zai: string; }; } @@ -448,6 +459,10 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { muteDoneSound: false, enhancementModel: 'sonnet', validationModel: 'opus', + enabledProviders: { + claude: true, + zai: true, + }, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, aiProfiles: [], projects: [], @@ -468,6 +483,7 @@ export const DEFAULT_CREDENTIALS: Credentials = { anthropic: '', google: '', openai: '', + zai: '', }, }; diff --git a/package-lock.json b/package-lock.json index a3e7176fc..07aeb6570 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", + "express-rate-limit": "^8.2.1", "morgan": "^1.10.1", "node-pty": "1.1.0-beta41", "ws": "^8.18.3" @@ -1242,7 +1243,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -1889,72 +1890,6 @@ "node": ">= 10.0.0" } }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.2", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", - "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/windows-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@emnapi/runtime": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", @@ -2700,17 +2635,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -2819,57 +2743,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-libvips-linux-x64": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", @@ -2962,75 +2835,6 @@ "@img/sharp-libvips-linux-arm64": "1.0.4" } }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, "node_modules/@img/sharp-linux-x64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", @@ -3097,66 +2901,6 @@ "@img/sharp-libvips-linuxmusl-x64": "1.0.4" } }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-win32-x64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", @@ -3491,165 +3235,29 @@ "license": "MIT", "peer": true }, - "node_modules/@next/swc-darwin-arm64": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.10.tgz", - "integrity": "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, + "node_modules/@npmcli/agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, "engines": { - "node": ">= 10" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/@next/swc-darwin-x64": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.10.tgz", - "integrity": "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.10.tgz", - "integrity": "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.10.tgz", - "integrity": "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.10.tgz", - "integrity": "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.10.tgz", - "integrity": "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.10.tgz", - "integrity": "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.0.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz", - "integrity": "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" }, "node_modules/@npmcli/fs": { "version": "4.0.0", @@ -8175,15 +7783,6 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -9509,6 +9108,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -11070,28 +10696,6 @@ "lightningcss-win32-x64-msvc": "1.30.2" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lightningcss-darwin-arm64": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", @@ -11132,28 +10736,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lightningcss-linux-arm-gnueabihf": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", @@ -13574,36 +13156,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -14376,352 +13928,6 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/sharp/node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/sharp/node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",