diff --git a/packages/ai/agents/ai-agent.ts b/packages/ai/agents/ai-agent.ts new file mode 100644 index 0000000..c937839 --- /dev/null +++ b/packages/ai/agents/ai-agent.ts @@ -0,0 +1,186 @@ +/** + * Main AI Agent orchestrator for documentation generation + */ + +import { + AIAgentConfig, + DocumentationRequest, + DocumentationResponse, + GenerateOptions, + GenerateTextOptions, + IAIProvider, + AIProviderError, + AIProvider, + ILogger, + ObservabilityMetadata, +} from '../core/types'; +import { VercelAIProvider } from '../providers/vercel'; +import { ConfigLoader } from '../config/config-loader'; + +/** + * Collection of AI Agent instances for different roles. + */ +export interface AIAgents { + planner: AIAgent; + writer: AIAgent; + researcher?: AIAgent; + reviewer?: AIAgent; +} + +/** + * AI Agent for generating documentation from code changes + */ +export class AIAgent { + private provider: IAIProvider; + private config: AIAgentConfig; + private retryConfig: { maxAttempts: number; delayMs: number }; + private logger?: ILogger; + private role?: string; + + constructor(config: AIAgentConfig, role?: string) { + this.config = config; + this.retryConfig = config.retry || { maxAttempts: 3, delayMs: 1000 }; + this.logger = config.logger; + this.role = role; + + // Initialize the appropriate provider + this.provider = new VercelAIProvider(config.model, config.debug, this.logger); + } + + /** + * Generate documentation for a code change + */ + async generateDocumentation( + request: DocumentationRequest, + _options: GenerateOptions = {}, + ): Promise { + this.log('Generating documentation', { + symbol: request.symbolName, + provider: this.provider.provider, + }); + + return this.executeWithRetry(() => this.provider.generateDocumentation(request)); + } + + /** + * Generate generic text using the AI provider + */ + async generateText( + prompt: string, + options: GenerateTextOptions = {}, + metadata?: ObservabilityMetadata, + ): Promise { + this.log('Generating text', { + promptLength: prompt.length, + model: this.config.model.modelId, + }); + + const mergedMetadata: ObservabilityMetadata = { + ...metadata, + agentRole: (this.role as any) || metadata?.agentRole, + tags: [...(metadata?.tags || []), ...(this.role ? [`agent-${this.role}`] : [])], + }; + + if (this.provider.generateText) { + return this.executeWithRetry(() => + this.provider.generateText!(prompt, options, mergedMetadata), + ); + } + + throw new Error(`Provider ${this.config.model.provider} does not support text generation`); + } + + /** + * Validate the AI provider connection + */ + async validateConnection(): Promise { + this.log('Validating provider connection for model: ' + this.config.model.modelId); + + try { + return await this.provider.validateConnection(); + } catch (error: unknown) { + this.log('Connection validation failed:', error); + return false; + } + } + + /** + * Execute a function with retry logic + */ + private async executeWithRetry(fn: () => Promise, attempt: number = 1): Promise { + try { + return await fn(); + } catch (error: unknown) { + const isRetryable = this.isRetryableError(error); + const hasAttemptsLeft = attempt < this.retryConfig.maxAttempts; + + if (isRetryable && hasAttemptsLeft) { + this.log(`Attempt ${attempt} failed, retrying in ${this.retryConfig.delayMs}ms...`); + + await new Promise((resolve) => setTimeout(resolve, this.retryConfig.delayMs)); + return this.executeWithRetry(fn, attempt + 1); + } + + throw error; + } + } + + private isRetryableError(error: unknown): boolean { + if (error instanceof AIProviderError) { + return ['TIMEOUT', 'RATE_LIMIT', 'NETWORK_ERROR'].includes(error.code); + } + return false; + } + + private log(message: string, ...args: unknown[]): void { + if (this.config.debug) { + const prefix = `[AIAgent${this.role ? `:${this.role}` : ''}]`; + if (this.logger) { + this.logger.debug(`${prefix} ${message}`, ...args); + } else { + console.log(prefix, message, ...args); + } + } + } + + getProvider(): AIProvider { + return this.config.model.provider; + } + + getModelId(): string { + return this.config.model.modelId; + } +} + +/** + * Orchestrator factory to create agents from environment + */ +export function createAIAgentsFromEnv( + options: { + debug?: boolean; + timeout?: number; + logger?: ILogger; + } = {}, +): AIAgents { + const config = ConfigLoader.loadFromEnv(); + const agents: Partial = {}; + + for (const [role, modelConfig] of Object.entries(config.roles)) { + const agentConfig: AIAgentConfig = { + model: modelConfig, + debug: options.debug, + timeout: options.timeout, + logger: options.logger, + }; + + (agents as any)[role] = new AIAgent(agentConfig, role); + + if (options.logger) { + options.logger.debug( + `[AIAgentManager] ${role} initialized with ${modelConfig.modelId} (${modelConfig.provider})`, + ); + } + } + + return agents as AIAgents; +} diff --git a/packages/ai/ai-agent.ts b/packages/ai/ai-agent.ts deleted file mode 100644 index a9eb09e..0000000 --- a/packages/ai/ai-agent.ts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * Main AI Agent orchestrator for documentation generation - */ - -import { - AIAgentConfig, - DocumentationRequest, - DocumentationResponse, - GenerateOptions, - GenerateTextOptions, - IAIProvider, - AIProviderError, - AIAgentRoleConfig, - AIProvider, // Import AIProvider type here - ILogger, - ObservabilityMetadata, -} from './types'; -import { VercelAIProvider } from './providers/vercel-ai-provider'; - -import { getDefaultModel } from './constants'; - -/** - * Collection of AI Agent instances for different roles. - */ -export interface AIAgents { - planner: AIAgent; - writer: AIAgent; - researcher?: AIAgent; - reviewer?: AIAgent; -} - -/** - * AI Agent for generating documentation from code changes - */ -export class AIAgent { - private provider: IAIProvider; - private config: AIAgentConfig; - private retryConfig: { maxAttempts: number; delayMs: number }; - private logger?: ILogger; - private role?: 'planner' | 'writer' | 'researcher' | 'reviewer'; - - constructor(config: AIAgentConfig, role?: 'planner' | 'writer' | 'researcher' | 'reviewer') { - this.config = config; - this.retryConfig = config.retry || { maxAttempts: 3, delayMs: 1000 }; - this.logger = config.logger; - this.role = role; - - // Initialize the appropriate provider - this.provider = this.createProvider(); - } - - /** - * Create provider based on configuration - */ - private createProvider(): IAIProvider { - const { model, debug } = this.config; - - // Use Vercel AI SDK for all supported providers - return new VercelAIProvider(model, debug, this.logger); - } - - /** - * Generate documentation for a code change - */ - async generateDocumentation( - request: DocumentationRequest, - _options: GenerateOptions = {}, - ): Promise { - this.log('Generating documentation', { - symbol: request.symbolName, - provider: this.provider.provider, - }); - - // TODO: Use options to customize generation (currently passed to provider) - return this.executeWithRetry(() => this.provider.generateDocumentation(request)); - } - - async generateBatch( - items: Array<{ symbolName: string; signatureText: string }>, - prompt: string, - systemPrompt: string, - ): Promise> { - this.log(`Generating batch documentation for ${items.length} items`); - - // Try batch generation if provider supports it - if (this.provider.generateBatchDocumentation) { - try { - const batchResult = await this.executeWithRetry(() => - this.provider.generateBatchDocumentation!(items, prompt, systemPrompt), - ); - - // Start with successful generations from batch - const results = [...batchResult.success]; - - // Log batch statistics - if (batchResult.stats.failed > 0) { - this.log( - `Batch generated ${batchResult.stats.succeeded}/${batchResult.stats.total} successfully. ` + - `Retrying ${batchResult.stats.failed} failed items sequentially...`, - ); - - // For the sake of refactor, removing complex batch retry fallback that depends on single prompt logic - // as it's not directly relevant to the current Model Routing task and would require more complex changes - // to pass single prompts or dynamically generate them here. - this.log( - `Batch retry fallback currently not supported for ${batchResult.stats.failed} failed items.`, - ); - } else { - this.log( - `Batch completed successfully: ${batchResult.stats.succeeded}/${batchResult.stats.total}`, - ); - } - - return results; - } catch (error: unknown) { - // Use unknown for caught errors - this.log('Batch generation failed completely', error); - } - } - - this.log('Batch generation not supported by provider or failed, returning empty.'); - return []; - } - - /** - * Generate generic text using the AI provider - */ - async generateText( - prompt: string, - options: GenerateTextOptions = {}, - metadata?: ObservabilityMetadata, - ): Promise { - this.log('Generating text', { - promptLength: prompt.length, - model: this.config.model.modelId, - }); - - // Merge role metadata with provided metadata - const mergedMetadata: ObservabilityMetadata = { - ...metadata, - agentRole: this.role || metadata?.agentRole, - tags: [...(metadata?.tags || []), ...(this.role ? [`agent-${this.role}`] : [])], - }; - - if (this.provider.generateText) { - return this.executeWithRetry(() => - this.provider.generateText!(prompt, options, mergedMetadata), - ); - } - - throw new Error( - `Provider ${this.config.model.provider} with model ${this.config.model.modelId} does not support text generation`, - ); - } - - /** - * Validate the AI provider connection - */ - async validateConnection(): Promise { - this.log('Validating provider connection for model: ' + this.config.model.modelId); - - try { - return await this.provider.validateConnection(); - } catch (error: unknown) { - // Use unknown for caught errors - this.log('Connection validation failed:', error); - return false; - } - } - - /** - * Execute a function with retry logic - */ - private async executeWithRetry(fn: () => Promise, attempt: number = 1): Promise { - try { - return await fn(); - } catch (error: unknown) { - // Use unknown for caught errors - const isRetryable = this.isRetryableError(error); - const hasAttemptsLeft = attempt < this.retryConfig.maxAttempts; - - if (isRetryable && hasAttemptsLeft) { - this.log( - `Attempt ${attempt} for model ${this.config.model.modelId} failed, retrying in ${this.retryConfig.delayMs}ms...`, - ); - - await this.delay(this.retryConfig.delayMs); - return this.executeWithRetry(fn, attempt + 1); - } - - // No more retries or non-retryable error - throw error; - } - } - - /** - * Check if an error is retryable - */ - private isRetryableError(error: unknown): boolean { - if (error instanceof AIProviderError && error.code) { - // Check if it's an instance of AIProviderError - const providerError = error as AIProviderError; - - // Retry on timeout, rate limit, and network errors - return ['TIMEOUT', 'RATE_LIMIT', 'NETWORK_ERROR'].includes(providerError.code); - } - - return false; - } - - /** - * Delay utility for retry logic - */ - private delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - /** - * Log debug messages - */ - /** - * Log debug messages - */ - private log(message: string, ...args: unknown[]): void { - if (this.config.debug) { - if (this.logger) { - this.logger.debug(`[AIAgent ${this.config.model.modelId}] ${message}`, ...args); - } else { - console.log(`[AIAgent ${this.config.model.modelId}]`, message, ...args); - } - } - } - - /** - * Get the current provider name - */ - getProvider(): AIProvider { - // Use AIProvider type - return this.config.model.provider; - } - - /** - * Get the current model ID - */ - getModelId(): string { - return this.config.model.modelId; - } - - /** - * Get a summary of configuration - */ - getConfig(): { provider: AIProvider; model: string; timeout: number } { - // Use AIProvider type - return { - provider: this.config.model.provider, - model: this.config.model.modelId, - timeout: this.config.timeout || 30000, - }; - } -} - -/** - * Helper to normalize logging calls (fallback to console if no logger) - */ -function getLogger(logger?: ILogger) { - return { - debug: (msg: string, ...args: unknown[]) => - logger ? logger.debug(msg, ...args) : console.log(msg, ...args), - info: (msg: string, ...args: unknown[]) => - logger ? logger.info(msg, ...args) : console.log(msg, ...args), - warn: (msg: string, ...args: unknown[]) => - logger ? logger.warn(msg, ...args) : console.warn(msg, ...args), - error: (msg: string, ...args: unknown[]) => - logger ? logger.error(msg, ...args) : console.error(msg, ...args), - }; -} - -/** - * Internal helper to create a single AI Agent based on role configuration and ENV vars. - * @param roleOptions Specific options for this role, potentially overriding ENV. - * @param globalOptions Global options like debug flag. - */ -function _createSingleAgentFromEnv( - roleOptions: AIAgentRoleConfig, - globalOptions: { debug?: boolean; timeout?: number; logger?: ILogger }, -): AIAgentConfig { - // Require OpenAI API key - const openaiKey = process.env.OPENAI_API_KEY; - - if (!openaiKey) { - throw new Error( - `OPENAI_API_KEY is required. Please set it in your .env file. ` + - `Note: HELICONE_API_KEY can be used for observability tracking, but OPENAI_API_KEY is still required.`, - ); - } - - const modelId = roleOptions.modelId || getDefaultModel('openai') || 'gpt-4o'; - return { - model: { - provider: 'openai', - modelId: modelId, - apiKey: openaiKey, - maxTokens: roleOptions.maxTokens, - temperature: roleOptions.temperature, - }, - timeout: globalOptions.timeout, - debug: globalOptions.debug, - logger: globalOptions.logger, - }; -} - -export function createAIAgentsFromEnv( - globalOptions: { - debug?: boolean; - timeout?: number; - maxTokens?: number; - temperature?: number; - logger?: ILogger; - } = {}, - roleConfigs: { - planner?: AIAgentRoleConfig; - writer?: AIAgentRoleConfig; - researcher?: AIAgentRoleConfig; - reviewer?: AIAgentRoleConfig; - } = {}, -): AIAgents { - const roles = ['planner', 'writer', 'researcher', 'reviewer'] as const; - - // Check if we have OpenAI API key - if (!process.env.OPENAI_API_KEY) { - throw new Error( - 'OPENAI_API_KEY is required. Please set it in your .env file.\n' + - 'Note: You can also set HELICONE_API_KEY for observability tracking.', - ); - } - - // OpenAI model defaults - const modelDefaults = { - planner: 'gpt-5-mini', - writer: 'gpt-5-mini', - researcher: 'gpt-5-mini', - reviewer: 'gpt-5-nano', - }; - - // Use Record type for better type safety instead of 'any' - const agents: Record = {}; - for (const role of roles) { - const roleConfig = roleConfigs?.[role]; - const options: AIAgentRoleConfig = { - modelId: roleConfig?.modelId || modelDefaults[role], - provider: 'openai', // Only OpenAI supported - maxTokens: roleConfig?.maxTokens, - temperature: roleConfig?.temperature, - }; - - const agent = new AIAgent(_createSingleAgentFromEnv(options, globalOptions), role); - agents[role] = agent; - - const log = getLogger(globalOptions.logger); - log.debug( - `[AIAgentManager] ${role.charAt(0).toUpperCase() + role.slice(1)} initialized with ${agent.getModelId()} (${agent.getProvider()})`, - ); - } - - return agents as unknown as AIAgents; -} diff --git a/packages/ai/config/config-loader.ts b/packages/ai/config/config-loader.ts new file mode 100644 index 0000000..79514ac --- /dev/null +++ b/packages/ai/config/config-loader.ts @@ -0,0 +1,93 @@ +import { AIProvider, AIModel } from '../core/types'; +import { DEFAULT_MODELS, DEFAULT_TEMPERATURES } from '../core/constants'; + +export interface AIAgentsConfig { + roles: Record; +} + +export class ConfigLoader { + /** + * Loads agent configurations from environment variables + */ + static loadFromEnv(): AIAgentsConfig { + const globalProvider = (process.env.AI_PROVIDER as AIProvider) || 'openai'; + + const globalDefaults = { + provider: globalProvider, + modelId: process.env.AI_MODEL_ID || DEFAULT_MODELS[globalProvider], + maxTokens: parseInt(process.env.AI_MAX_TOKENS || '2000'), + temperature: parseFloat( + process.env.AI_TEMPERATURE || String(DEFAULT_TEMPERATURES.writer), + ), + endpoint: process.env.AI_ENDPOINT, + heliconeApiKey: process.env.HELICONE_API_KEY, + }; + + const roles = { + planner: this.loadRoleModel('PLANNER', globalDefaults, DEFAULT_TEMPERATURES.planner), + researcher: this.loadRoleModel( + 'RESEARCHER', + globalDefaults, + DEFAULT_TEMPERATURES.researcher, + ), + writer: this.loadRoleModel('WRITER', globalDefaults, DEFAULT_TEMPERATURES.writer), + reviewer: this.loadRoleModel('REVIEWER', globalDefaults, DEFAULT_TEMPERATURES.reviewer), + }; + + return { roles }; + } + + private static loadRoleModel( + prefix: string, + fallback: { + provider: AIProvider; + modelId: string; + maxTokens: number; + temperature: number; + endpoint?: string; + heliconeApiKey?: string; + }, + defaultTemp: number, + ): AIModel { + const provider = (process.env[`${prefix}_PROVIDER`] as AIProvider) || fallback.provider; + const apiKey = this.getApiKey(provider); + + return { + provider, + apiKey, + modelId: + process.env[`${prefix}_MODEL_ID`] || + (provider === fallback.provider ? fallback.modelId : DEFAULT_MODELS[provider]), + temperature: process.env[`${prefix}_TEMPERATURE`] + ? parseFloat(process.env[`${prefix}_TEMPERATURE`]!) + : provider === fallback.provider + ? fallback.temperature + : defaultTemp, + maxTokens: process.env[`${prefix}_MAX_TOKENS`] + ? parseInt(process.env[`${prefix}_MAX_TOKENS`]!) + : fallback.maxTokens, + endpoint: process.env[`${prefix}_ENDPOINT`] || fallback.endpoint, + observability: { + heliconeApiKey: + process.env[`${prefix}_HELICONE_API_KEY`] || fallback.heliconeApiKey, + }, + }; + } + + private static getApiKey(provider: AIProvider): string { + const keys: Record = { + openai: process.env.OPENAI_API_KEY || process.env.LDR_OPENAI_API_KEY, + gemini: process.env.GEMINI_API_KEY || process.env.LDR_GEMINI_API_KEY, + anthropic: process.env.ANTHROPIC_API_KEY || process.env.LDR_ANTHROPIC_API_KEY, + mistral: process.env.MISTRAL_API_KEY || process.env.LDR_MISTRAL_API_KEY, + }; + + const key = keys[provider]; + if (!key) { + throw new Error( + `API Key for provider ${provider} is missing (tried ${provider.toUpperCase()}_API_KEY and LDR_${provider.toUpperCase()}_API_KEY)`, + ); + } + return key; + } +} diff --git a/packages/ai/constants.ts b/packages/ai/core/constants.ts similarity index 75% rename from packages/ai/constants.ts rename to packages/ai/core/constants.ts index 187951e..1cca355 100644 --- a/packages/ai/constants.ts +++ b/packages/ai/core/constants.ts @@ -15,6 +15,16 @@ export const DEFAULT_MODELS: Record = { mistral: 'mistral-small-latest', } as const; +/** + * Default temperatures for different agent roles + */ +export const DEFAULT_TEMPERATURES = { + planner: 0.2, + researcher: 0.3, + writer: 0.7, + reviewer: 0.1, +} as const; + /** * Get the default model ID for a given provider */ diff --git a/packages/ai/core/index.ts b/packages/ai/core/index.ts new file mode 100644 index 0000000..33c8572 --- /dev/null +++ b/packages/ai/core/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './constants'; diff --git a/packages/ai/types.ts b/packages/ai/core/types.ts similarity index 79% rename from packages/ai/types.ts rename to packages/ai/core/types.ts index b160d0d..0a278dd 100644 --- a/packages/ai/types.ts +++ b/packages/ai/core/types.ts @@ -3,6 +3,8 @@ */ import { CodeSignature } from '@sintesi/core'; +import { ILogger } from '@sintesi/shared'; +export { ILogger }; /** * Supported AI providers @@ -30,18 +32,11 @@ export interface AIModel { /** API endpoint (optional, for custom endpoints) */ endpoint?: string; -} -/** - * Interface for a logger to be injected into the AI module - */ -export interface ILogger { - debug(message: string, ...args: unknown[]): void; - info(message: string, ...args: unknown[]): void; - warn(message: string, ...args: unknown[]): void; - error(message: string, ...args: unknown[]): void; - success?(message: string, ...args: unknown[]): void; - log?(message: string, ...args: unknown[]): void; + /** Observability configuration (e.g., Helicone) */ + observability?: { + heliconeApiKey?: string; + }; } /** @@ -117,12 +112,23 @@ export interface DocumentationResponse { timestamp: number; } +/** + * Common error codes for AI providers + */ +export type AIErrorCode = + | 'TIMEOUT' + | 'RATE_LIMIT' + | 'NETWORK_ERROR' + | 'INVALID_API_KEY' + | 'GENERATION_FAILED' + | 'MODEL_NOT_FOUND'; + /** * Error from AI provider */ export class AIProviderError extends Error { /** Error code */ - code: string; + code: AIErrorCode; /** Provider name */ provider: AIProvider; @@ -130,7 +136,7 @@ export class AIProviderError extends Error { /** Original error (if available) */ originalError?: unknown; - constructor(code: string, message: string, provider: AIProvider, originalError?: unknown) { + constructor(code: AIErrorCode, message: string, provider: AIProvider, originalError?: unknown) { super(message); this.name = 'AIProviderError'; this.code = code; @@ -286,3 +292,62 @@ export interface GenerateOptions { /** Observability metadata for tracking */ metadata?: ObservabilityMetadata; } + +/** + * Structured data for a single parameter + */ +export interface Parameter { + name: string; + type: string; + description: string; + optional?: boolean; + defaultValue?: string; +} + +/** + * Structured data for return type information + */ +export interface ReturnTypeInfo { + type: string; + description: string; +} + +/** + * Structured data for properties (interfaces, classes) + */ +export interface Property { + name: string; + type: string; + description: string; + optional?: boolean; + readonly?: boolean; +} + +/** + * Core documentation structure + */ +export interface DocumentationStructure { + symbolName: string; + purpose: string; + parameters?: Parameter[]; + returnType?: ReturnTypeInfo; + usageExample?: string; + notes?: string[]; +} + +/** + * Extended documentation structure for complex types + */ +export interface ExtendedDocumentation extends DocumentationStructure { + properties?: Property[]; + methods?: DocumentationStructure[]; + extends?: string; + implements?: string[]; +} + +/** + * Batch documentation structure + */ +export interface BatchDocumentationStructure { + documentations: DocumentationStructure[]; +} diff --git a/packages/ai/markdown-builder.ts b/packages/ai/documentation/markdown-builder.ts similarity index 53% rename from packages/ai/markdown-builder.ts rename to packages/ai/documentation/markdown-builder.ts index aa21650..5349150 100644 --- a/packages/ai/markdown-builder.ts +++ b/packages/ai/documentation/markdown-builder.ts @@ -1,41 +1,34 @@ /** * Deterministic Markdown builder from structured documentation - * - * This module takes structured JSON documentation and builds consistent, - * well-formatted Markdown output using templates. This approach eliminates - * JSON artifacts and ensures all documentation follows the same format. */ -import type { DocumentationStructure, Parameter, ReturnTypeInfo } from './structured-schema'; +import type { DocumentationStructure, Parameter, ReturnTypeInfo } from '../core/types'; /** * Build Markdown documentation from structured data - * - * This is the core function that converts JSON structure to Markdown. - * The template is deterministic - the same input always produces the same output. */ export function buildMarkdownFromStructure(doc: DocumentationStructure): string { const parts: string[] = []; - // 1. Purpose (always present) - parts.push(`**Purpose:** ${doc.purpose}`); + // 1. Purpose + parts.push('**Purpose:** ' + doc.purpose); - // 2. Parameters (if present) + // 2. Parameters if (doc.parameters && doc.parameters.length > 0) { parts.push(''); parts.push('**Parameters:**'); - doc.parameters.forEach((param) => { + for (const param of doc.parameters) { parts.push(formatParameter(param)); - }); + } } - // 3. Return Type (if present) + // 3. Return Type if (doc.returnType) { parts.push(''); parts.push(formatReturnType(doc.returnType)); } - // 4. Usage Example (if present) + // 4. Usage Example if (doc.usageExample) { parts.push(''); parts.push('**Usage Example:**'); @@ -44,13 +37,13 @@ export function buildMarkdownFromStructure(doc: DocumentationStructure): string parts.push('```'); } - // 5. Notes (if present) + // 5. Notes if (doc.notes && doc.notes.length > 0) { parts.push(''); parts.push('**Notes:**'); - doc.notes.forEach((note) => { - parts.push(`- ${note}`); - }); + for (const note of doc.notes) { + parts.push('- ' + note); + } } return parts.join('\n'); @@ -60,36 +53,30 @@ export function buildMarkdownFromStructure(doc: DocumentationStructure): string * Format a single parameter */ function formatParameter(param: Parameter): string { - const parts: string[] = [`- \`${param.name}\``]; + let line = '- `' + param.name + '`'; - // Add optional marker if (param.optional) { - parts.push('(optional)'); + line += ' (optional)'; } - // Add type - parts.push(`(\`${param.type}\`):`); + line += ' (`' + param.type + '`): ' + param.description; - // Add description - parts.push(param.description); - - // Add default value if present if (param.defaultValue) { - parts.push(`Default: \`${param.defaultValue}\``); + line += ' Default: `' + param.defaultValue + '`'; } - return parts.join(' '); + return line; } /** * Format return type information */ function formatReturnType(returnType: ReturnTypeInfo): string { - return `**Returns:** \`${returnType.type}\` - ${returnType.description}`; + return '**Returns:** `' + returnType.type + '` - ' + returnType.description; } /** - * Build markdown for multiple symbols (used in batch generation) + * Build markdown for multiple symbols */ export function buildBatchMarkdown( docs: DocumentationStructure[], diff --git a/packages/ai/structured-schema.ts b/packages/ai/documentation/structured-schema.ts similarity index 85% rename from packages/ai/structured-schema.ts rename to packages/ai/documentation/structured-schema.ts index 384a7d8..5ccc10e 100644 --- a/packages/ai/structured-schema.ts +++ b/packages/ai/documentation/structured-schema.ts @@ -6,6 +6,14 @@ */ import { z } from 'zod'; +import type { + Parameter, + ReturnTypeInfo, + DocumentationStructure, + BatchDocumentationStructure, + Property, + ExtendedDocumentation, +} from '../core/types'; /** * Schema for a single parameter @@ -24,9 +32,7 @@ export const ParameterSchema = z.object({ .describe('Brief description of what this parameter does'), optional: z.boolean().optional().describe('Whether this parameter is optional'), defaultValue: z.string().trim().optional().describe('Default value if any'), -}); - -export type Parameter = z.infer; +}) as z.ZodType; /** * Schema for return type information @@ -42,14 +48,12 @@ export const ReturnTypeSchema = z.object({ .trim() .min(1, 'Return type description cannot be empty') .describe('Description of what is returned'), -}); - -export type ReturnTypeInfo = z.infer; +}) as z.ZodType; /** - * Schema for a single documentation entry + * Internal Zod Object for DocumentationStructure to allow extension */ -export const DocumentationStructureSchema = z.object({ +const DocumentationStructureObject = z.object({ symbolName: z .string() .trim() @@ -78,16 +82,18 @@ export const DocumentationStructureSchema = z.object({ .describe('Additional notes, caveats, or important information'), }); -export type DocumentationStructure = z.infer; +/** + * Schema for a single documentation entry + */ +export const DocumentationStructureSchema = + DocumentationStructureObject as z.ZodType; /** * Schema for batch documentation generation */ export const BatchDocumentationStructureSchema = z.object({ documentations: z.array(DocumentationStructureSchema), -}); - -export type BatchDocumentationStructure = z.infer; +}) as z.ZodType; /** * Schema for properties (interfaces, classes) @@ -102,18 +108,14 @@ export const PropertySchema = z.object({ .describe('What this property represents'), optional: z.boolean().optional().describe('Whether this property is optional'), readonly: z.boolean().optional().describe('Whether this property is readonly'), -}); - -export type Property = z.infer; +}) as z.ZodType; /** * Extended schema for complex types (classes, interfaces) */ -export const ExtendedDocumentationSchema = DocumentationStructureSchema.extend({ +export const ExtendedDocumentationSchema = DocumentationStructureObject.extend({ properties: z.array(PropertySchema).optional().describe('Properties for classes/interfaces'), methods: z.array(DocumentationStructureSchema).optional().describe('Methods for classes'), extends: z.string().optional().describe('Parent class or extended interfaces'), implements: z.array(z.string()).optional().describe('Implemented interfaces'), -}); - -export type ExtendedDocumentation = z.infer; +}) as z.ZodType; diff --git a/packages/ai/index.ts b/packages/ai/index.ts index c0f7af5..aea8521 100644 --- a/packages/ai/index.ts +++ b/packages/ai/index.ts @@ -1,12 +1,6 @@ -/** - * AI Agent module - * - * Provides AI-powered documentation generation using OpenAI, Gemini, Anthropic or Mistral APIs - */ - -export * from './types'; -export * from './ai-agent'; // Exports AIAgent, createOpenAIAgent, createAIAgentsFromEnv -export * from './providers/vercel-ai-provider'; -export * from './constants'; -export * from './structured-schema'; -export * from './markdown-builder'; +export * from './core'; +export * from './config/config-loader'; +export * from './agents/ai-agent'; +export * from './documentation/structured-schema'; +export * from './documentation/markdown-builder'; +export * from './providers/vercel'; diff --git a/packages/ai/providers/vercel-ai-provider.ts b/packages/ai/providers/vercel-ai-provider.ts deleted file mode 100644 index 218f819..0000000 --- a/packages/ai/providers/vercel-ai-provider.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { - IAIProvider, - AIProvider, - DocumentationRequest, - DocumentationResponse, - AIModel, - AIProviderError, - BatchDocumentationResult, - ILogger, - ObservabilityMetadata, - GenerateTextOptions, -} from '../types'; -import { generateObject, generateText } from 'ai'; -import { createOpenAI } from '@ai-sdk/openai'; -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createMistral } from '@ai-sdk/mistral'; -import { createHelicone } from '@helicone/ai-sdk-provider'; -import { - BatchDocumentationStructureSchema, - DocumentationStructureSchema, - type DocumentationStructure, -} from '../structured-schema'; -import { buildMarkdownFromStructure } from '../markdown-builder'; -import { z } from 'zod'; - -export class VercelAIProvider implements IAIProvider { - readonly provider: AIProvider; - private modelConfig: AIModel; - private debug: boolean; - private logger?: ILogger; - - constructor(config: AIModel, debug: boolean = false, logger?: ILogger) { - this.provider = config.provider; - this.modelConfig = config; - this.debug = debug; - this.logger = logger; - } - - /** - * Get model with Helicone fallback - * Tries to use Helicone if configured, falls back to native provider on failure - */ - private getModel(metadata?: ObservabilityMetadata) { - const heliconeApiKey = process.env.HELICONE_API_KEY; - - // If Helicone is configured and we have metadata, try to use it as a proxy - if (heliconeApiKey && metadata) { - try { - const helicone = createHelicone({ apiKey: heliconeApiKey }); - - // Log when Helicone is successfully initialized - if (this.logger) { - this.logger.info('📊 Helicone observability enabled'); - } else if (this.debug) { - console.log('[VercelAIProvider] ✓ Using Helicone proxy for observability'); - } - - return helicone(this.modelConfig.modelId, { - extraBody: { - helicone: this.buildHeliconeConfig(metadata), - }, - }); - } catch (error) { - // Log warning and fall back to native provider - if (this.logger) { - this.logger.warn( - `[VercelAIProvider] Helicone initialization failed, falling back to native provider: ${error}`, - ); - } else { - console.warn( - `[VercelAIProvider] Helicone initialization failed, falling back to native provider:`, - error, - ); - } - // Fall through to native provider - } - } - - // Use the standard native provider (fallback or when Helicone not configured) - return this.getNativeModel(); - } - - /** - * Get native model without Helicone proxy - */ - private getNativeModel() { - switch (this.provider) { - case 'openai': { - const provider = createOpenAI({ - apiKey: this.modelConfig.apiKey, - baseURL: this.modelConfig.endpoint, - }); - return provider(this.modelConfig.modelId); - } - case 'gemini': { - const provider = createGoogleGenerativeAI({ - apiKey: this.modelConfig.apiKey, - }); - return provider(this.modelConfig.modelId); - } - case 'anthropic': { - const provider = createAnthropic({ - apiKey: this.modelConfig.apiKey, - }); - return provider(this.modelConfig.modelId); - } - case 'mistral': { - const provider = createMistral({ - apiKey: this.modelConfig.apiKey, - }); - return provider(this.modelConfig.modelId); - } - default: - throw new Error(`Unsupported provider: ${this.provider}`); - } - } - - /** - * Build Helicone configuration from observability metadata - */ - private buildHeliconeConfig(metadata?: ObservabilityMetadata): any { - if (!metadata) return {}; - - const heliconeConfig: any = {}; - - if (metadata.sessionId) { - heliconeConfig.sessionId = metadata.sessionId; - } - - if (metadata.sessionName) { - heliconeConfig.sessionName = metadata.sessionName; - } - - if (metadata.userId) { - heliconeConfig.userId = metadata.userId; - } - - if (metadata.tags || metadata.agentRole) { - heliconeConfig.tags = [ - ...(metadata.tags || []), - ...(metadata.agentRole ? [metadata.agentRole] : []), - ]; - } - - if (metadata.properties) { - heliconeConfig.properties = metadata.properties; - } - - return heliconeConfig; - } - - async generateDocumentation(request: DocumentationRequest): Promise { - const model = this.getModel(); - - // Use structured prompt from request - const prompt = request.prompt; - const systemPrompt = request.systemPrompt; - - try { - const options: any = { - model, - prompt, - system: systemPrompt, - schema: z.object({ - documentation: DocumentationStructureSchema, - }), - }; - - if (this.modelConfig.maxTokens) { - options.maxTokens = this.modelConfig.maxTokens; - } - - if (this.modelConfig.temperature !== undefined) { - options.temperature = this.modelConfig.temperature; - } - - const result: any = await generateObject(options); - const doc: DocumentationStructure = result.object.documentation; - const usageAny = result.usage as any; - - // Build Markdown from structure (Zod schema already validated the data) - const markdown = buildMarkdownFromStructure(doc); - - return { - content: markdown, - provider: this.provider, - modelId: this.modelConfig.modelId, - timestamp: Date.now(), - usage: result.usage - ? { - promptTokens: usageAny.promptTokens || 0, - completionTokens: usageAny.completionTokens || 0, - totalTokens: usageAny.totalTokens || 0, - } - : undefined, - }; - } catch (error) { - const err = error as any; - let code = 'GENERATION_FAILED'; - const message = err.message || 'Unknown error during generation'; - - // Map common error codes if possible - if (err.name === 'APICallError' && err.statusCode === 429) { - code = 'RATE_LIMIT'; - } - - throw new AIProviderError(code, message, this.provider, error); - } - } - - async generateBatchDocumentation( - items: Array<{ symbolName: string; signatureText: string }>, - prompt: string, - systemPrompt: string, - ): Promise { - const model = this.getModel(); - - try { - const options: any = { - model, - prompt, - system: systemPrompt, - schema: BatchDocumentationStructureSchema, - }; - - // Increase token limit for batches - if (this.modelConfig.maxTokens) { - options.maxTokens = this.modelConfig.maxTokens * items.length; - } else { - // Default generous limit for batches if not specified - options.maxTokens = 4096; - } - - if (this.modelConfig.temperature !== undefined) { - options.temperature = this.modelConfig.temperature; - } - - const result: any = await generateObject(options); - - // Build Markdown from each structured documentation entry - // (Zod schema already validated all data during parsing) - const success: Array<{ symbolName: string; content: string }> = - result.object.documentations.map((doc: DocumentationStructure) => { - const markdown = buildMarkdownFromStructure(doc); - - if (this.debug) { - if (this.logger) { - this.logger.debug( - `[VercelAIProvider] Generated markdown for ${doc.symbolName}`, - ); - } else { - console.log( - `[VercelAIProvider] Generated markdown for ${doc.symbolName}`, - ); - } - } - - return { - symbolName: doc.symbolName, - content: markdown, - }; - }); - - return { - success, - failures: [], - stats: { - total: items.length, - succeeded: success.length, - failed: 0, - }, - }; - } catch (error) { - const err = error as any; - // Complete batch failure (network, API error, etc.) - if (this.logger) { - this.logger.warn( - `[VercelAIProvider] Batch generation failed completely: ${err.message}`, - ); - } else { - console.warn( - `[VercelAIProvider] Batch generation failed completely: ${err.message}`, - ); - } - - // Return empty result with all items marked as failures - return { - success: [], - failures: items.map((item) => ({ - symbolName: item.symbolName, - errors: [`Batch generation error: ${err.message}`], - })), - stats: { - total: items.length, - succeeded: 0, - failed: items.length, - }, - }; - } - } - - async validateConnection(): Promise { - const model = this.getModel(); - // Check for reasoning models (o1-*, o3-*) which have special parameter restrictions - const isReasoningModel = /^o[13]-/.test(this.modelConfig.modelId); - - try { - // Simple ping to validate key - const options: any = { - model, - prompt: 'Hello', - }; - - // Reasoning models (o1, o3) do not support maxTokens in the same way - if (!isReasoningModel) { - options.maxTokens = 5; - } - - await generateText(options); - return true; - } catch (error) { - if (this.debug) { - if (this.logger) { - this.logger.error('Connection validation failed:', error); - } else { - console.error('Connection validation failed:', error); - } - } - return false; - } - } - - /** - * Generate plain text from a prompt (useful for non-documentation tasks) - */ - async generateText( - prompt: string, - options: GenerateTextOptions = {}, - metadata?: ObservabilityMetadata, - ): Promise { - const model = this.getModel(metadata); - // Check for reasoning models (o1-*, o3-*) which have special parameter restrictions - const isReasoningModel = /^o[13]-/.test(this.modelConfig.modelId); - - try { - const genOptions: any = { - model, - prompt, - }; - - // Reasoning models (o1, o3) have restrictions on temperature and other params - if (!isReasoningModel) { - genOptions.temperature = options.temperature ?? this.modelConfig.temperature; - genOptions.maxTokens = options.maxTokens ?? this.modelConfig.maxTokens ?? 1000; - } else { - // For reasoning models (o1/o3), we omit temperature/maxTokens - // They use maxCompletionTokens instead, handled by the AI SDK - } - - // Pass tools if provided - if (options.tools) { - genOptions.tools = options.tools; - } - - // Pass maxSteps if provided (default to 1 for standard generation, higher for agents) - if (options.maxSteps) { - genOptions.maxSteps = options.maxSteps; - } - - const result = await generateText(genOptions); - return result.text; - } catch (error) { - const err = error as any; - - // If Helicone was used and it failed, retry with native provider - const heliconeApiKey = process.env.HELICONE_API_KEY; - if (heliconeApiKey && metadata) { - if (this.logger) { - this.logger.warn( - `[VercelAIProvider] Helicone API call failed, retrying with native provider: ${err.message}`, - ); - } else { - console.warn( - `[VercelAIProvider] Helicone API call failed, retrying with native provider:`, - err.message, - ); - } - - try { - // Retry with native provider - const nativeModel = this.getNativeModel(); - const genOptions: any = { - model: nativeModel, - prompt, - }; - - // Redeclare for fallback scope - const isReasoningModel = /^o[13]-/.test(this.modelConfig.modelId); - - // Reasoning models don't support maxTokens parameter - if (!isReasoningModel) { - genOptions.temperature = - options.temperature ?? this.modelConfig.temperature; - genOptions.maxTokens = - options.maxTokens ?? this.modelConfig.maxTokens ?? 1000; - } - - // Pass tools if provided - if (options.tools) { - genOptions.tools = options.tools; - } - if (options.maxSteps) { - genOptions.maxSteps = options.maxSteps; - } - - const result = await generateText(genOptions); - return result.text; - } catch (fallbackError) { - // Both Helicone and native failed, throw the native error - const fallbackErr = fallbackError as any; - throw new AIProviderError( - 'GENERATION_FAILED', - fallbackErr.message || 'Text generation failed', - this.provider, - fallbackError, - ); - } - } - - // Either Helicone wasn't used, or it was the only attempt - throw new AIProviderError( - 'GENERATION_FAILED', - err.message || 'Text generation failed', - this.provider, - error, - ); - } - } -} diff --git a/packages/ai/providers/vercel/index.ts b/packages/ai/providers/vercel/index.ts new file mode 100644 index 0000000..b64c35d --- /dev/null +++ b/packages/ai/providers/vercel/index.ts @@ -0,0 +1,4 @@ +export * from './provider'; +export * from './model-factory'; +export * from './options-builder'; +export * from './utils'; diff --git a/packages/ai/providers/vercel/model-factory.ts b/packages/ai/providers/vercel/model-factory.ts new file mode 100644 index 0000000..b75ace8 --- /dev/null +++ b/packages/ai/providers/vercel/model-factory.ts @@ -0,0 +1,116 @@ +import { createOpenAI } from '@ai-sdk/openai'; +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { createAnthropic } from '@ai-sdk/anthropic'; +import { createMistral } from '@ai-sdk/mistral'; +import { createHelicone } from '@helicone/ai-sdk-provider'; +import { AIModel, ILogger, ObservabilityMetadata } from '../../core/types'; +import { log } from './utils'; + +export interface ModelFactoryConfig { + modelConfig: AIModel; + debug?: boolean; + logger?: ILogger; +} + +export class ModelFactory { + readonly #config: ModelFactoryConfig; + + constructor(config: ModelFactoryConfig) { + this.#config = config; + } + + getModel(metadata?: ObservabilityMetadata) { + if (metadata) { + const heliconeModel = this.tryCreateHeliconeModel(metadata); + if (heliconeModel) { + log( + this.#config.logger, + this.#config.debug || false, + 'info', + '📊 Helicone observability enabled', + ); + return heliconeModel; + } + } + + return this.getNativeModel(); + } + + getNativeModel() { + return this.createProviderModel()(this.#config.modelConfig.modelId); + } + + private createProviderModel(): (id: string) => any { + const factories = { + openai: createOpenAI, + gemini: createGoogleGenerativeAI, + anthropic: createAnthropic, + mistral: createMistral, + } as const; + + const factory = factories[this.#config.modelConfig.provider]; + if (!factory) { + throw new Error(`Unsupported provider: ${this.#config.modelConfig.provider}`); + } + + const instance = factory({ + apiKey: this.#config.modelConfig.apiKey, + baseURL: this.#config.modelConfig.endpoint, + } as any); + + return instance; + } + + private tryCreateHeliconeModel(metadata: ObservabilityMetadata) { + const heliconeApiKey = this.#config.modelConfig.observability?.heliconeApiKey; + if (!heliconeApiKey) return null; + + try { + const helicone = createHelicone({ apiKey: heliconeApiKey }); + + return helicone(this.#config.modelConfig.modelId, { + extraBody: { + helicone: this.buildHeliconeConfig(metadata), + }, + }); + } catch (error) { + log( + this.#config.logger, + this.#config.debug || false, + 'warn', + `[ModelFactory] Helicone initialization failed, falling back to native provider`, + error, + ); + return null; + } + } + + private buildHeliconeConfig(metadata: ObservabilityMetadata) { + const heliconeConfig: Record = {}; + + if (metadata.sessionId) { + heliconeConfig.sessionId = metadata.sessionId; + } + + if (metadata.sessionName) { + heliconeConfig.sessionName = metadata.sessionName; + } + + if (metadata.userId) { + heliconeConfig.userId = metadata.userId; + } + + if (metadata.tags || metadata.agentRole) { + heliconeConfig.tags = [ + ...(metadata.tags || []), + ...(metadata.agentRole ? [metadata.agentRole] : []), + ]; + } + + if (metadata.properties) { + heliconeConfig.properties = metadata.properties; + } + + return heliconeConfig; + } +} diff --git a/packages/ai/providers/vercel/options-builder.ts b/packages/ai/providers/vercel/options-builder.ts new file mode 100644 index 0000000..d351b09 --- /dev/null +++ b/packages/ai/providers/vercel/options-builder.ts @@ -0,0 +1,60 @@ +import { AIModel, GenerateTextOptions } from '../../core/types'; +import { isReasoningModel } from './utils'; + +export class OptionsBuilder { + readonly #config: AIModel; + + constructor(config: AIModel) { + this.#config = config; + } + + /** + * Build base options for AI SDK generation functions + */ + buildBaseOptions(model: any, prompt: string, overrides: Record = {}) { + const options: Record = { + model, + prompt, + ...overrides, + }; + + // Reasoning models (o1, o3) handle tokens/temperature differently + if (!isReasoningModel(this.#config.modelId)) { + if (this.#config.maxTokens && options.maxTokens === undefined) { + options.maxTokens = this.#config.maxTokens; + } + + if (this.#config.temperature !== undefined && options.temperature === undefined) { + options.temperature = this.#config.temperature; + } + } + + return options; + } + + /** + * Build options specifically for generateText + */ + buildTextOptions( + model: any, + prompt: string, + options: GenerateTextOptions, + ): Record { + const baseOptions = this.buildBaseOptions(model, prompt, {}); + + if (!isReasoningModel(this.#config.modelId)) { + baseOptions.temperature = options.temperature ?? this.#config.temperature; + baseOptions.maxTokens = options.maxTokens ?? this.#config.maxTokens ?? 1000; + } + + if (options.tools) { + baseOptions.tools = options.tools; + } + + if (options.maxSteps) { + baseOptions.maxSteps = options.maxSteps; + } + + return baseOptions; + } +} diff --git a/packages/ai/providers/vercel/provider.ts b/packages/ai/providers/vercel/provider.ts new file mode 100644 index 0000000..c78549a --- /dev/null +++ b/packages/ai/providers/vercel/provider.ts @@ -0,0 +1,194 @@ +import { + IAIProvider, + AIProvider, + DocumentationRequest, + DocumentationResponse, + AIModel, + BatchDocumentationResult, + ILogger, + ObservabilityMetadata, + GenerateTextOptions, + DocumentationStructure, +} from '../../core/types'; +import { generateObject, generateText } from 'ai'; +import { + BatchDocumentationStructureSchema, + DocumentationStructureSchema, +} from '../../documentation/structured-schema'; +import { buildMarkdownFromStructure } from '../../documentation/markdown-builder'; +import { z } from 'zod'; +import { ModelFactory } from './model-factory'; +import { OptionsBuilder } from './options-builder'; +import { isReasoningModel, mapToProviderError, log } from './utils'; + +export class VercelAIProvider implements IAIProvider { + readonly provider: AIProvider; + readonly #modelConfig: AIModel; + readonly #debug: boolean; + readonly #logger?: ILogger; + readonly #modelFactory: ModelFactory; + readonly #optionsBuilder: OptionsBuilder; + + constructor(config: AIModel, debug: boolean = false, logger?: ILogger) { + this.provider = config.provider; + this.#modelConfig = config; + this.#debug = debug; + this.#logger = logger; + + this.#modelFactory = new ModelFactory({ + modelConfig: config, + debug, + logger, + }); + + this.#optionsBuilder = new OptionsBuilder(config); + } + + /** + * Executes a task with Helicone observability, falling back to native model on failure + */ + private async withHeliconeFallback( + metadata: ObservabilityMetadata | undefined, + task: (model: any) => Promise, + ): Promise { + try { + return await task(this.#modelFactory.getModel(metadata)); + } catch (error) { + if (metadata && this.#modelConfig.observability?.heliconeApiKey) { + log( + this.#logger, + this.#debug, + 'warn', + 'Helicone failed, retrying with native model', + error, + ); + return task(this.#modelFactory.getNativeModel()); + } + throw error; + } + } + + /** + * Maps AI SDK result to DocumentationResponse + */ + private toDocumentationResponse( + result: any, + doc: DocumentationStructure, + ): DocumentationResponse { + const usage = result.usage ?? {}; + return { + content: buildMarkdownFromStructure(doc), + provider: this.provider, + modelId: this.#modelConfig.modelId, + timestamp: Date.now(), + usage: { + promptTokens: usage.promptTokens ?? 0, + completionTokens: usage.completionTokens ?? 0, + totalTokens: usage.totalTokens ?? 0, + }, + }; + } + + async generateDocumentation(request: DocumentationRequest): Promise { + try { + return await this.withHeliconeFallback(undefined, async (model) => { + const options = this.#optionsBuilder.buildBaseOptions(model, request.prompt, { + system: request.systemPrompt, + schema: z.object({ + documentation: DocumentationStructureSchema, + }), + }); + + const result: any = await generateObject(options as any); + return this.toDocumentationResponse(result, result.object.documentation); + }); + } catch (error) { + throw mapToProviderError(error, this.provider); + } + } + + async generateBatchDocumentation( + items: Array<{ symbolName: string; signatureText: string }>, + prompt: string, + systemPrompt: string, + ): Promise { + const model = this.#modelFactory.getNativeModel(); // Batch usually doesn't need Helicone retry logic here + + try { + const options = this.#optionsBuilder.buildBaseOptions(model, prompt, { + system: systemPrompt, + schema: BatchDocumentationStructureSchema, + maxTokens: this.#modelConfig.maxTokens + ? this.#modelConfig.maxTokens * items.length + : 4096, + }); + + const result: any = await generateObject(options as any); + + const success = result.object.documentations.map((doc: DocumentationStructure) => ({ + symbolName: doc.symbolName, + content: buildMarkdownFromStructure(doc), + })); + + return { + success, + failures: [], + stats: { + total: items.length, + succeeded: success.length, + failed: 0, + }, + }; + } catch (error) { + const err = error as any; + log(this.#logger, this.#debug, 'warn', `Batch generation failed: ${err.message}`); + + return { + success: [], + failures: items.map((item) => ({ + symbolName: item.symbolName, + errors: [`Batch generation error: ${err.message}`], + })), + stats: { + total: items.length, + succeeded: 0, + failed: items.length, + }, + }; + } + } + + async validateConnection(): Promise { + const model = this.#modelFactory.getNativeModel(); + const reasoning = isReasoningModel(this.#modelConfig.modelId); + + try { + const options: Record = { model, prompt: 'Hello' }; + if (!reasoning) options.maxTokens = 5; + + await generateText(options as any); + return true; + } catch (error) { + if (this.#debug) { + log(this.#logger, this.#debug, 'error', 'Connection validation failed:', error); + } + return false; + } + } + + async generateText( + prompt: string, + options: GenerateTextOptions = {}, + metadata?: ObservabilityMetadata, + ): Promise { + try { + return await this.withHeliconeFallback(metadata, async (model) => { + const textOptions = this.#optionsBuilder.buildTextOptions(model, prompt, options); + const result = await generateText(textOptions as any); + return result.text; + }); + } catch (error) { + throw mapToProviderError(error, this.provider); + } + } +} diff --git a/packages/ai/providers/vercel/utils.ts b/packages/ai/providers/vercel/utils.ts new file mode 100644 index 0000000..9c45e4e --- /dev/null +++ b/packages/ai/providers/vercel/utils.ts @@ -0,0 +1,47 @@ +import { AIProvider, AIProviderError, ILogger, AIErrorCode } from '../../core/types'; + +/** + * Check if the model is a reasoning model (like OpenAI o1/o3) + */ +export function isReasoningModel(modelId: string): boolean { + return /^o[13]-/.test(modelId); +} + +/** + * Maps common AI SDK errors to internal AIProviderError + */ +export function mapToProviderError( + error: any, + provider: AIProvider, + fallbackCode: AIErrorCode = 'GENERATION_FAILED', +) { + if (error?.name === 'APICallError' && error.statusCode === 429) { + return new AIProviderError('RATE_LIMIT', error.message, provider, error); + } + + return new AIProviderError(fallbackCode, error?.message ?? 'Unknown error', provider, error); +} + +/** + * Helper to log messages using the provided logger or falling back to console + */ +export function log( + logger: ILogger | undefined, + debug: boolean, + level: 'info' | 'warn' | 'debug' | 'error', + message: string, + error?: unknown, +) { + const loggerFn = logger?.[level]; + + if (loggerFn) { + error ? loggerFn.call(logger, message, error) : loggerFn.call(logger, message); + return; + } + + // Fallback if no logger provided or method missing + if (debug || level !== 'debug') { + const consoleFn = console[level === 'debug' ? 'log' : level] || console.log; + error ? consoleFn(message, error) : consoleFn(message); + } +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 3e389ca..601043f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,6 +50,7 @@ "@helicone/ai-sdk-provider": "^1.0.11", "@lancedb/lancedb": "^0.23.0", "@sintesi/core": "workspace:*", + "@sintesi/shared": "workspace:*", "@types/uuid": "^10.0.0", "ai": "^5.0.106", "cohere-ai": "^7.20.0", diff --git a/packages/cli/src/services/generation-context.ts b/packages/cli/src/services/generation-context.ts index d7327d7..dbb3ea7 100644 --- a/packages/cli/src/services/generation-context.ts +++ b/packages/cli/src/services/generation-context.ts @@ -4,7 +4,7 @@ import { resolve, relative, dirname, join } from 'path'; import { readFileSync, existsSync } from 'fs'; import { execSync } from 'child_process'; import { ChangeAnalysisService } from './analysis-service'; -import { createAIAgentsFromEnv, AIAgents, AIAgentRoleConfig } from '../../../ai'; +import { createAIAgentsFromEnv, AIAgents } from '../../../ai'; import { getContextPrompt } from '../prompts/analysis'; import { RetrievalService } from './rag'; import { SkeletonizerService } from './skeletonizer'; @@ -79,34 +79,15 @@ export class GenerationContextService { /** * Initializes AI Agents from environment variables. */ - async getAIAgents( - verbose: boolean, - plannerModelId?: string, - writerModelId?: string, - ): Promise { + async getAIAgents(verbose: boolean): Promise { try { - const plannerConfig: AIAgentRoleConfig = { - modelId: plannerModelId || process.env.SINTESI_PLANNER_MODEL_ID || '', - provider: process.env.SINTESI_PLANNER_PROVIDER as any, - }; - const writerConfig: AIAgentRoleConfig = { - modelId: writerModelId || process.env.SINTESI_WRITER_MODEL_ID || '', - provider: process.env.SINTESI_WRITER_PROVIDER as any, - }; - - const aiAgents = createAIAgentsFromEnv( - { debug: verbose, logger: this.logger }, - { planner: plannerConfig, writer: writerConfig }, - ); + const aiAgents = createAIAgentsFromEnv({ + debug: verbose, + logger: this.logger, + }); const writerConnected = await aiAgents.writer.validateConnection(); - - // If planner is different from writer, check it too, otherwise we assume if writer works, planner likely works (or it's the same) - // But let's be safe if they are distinct instances - let plannerConnected = true; - if (aiAgents.planner !== aiAgents.writer) { - plannerConnected = await aiAgents.planner.validateConnection(); - } + const plannerConnected = await aiAgents.planner.validateConnection(); if (!writerConnected || !plannerConnected) { this.logger.error('AI provider connection failed. Please check your API key.'); diff --git a/packages/cli/src/utils/logger.ts b/packages/cli/src/utils/logger.ts index 7e8acb7..92eaadb 100644 --- a/packages/cli/src/utils/logger.ts +++ b/packages/cli/src/utils/logger.ts @@ -1,210 +1,4 @@ -/** - * Simple CLI logger with colored output - */ - -import type { ILogger } from '../../../ai'; - -export enum LogLevel { - ERROR = 'error', - WARN = 'warn', - INFO = 'info', - SUCCESS = 'success', - DEBUG = 'debug', -} - -/** - * ANSI color codes for terminal output - */ -const colors = { - reset: '\x1b[0m', - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - magenta: '\x1b[35m', - cyan: '\x1b[36m', - gray: '\x1b[90m', - bold: '\x1b[1m', -}; - -/** - * CLI Logger for formatted console output - * Implements ILogger interface for compatibility with AI module - */ -export class Logger implements ILogger { - private verbose: boolean; - - constructor(verbose: boolean = false) { - this.verbose = verbose; - } - - /** - * Log an error message - */ - public error(message: string, ...args: unknown[]): void { - console.error(`${colors.red}✗${colors.reset} ${message}`, ...args); - } - - /** - * Log a warning message - */ - public warn(message: string, ...args: unknown[]): void { - console.warn(`${colors.yellow}⚠${colors.reset} ${message}`, ...args); - } - - /** - * Log an info message - */ - public info(message: string, ...args: unknown[]): void { - console.log(`${colors.blue}ℹ${colors.reset} ${message}`, ...args); - } - - /** - * Log a success message - */ - public success(message: string, ...args: unknown[]): void { - console.log(`${colors.green}✓${colors.reset} ${message}`, ...args); - } - - /** - * Log a debug message (only in verbose mode) - */ - public debug(message: string, ...args: unknown[]): void { - if (this.verbose) { - console.log(`${colors.gray}[DEBUG]${colors.reset} ${message}`, ...args); - } - } - - /** - * Log a plain message without prefix - */ - public log(message: string, ...args: unknown[]): void { - console.log(message, ...args); - } - - /** - * Log a section header - */ - public header(message: string): void { - console.log(`\n${colors.bold}${colors.cyan}${message}${colors.reset}\n`); - } - - /** - * Log a divider line - */ - public divider(): void { - console.log(colors.gray + '─'.repeat(60) + colors.reset); - } - - /** - * Create a new line - */ - public newline(): void { - console.log(); - } - - /** - * Format text with color - */ - public static color(text: string, color: keyof typeof colors): string { - return `${colors[color]}${text}${colors.reset}`; - } - - /** - * Format text as bold - */ - public static bold(text: string): string { - return `${colors.bold}${text}${colors.reset}`; - } - - /** - * Format file path - */ - public static path(filePath: string): string { - return `${colors.cyan}${filePath}${colors.reset}`; - } - - /** - * Format symbol name - */ - public static symbol(symbolName: string): string { - return `${colors.magenta}${symbolName}${colors.reset}`; - } - - /** - * Format hash (shortened) - */ - public static hash(hash: string, length: number = 8): string { - return `${colors.gray}${hash.substring(0, length)}${colors.reset}`; - } - - /** - * Get verbose status - */ - public getVerbose(): boolean { - return this.verbose; - } - - /** - * Print a banner with text - */ - public banner(text: string): void { - const width = 60; - const padding = Math.max(0, Math.floor((width - text.length - 2) / 2)); - const line = '═'.repeat(width); - - console.log(`${colors.cyan}${colors.bold}`); - console.log(`╔${line}╗`); - console.log(`║${' '.repeat(padding)}${text}${' '.repeat(width - padding - text.length)}║`); - console.log(`╚${line}╝`); - console.log(colors.reset); - } - - /** - * Print a box around text - */ - public box(title: string, content: string[]): void { - const maxLength = Math.max( - title.length, - // eslint-disable-next-line no-control-regex - ...content.map((line) => line.replace(/\x1b\[[0-9;]*m/g, '').length), - ); - const width = Math.min(maxLength + 4, 70); - - console.log(`${colors.cyan}┌${'─'.repeat(width)}┐${colors.reset}`); - console.log( - `${colors.cyan}│${colors.reset} ${colors.bold}${title}${colors.reset}${' '.repeat(width - title.length - 1)}${colors.cyan}│${colors.reset}`, - ); - console.log(`${colors.cyan}├${'─'.repeat(width)}┤${colors.reset}`); - - content.forEach((line) => { - // eslint-disable-next-line no-control-regex - const stripped = line.replace(/\x1b\[[0-9;]*m/g, ''); - const padding = width - stripped.length - 1; - console.log( - `${colors.cyan}│${colors.reset} ${line}${' '.repeat(padding)}${colors.cyan}│${colors.reset}`, - ); - }); - - console.log(`${colors.cyan}└${'─'.repeat(width)}┘${colors.reset}`); - } - - /** - * Print a step header - */ - public step(stepNumber: number, totalSteps: number, title: string, emoji: string = '▶'): void { - console.log(); - console.log( - `${colors.bold}${colors.cyan}${emoji} Step ${stepNumber}/${totalSteps}: ${colors.reset}${colors.bold}${title}${colors.reset}`, - ); - console.log(); - } - - /** - * Print an item in a list - */ - public listItem(text: string, checked: boolean = false): void { - const icon = checked ? `${colors.green}✓${colors.reset}` : `${colors.gray}○${colors.reset}`; - console.log(` ${icon} ${text}`); - } -} +export * from '@sintesi/shared'; +import { Logger } from '@sintesi/shared'; +export { Logger }; +export default Logger; diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..a23d527 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,15 @@ +{ + "name": "@sintesi/shared", + "version": "0.1.0", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc", + "clean": "rm -rf dist" + }, + "devDependencies": { + "@types/node": "^20.10.6", + "typescript": "^5.3.3" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..f48c6f6 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,150 @@ +/** + * Interface for a logger + */ +export interface ILogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; + success?(message: string, ...args: unknown[]): void; + log?(message: string, ...args: unknown[]): void; +} + +/** + * ANSI color codes for terminal output + */ +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + gray: '\x1b[90m', + bold: '\x1b[1m', +}; + +/** + * CLI Logger for formatted console output + */ +export class Logger implements ILogger { + private verbose: boolean; + + constructor(verbose: boolean = false) { + this.verbose = verbose; + } + + public error(message: string, ...args: unknown[]): void { + console.error(`${colors.red}✗${colors.reset} ${message}`, ...args); + } + + public warn(message: string, ...args: unknown[]): void { + console.warn(`${colors.yellow}⚠${colors.reset} ${message}`, ...args); + } + + public info(message: string, ...args: unknown[]): void { + console.log(`${colors.blue}ℹ${colors.reset} ${message}`, ...args); + } + + public success(message: string, ...args: unknown[]): void { + console.log(`${colors.green}✓${colors.reset} ${message}`, ...args); + } + + public debug(message: string, ...args: unknown[]): void { + if (this.verbose) { + console.log(`${colors.gray}[DEBUG]${colors.reset} ${message}`, ...args); + } + } + + public log(message: string, ...args: unknown[]): void { + console.log(message, ...args); + } + + public header(message: string): void { + console.log(`\n${colors.bold}${colors.cyan}${message}${colors.reset}\n`); + } + + public divider(): void { + console.log(colors.gray + '─'.repeat(60) + colors.reset); + } + + public newline(): void { + console.log(); + } + + public static color(text: string, color: keyof typeof colors): string { + return `${colors[color]}${text}${colors.reset}`; + } + + public static bold(text: string): string { + return `${colors.bold}${text}${colors.reset}`; + } + + public static path(filePath: string): string { + return `${colors.cyan}${filePath}${colors.reset}`; + } + + public static symbol(symbolName: string): string { + return `${colors.magenta}${symbolName}${colors.reset}`; + } + + public static hash(hash: string, length: number = 8): string { + return `${colors.gray}${hash.substring(0, length)}${colors.reset}`; + } + + public getVerbose(): boolean { + return this.verbose; + } + + public banner(text: string): void { + const width = 60; + const padding = Math.max(0, Math.floor((width - text.length - 2) / 2)); + const line = '═'.repeat(width); + + console.log(`${colors.cyan}${colors.bold}`); + console.log(`╔${line}╗`); + console.log(`║${' '.repeat(padding)}${text}${' '.repeat(width - padding - text.length)}║`); + console.log(`╚${line}╝`); + console.log(colors.reset); + } + + public box(title: string, content: string[]): void { + const maxLength = Math.max( + title.length, + // eslint-disable-next-line no-control-regex + ...content.map((line) => line.replace(/\x1b\[[0-9;]*m/g, '').length), + ); + const width = Math.min(maxLength + 4, 70); + + console.log(`${colors.cyan}┌${'─'.repeat(width)}┐${colors.reset}`); + console.log( + `${colors.cyan}│${colors.reset} ${colors.bold}${title}${colors.reset}${' '.repeat(width - title.length - 1)}${colors.cyan}│${colors.reset}`, + ); + console.log(`${colors.cyan}├${'─'.repeat(width)}┤${colors.reset}`); + + content.forEach((line) => { + // eslint-disable-next-line no-control-regex + const stripped = line.replace(/\x1b\[[0-9;]*m/g, ''); + const padding = width - stripped.length - 1; + console.log( + `${colors.cyan}│${colors.reset} ${line}${' '.repeat(padding)}${colors.cyan}│${colors.reset}`, + ); + }); + + console.log(`${colors.cyan}└${'─'.repeat(width)}┘${colors.reset}`); + } + + public step(stepNumber: number, totalSteps: number, title: string, emoji: string = '▶'): void { + console.log(); + console.log( + `${colors.bold}${colors.cyan}${emoji} Step ${stepNumber}/${totalSteps}: ${colors.reset}${colors.bold}${title}${colors.reset}`, + ); + console.log(); + } + + public listItem(text: string, checked: boolean = false): void { + const icon = checked ? `${colors.green}✓${colors.reset}` : `${colors.gray}○${colors.reset}`; + console.log(` ${icon} ${text}`); + } +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..2b3a413 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": ".", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c95d0ea..26ad8fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: '@sintesi/core': specifier: workspace:* version: link:../core + '@sintesi/shared': + specifier: workspace:* + version: link:../shared '@types/uuid': specifier: ^10.0.0 version: 10.0.0 @@ -189,6 +192,15 @@ importers: specifier: ^5.3.3 version: 5.9.3 + packages/shared: + devDependencies: + '@types/node': + specifier: ^20.10.6 + version: 20.19.27 + typescript: + specifier: ^5.3.3 + version: 5.9.3 + packages: /@ai-sdk/anthropic@2.0.56(zod@4.2.1):