diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index c258e505..f9ff78ab 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -54,4 +54,4 @@ jobs: workspaces: "contract" - name: Build contract run: cargo build --target wasm32-unknown-unknown --release - working-directory: contract + working-directory: contract \ No newline at end of file diff --git a/middleware/src/orcher/orcher.controller.ts b/middleware/src/orcher/orcher.controller.ts new file mode 100644 index 00000000..8945ca07 --- /dev/null +++ b/middleware/src/orcher/orcher.controller.ts @@ -0,0 +1,281 @@ +import { + Controller, + Post, + Get, + Body, + Query, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { MultiProviderOrchestrationService } from './multi-provider-orchestration.service'; +import { AuditService } from './audit.service'; +import { ConsensusService } from './consensus.service'; +import { + OrchestrationStrategy, + OrchestratedRequestConfig, + OrchestratedResponse, + ProviderExecutionMode, + ConsensusAlgorithm, +} from './orchestration.interface'; +import { AIProviderType } from '../provider.interface'; +import { CompletionRequestDto } from '../base.dto'; + +/** + * Orchestration DTOs + */ +class OrchestratedCompletionRequestDto extends CompletionRequestDto { + strategy: OrchestrationStrategy; + targetProviders?: AIProviderType[]; + timeoutMs?: number; + consensusConfig?: { + algorithm: ConsensusAlgorithm; + minAgreementPercentage: number; + similarityThreshold?: number; + }; + bestOfNConfig?: { + n: number; + criteria: 'fastest' | 'cheapest' | 'highest_quality' | 'most_tokens'; + }; +} + +class ProviderModeUpdateDto { + provider: AIProviderType; + mode: ProviderExecutionMode; +} + +@ApiTags('orchestration') +@Controller('orchestration') +export class OrchestrationController { + constructor( + private readonly orchestrationService: MultiProviderOrchestrationService, + private readonly auditService: AuditService, + private readonly consensusService: ConsensusService, + ) {} + + /** + * Execute a completion with multi-provider orchestration + */ + @Post('complete') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Execute completion with multi-provider orchestration' }) + @ApiResponse({ status: 200, description: 'Completion successful' }) + @ApiResponse({ status: 500, description: 'All providers failed' }) + async orchestratedComplete( + @Body() request: OrchestratedCompletionRequestDto, + ): Promise { + const config: OrchestratedRequestConfig = { + strategy: request.strategy, + targetProviders: request.targetProviders, + timeoutMs: request.timeoutMs, + consensusConfig: request.consensusConfig, + bestOfNConfig: request.bestOfNConfig, + }; + + // Remove orchestration-specific fields from the request + const completionRequest: CompletionRequestDto = { + provider: request.provider, + model: request.model, + messages: request.messages, + temperature: request.temperature, + maxTokens: request.maxTokens, + topP: request.topP, + stream: false, + stop: request.stop, + timeout: request.timeout, + }; + + return this.orchestrationService.orchestrate(completionRequest, config); + } + + /** + * Execute with consensus strategy + */ + @Post('consensus') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Execute with consensus across multiple providers' }) + async consensusComplete( + @Body() request: CompletionRequestDto, + @Query('providers') providers?: AIProviderType[], + @Query('algorithm') algorithm: ConsensusAlgorithm = ConsensusAlgorithm.MAJORITY_VOTE, + @Query('minAgreement') minAgreement: number = 0.5, + ): Promise { + const config: OrchestratedRequestConfig = { + strategy: OrchestrationStrategy.CONSENSUS, + targetProviders: providers, + consensusConfig: { + algorithm, + minAgreementPercentage: minAgreement, + }, + }; + + return this.orchestrationService.orchestrate(request, config); + } + + /** + * Execute in parallel to all providers + */ + @Post('parallel') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Execute in parallel to all enabled providers' }) + async parallelComplete( + @Body() request: CompletionRequestDto, + @Query('providers') providers?: AIProviderType[], + ): Promise { + const config: OrchestratedRequestConfig = { + strategy: OrchestrationStrategy.PARALLEL, + targetProviders: providers, + }; + + return this.orchestrationService.orchestrate(request, config); + } + + /** + * Execute with best-of-N selection + */ + @Post('best-of-n') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Execute with best-of-N provider selection' }) + async bestOfNComplete( + @Body() request: CompletionRequestDto, + @Query('n') n: number = 3, + @Query('criteria') criteria: 'fastest' | 'cheapest' | 'highest_quality' | 'most_tokens' = 'fastest', + ): Promise { + const config: OrchestratedRequestConfig = { + strategy: OrchestrationStrategy.BEST_OF_N, + bestOfNConfig: { + n, + criteria, + }, + }; + + return this.orchestrationService.orchestrate(request, config); + } + + /** + * Get orchestration health status + */ + @Get('health') + @ApiOperation({ summary: 'Get orchestration health status' }) + async getHealthStatus() { + return this.orchestrationService.getHealthStatus(); + } + + /** + * Get provider execution mode + */ + @Get('providers/:provider/mode') + @ApiOperation({ summary: 'Get provider execution mode' }) + getProviderMode(@Query('provider') provider: AIProviderType) { + return { + provider, + mode: this.orchestrationService.getProviderMode(provider), + }; + } + + /** + * Set provider execution mode + */ + @Post('providers/mode') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Set provider execution mode at runtime' }) + setProviderMode(@Body() update: ProviderModeUpdateDto) { + this.orchestrationService.setProviderMode(update.provider, update.mode); + return { + message: `Provider ${update.provider} mode set to ${update.mode}`, + provider: update.provider, + mode: update.mode, + }; + } + + /** + * Enable a provider + */ + @Post('providers/:provider/enable') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Enable a provider' }) + enableProvider(@Query('provider') provider: AIProviderType) { + this.orchestrationService.setProviderMode(provider, ProviderExecutionMode.ENABLED); + return { + message: `Provider ${provider} enabled`, + provider, + mode: ProviderExecutionMode.ENABLED, + }; + } + + /** + * Disable a provider + */ + @Post('providers/:provider/disable') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Disable a provider' }) + disableProvider(@Query('provider') provider: AIProviderType) { + this.orchestrationService.setProviderMode(provider, ProviderExecutionMode.DISABLED); + return { + message: `Provider ${provider} disabled`, + provider, + mode: ProviderExecutionMode.DISABLED, + }; + } + + /** + * Get audit log + */ + @Get('audit-log') + @ApiOperation({ summary: 'Get provider audit log' }) + async getAuditLog( + @Query('requestId') requestId?: string, + @Query('provider') provider?: AIProviderType, + @Query('limit') limit?: number, + @Query('offset') offset?: number, + ) { + const entries = this.auditService.getAuditLog({ + requestId, + provider, + limit, + offset, + }); + + return { + entries, + count: entries.length, + }; + } + + /** + * Export audit log + */ + @Get('audit-log/export') + @ApiOperation({ summary: 'Export audit log' }) + async exportAuditLog( + @Query('format') format: 'json' | 'csv' = 'json', + ) { + const data = this.auditService.exportAuditLog({ format }); + return { + data, + format, + }; + } + + /** + * Get audit statistics + */ + @Get('audit-log/statistics') + @ApiOperation({ summary: 'Get audit statistics' }) + async getAuditStatistics() { + return this.auditService.getStatistics(); + } + + /** + * Verify audit entry integrity + */ + @Get('audit-log/:auditId/verify') + @ApiOperation({ summary: 'Verify audit entry integrity' }) + verifyAuditEntry(@Query('auditId') auditId: string) { + const isValid = this.auditService.verifyIntegrity(auditId); + return { + auditId, + isValid, + }; + } +} diff --git a/middleware/src/orcher/providers/orcher.service.ts b/middleware/src/orcher/providers/orcher.service.ts new file mode 100644 index 00000000..c801bf70 --- /dev/null +++ b/middleware/src/orcher/providers/orcher.service.ts @@ -0,0 +1,288 @@ +import { Injectable } from '@nestjs/common'; +import axios, { AxiosInstance } from 'axios'; +import { BaseAIProvider } from '../base-provider.service'; +import { AIProviderType, ICompletionProvider, IModelInfo } from '../provider.interface'; +import { CompletionRequestDto, CompletionResponseDto, MessageRole } from '../base.dto'; +import { Provider } from '../provider.decorator'; + +/** + * Google AI Provider Adapter + * + * Adapter for Google's Gemini models via Vertex AI or Gemini API + * Implements the ICompletionProvider interface for text generation. + */ +@Provider(AIProviderType.GOOGLE) +@Injectable() +export class GoogleProvider extends BaseAIProvider implements ICompletionProvider { + private client: AxiosInstance; + private apiVersion: string = 'v1'; + + private readonly models: IModelInfo[] = [ + { + id: 'gemini-1.5-pro', + name: 'Gemini 1.5 Pro', + provider: AIProviderType.GOOGLE, + capabilities: { + textGeneration: true, + imageUnderstanding: true, + functionCalling: true, + streaming: true, + embeddings: false, + maxContextTokens: 1000000, + }, + costPerInputToken: 0.0035, + costPerOutputToken: 0.0105, + }, + { + id: 'gemini-1.5-flash', + name: 'Gemini 1.5 Flash', + provider: AIProviderType.GOOGLE, + capabilities: { + textGeneration: true, + imageUnderstanding: true, + functionCalling: true, + streaming: true, + embeddings: false, + maxContextTokens: 1000000, + }, + costPerInputToken: 0.00035, + costPerOutputToken: 0.00105, + }, + { + id: 'gemini-1.0-pro', + name: 'Gemini 1.0 Pro', + provider: AIProviderType.GOOGLE, + capabilities: { + textGeneration: true, + imageUnderstanding: false, + functionCalling: true, + streaming: true, + embeddings: false, + maxContextTokens: 32768, + }, + costPerInputToken: 0.0005, + costPerOutputToken: 0.0015, + }, + ]; + + constructor() { + super(GoogleProvider.name); + } + + getProviderType(): AIProviderType { + return AIProviderType.GOOGLE; + } + + protected async initializeProvider(): Promise { + const config = this.getConfig(); + + // Determine if using Vertex AI or Gemini API + const isVertexAI = config.apiEndpoint?.includes('vertexai.googleapis.com'); + + if (isVertexAI) { + // Vertex AI endpoint format + this.client = axios.create({ + baseURL: config.apiEndpoint, + headers: { + 'Authorization': `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json', + }, + timeout: config.timeout || 60000, + }); + } else { + // Gemini API endpoint + this.client = axios.create({ + baseURL: config.apiEndpoint || 'https://generativelanguage.googleapis.com/v1beta', + headers: { + 'Content-Type': 'application/json', + }, + timeout: config.timeout || 60000, + }); + } + + this.logger.log('Google provider initialized'); + } + + async listModels(): Promise { + return this.models; + } + + async getModelInfo(modelId: string): Promise { + const model = this.models.find(m => m.id === modelId); + if (!model) { + throw new Error(`Model ${modelId} not found`); + } + return model; + } + + async complete(request: CompletionRequestDto): Promise { + if (!this.client) { + throw new Error('Provider not initialized'); + } + + const config = this.getConfig(); + const isVertexAI = config.apiEndpoint?.includes('vertexai.googleapis.com'); + + const response = await this.executeWithRetry(async () => { + const contents = this.convertMessagesToContents(request.messages); + + let url: string; + let body: any; + + if (isVertexAI) { + // Vertex AI format + url = `/models/${request.model}:generateContent`; + body = { + contents, + generationConfig: { + temperature: request.temperature, + maxOutputTokens: request.maxTokens, + topP: request.topP, + stopSequences: request.stop, + }, + }; + } else { + // Gemini API format + url = `/models/${request.model}:generateContent?key=${config.apiKey}`; + body = { + contents, + generationConfig: { + temperature: request.temperature, + maxOutputTokens: request.maxTokens, + topP: request.topP, + stopSequences: request.stop, + }, + }; + } + + const result = await this.client.post(url, body); + return result.data; + }); + + return this.transformResponse(response); + } + + async *streamComplete(request: CompletionRequestDto): AsyncGenerator { + if (!this.client) { + throw new Error('Provider not initialized'); + } + + const config = this.getConfig(); + const isVertexAI = config.apiEndpoint?.includes('vertexai.googleapis.com'); + const contents = this.convertMessagesToContents(request.messages); + + let url: string; + let body: any; + + if (isVertexAI) { + url = `/models/${request.model}:streamGenerateContent`; + body = { + contents, + generationConfig: { + temperature: request.temperature, + maxOutputTokens: request.maxTokens, + topP: request.topP, + }, + }; + } else { + url = `/models/${request.model}:streamGenerateContent?key=${config.apiKey}`; + body = { + contents, + generationConfig: { + temperature: request.temperature, + maxOutputTokens: request.maxTokens, + topP: request.topP, + }, + }; + } + + const response = await this.client.post(url, body, { + responseType: 'stream', + }); + + for await (const chunk of response.data) { + try { + const parsed = JSON.parse(chunk.toString()); + yield parsed; + } catch { + // Skip invalid JSON + } + } + } + + async healthCheck(): Promise { + try { + // Check by listing models or a simple request + const config = this.getConfig(); + if (config.apiEndpoint?.includes('vertexai.googleapis.com')) { + await this.client.get('/models'); + } else { + // For Gemini API, try a minimal request + await this.client.get(`/models?key=${config.apiKey}&pageSize=1`); + } + return true; + } catch (error: any) { + this.logger.warn('Google health check failed:', error.message); + return false; + } + } + + /** + * Convert standard messages to Google content format + */ + private convertMessagesToContents(messages: any[]): any[] { + const contents = []; + let currentRole = ''; + let currentParts: any[] = []; + + for (const message of messages) { + const role = message.role === MessageRole.ASSISTANT ? 'model' : 'user'; + + if (role !== currentRole && currentParts.length > 0) { + contents.push({ + role: currentRole, + parts: currentParts, + }); + currentParts = []; + } + + currentRole = role; + currentParts.push({ text: message.content }); + } + + if (currentParts.length > 0) { + contents.push({ + role: currentRole, + parts: currentParts, + }); + } + + return contents; + } + + private transformResponse(data: any): CompletionResponseDto { + const candidate = data.candidates?.[0]; + const content = candidate?.content; + + return { + id: data.id || `google-${Date.now()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: data.modelVersion || 'gemini', + provider: AIProviderType.GOOGLE, + choices: [{ + index: 0, + message: { + role: MessageRole.ASSISTANT, + content: content?.parts?.map((p: any) => p.text).join('') || '', + }, + finishReason: candidate?.finishReason?.toLowerCase() || 'stop', + }], + usage: { + promptTokens: data.usageMetadata?.promptTokenCount || 0, + completionTokens: data.usageMetadata?.candidatesTokenCount || 0, + totalTokens: data.usageMetadata?.totalTokenCount || 0, + }, + }; + } +}