diff --git a/.env.example b/.env.example index a44e46f0..a44452e1 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,11 @@ OLLAMA_PORT="" GEMINI_API_KEY="" USE_GEMINI="false" +# MiniMax Option +MINIMAX_API_KEY="" +USE_MINIMAX="false" +MINIMAX_MODEL="MiniMax-M1" + # Ports Mapping CHROMA_PORT=8000 PORT=3745 diff --git a/README.md b/README.md index 36aadc8f..b81851ee 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Note: (Edit Mind name is coming from Video Editor Mind, so this will be the edit | **Containerization** | [Docker](https://www.docker.com/), [Docker Compose](https://docs.docker.com/compose/) | | **Web Service** | [React Router V7](https://reactrouter.com/), [TypeScript](https://www.typescriptlang.org/), [Vite](https://vitejs.dev/) | | **Background Jobs Service** | [Node.js](https://nodejs.org/), [Express.js](https://expressjs.com/), [BullMQ](https://bullmq.io/) | -| **ML Sevice** | [Python](https://www.python.org/), [PyAV](https://github.com/PyAV-Org/PyAV), [PyTorch](https://pytorch.org/), OpenAI Whisper, Google Gemini or Ollama (Used for NLP) | +| **ML Sevice** | [Python](https://www.python.org/), [PyAV](https://github.com/PyAV-Org/PyAV), [PyTorch](https://pytorch.org/), OpenAI Whisper, Google Gemini, [MiniMax](https://www.minimaxi.com/) or Ollama (Used for NLP) | | **Vector Database** | [ChromaDB](https://www.trychroma.com/) | | **Relational DB** | [PostgreSQL](https://www.postgresql.org/) (via [Prisma ORM](https://www.prisma.io/)) | @@ -132,6 +132,11 @@ OLLAMA_MODEL="qwen2.5:7b-instruct" USE_GEMINI="true" GEMINI_API_KEY="your-gemini-api-key-from-google-ai-studio" +# Option C: Use MiniMax API (requires API key) +USE_MINIMAX="true" +MINIMAX_API_KEY="your-minimax-api-key" +MINIMAX_MODEL="MiniMax-M1" # or MiniMax-M1-highspeed + # 3. GENERATE SECURITY KEYS (REQUIRED) # Generate with: openssl rand -base64 32 ENCRYPTION_KEY="your-random-32-char-base64-key" diff --git a/packages/ai/package.json b/packages/ai/package.json index 6a241a2f..4d425d72 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -24,6 +24,7 @@ "@google/generative-ai": "^0.24.1", "db": "workspace:*", "ollama": "^0.6.3", + "openai": "^4.104.0", "search": "workspace:*", "shared": "workspace:*" } diff --git a/packages/ai/src/constants/index.ts b/packages/ai/src/constants/index.ts index 49d4ac93..3ce527d3 100644 --- a/packages/ai/src/constants/index.ts +++ b/packages/ai/src/constants/index.ts @@ -11,3 +11,8 @@ export const USE_OLLAMA_MODEL = process.env.USE_OLLAMA_MODEL === 'true' export const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen2.5:7b-instruct' export const OLLAMA_HOST = process.env.OLLAMA_HOST export const OLLAMA_PORT = process.env.OLLAMA_PORT + +// MiniMax Settings +export const MINIMAX_API_KEY = process.env.MINIMAX_API_KEY +export const USE_MINIMAX = process.env.USE_MINIMAX === 'true' +export const MINIMAX_MODEL = process.env.MINIMAX_MODEL || 'MiniMax-M1' diff --git a/packages/ai/src/services/minimax.ts b/packages/ai/src/services/minimax.ts new file mode 100644 index 00000000..c0d885d3 --- /dev/null +++ b/packages/ai/src/services/minimax.ts @@ -0,0 +1,229 @@ +import OpenAI from 'openai' +import type { ChatMessage } from '@prisma/client' +import { + SEARCH_PROMPT, + ASSISTANT_MESSAGE_PROMPT, + VIDEO_COMPILATION_MESSAGE_PROMPT, + YEAR_IN_REVIEW, + GENERAL_RESPONSE_PROMPT, + CLASSIFY_INTENT_PROMPT, + ANALYTICS_RESPONSE_PROMPT, +} from '../constants/prompts' +import { VideoSearchParamsSchema } from '@shared/schemas/search' +import { YearInReviewData, YearInReviewDataSchema } from '@shared/schemas/yearInReview' +import type { VideoWithScenes } from '@shared/types/video' +import type { YearStats } from '@shared/types/stats' +import { ModelResponse } from '@ai/types/ai' +import { logger } from '@shared/services/logger' +import { VideoSearchParams } from '@shared/types/search' +import { MINIMAX_API_KEY, MINIMAX_MODEL } from '@ai/constants' +import { VideoAnalytics } from '@shared/types/analytics' +import { formatHistory } from '@ai/utils' + +const CONTEXT_WINDOW_LIMIT = 1_000_000 + +let client: OpenAI | null = null + +function getClient(): OpenAI { + if (!client) { + if (!MINIMAX_API_KEY) { + throw new Error('MINIMAX_API_KEY is not set') + } + client = new OpenAI({ + apiKey: MINIMAX_API_KEY, + baseURL: 'https://api.minimax.io/v1', + }) + } + return client +} + +function stripThinkTags(text: string): string { + return text.replace(/[\s\S]*?<\/think>/g, '').trim() +} + +async function generate(prompt: string): Promise> { + const openai = getClient() + + const response = await openai.chat.completions.create({ + model: MINIMAX_MODEL, + messages: [{ role: 'user', content: prompt }], + temperature: 0.7, + }) + + const content = response.choices[0]?.message?.content ?? '' + const tokens = response.usage?.prompt_tokens ?? 0 + + return { + data: stripThinkTags(content).trim(), + tokens, + } +} + +async function generateJSON(prompt: string): Promise> { + const openai = getClient() + + const response = await openai.chat.completions.create({ + model: MINIMAX_MODEL, + messages: [{ role: 'user', content: prompt }], + temperature: 0.7, + response_format: { type: 'json_object' }, + }) + + const content = response.choices[0]?.message?.content ?? '' + const tokens = response.usage?.prompt_tokens ?? 0 + + return { + data: stripThinkTags(content) + .replace(/```json|```/g, '') + .trim(), + tokens, + } +} + +export const MiniMaxModel = { + async generateActionFromPrompt( + query: string, + chatHistory?: ChatMessage[], + projectInstructions?: string + ): Promise> { + const fallback = VideoSearchParamsSchema.parse({}) + + if (!query || query.trim() === '') return { data: fallback, tokens: 0, error: undefined } + + try { + const history = formatHistory(chatHistory) + const prompt = SEARCH_PROMPT(query, history, projectInstructions) + + const { data: raw, tokens } = await generateJSON(prompt) + + try { + const parsed = JSON.parse(raw) + return { + data: VideoSearchParamsSchema.parse({ + ...parsed, + semanticQuery: null, + locations: [], + camera: null, + detectedText: null, + }), + tokens, + error: undefined, + } + } catch (parseError) { + logger.error('Failed to parse JSON: ' + parseError) + return { data: fallback, tokens: 0, error: 'Invalid JSON' } + } + } catch (err) { + logger.error('MiniMax generateActionFromPrompt failed: ' + err) + throw err + } + }, + + async generateAssistantMessage( + userPrompt: string, + count: number, + chatHistory?: ChatMessage[], + projectInstructions?: string + ): Promise> { + try { + const history = chatHistory?.length + ? chatHistory.map((h) => `${h.sender}: ${h.text}`).join('\n') + : '' + return await generate(ASSISTANT_MESSAGE_PROMPT(userPrompt, count, history, projectInstructions)) + } catch (error) { + logger.error('MiniMax generateAssistantMessage error: ' + error) + throw error + } + }, + + async generateYearInReviewResponse( + stats: YearStats, + videos: VideoWithScenes[], + extraDetails: string, + projectInstructions?: string + ): Promise> { + try { + const content = YEAR_IN_REVIEW(stats, videos, extraDetails, projectInstructions) + const { data: raw, tokens } = await generateJSON(content) + + try { + const parsed = JSON.parse(raw) + const validated = YearInReviewDataSchema.parse(parsed) + return { data: validated, tokens, error: undefined } + } catch (parseError) { + logger.error('Failed to parse year in review JSON: ' + parseError) + return { data: null, tokens: 0, error: 'Invalid JSON response from AI' } + } + } catch (err) { + logger.error('MiniMax year in review error: ' + err) + throw err + } + }, + + async generateGeneralResponse( + userPrompt: string, + chatHistory?: ChatMessage[], + projectInstructions?: string + ): Promise> { + try { + const history = formatHistory(chatHistory) + return await generate(GENERAL_RESPONSE_PROMPT(userPrompt, history, projectInstructions)) + } catch (err) { + logger.error('MiniMax general response error: ' + err) + throw err + } + }, + + async classifyIntent( + prompt: string, + chatHistory?: ChatMessage[], + projectInstructions?: string + ): Promise< + ModelResponse<{ type?: 'general' | 'compilation' | 'analytics' | undefined; needsVideoData?: boolean | undefined }> + > { + try { + const history = formatHistory(chatHistory) + const content = CLASSIFY_INTENT_PROMPT(prompt, history, projectInstructions) + const { data: raw, tokens } = await generateJSON(content) + + return { + data: JSON.parse(raw), + tokens, + error: undefined, + } + } catch (error) { + logger.error('MiniMax classifyIntent error: ' + error) + throw error + } + }, + + async generateCompilationResponse( + userPrompt: string, + count: number, + chatHistory?: ChatMessage[], + projectInstructions?: string + ): Promise> { + try { + const history = formatHistory(chatHistory) + return await generate(VIDEO_COMPILATION_MESSAGE_PROMPT(userPrompt, count, history, projectInstructions)) + } catch (error) { + logger.error('MiniMax compilation response error: ' + error) + throw error + } + }, + + async generateAnalyticsResponse( + userPrompt: string, + analytics: VideoAnalytics, + chatHistory?: ChatMessage[], + projectInstructions?: string + ): Promise> { + try { + const history = formatHistory(chatHistory) + return await generate(ANALYTICS_RESPONSE_PROMPT(userPrompt, analytics, history, projectInstructions)) + } catch (error) { + logger.error('MiniMax analytics response error: ' + error) + throw error + } + }, +} diff --git a/packages/ai/src/services/modelRouter.ts b/packages/ai/src/services/modelRouter.ts index 9794b6c0..a7207e29 100644 --- a/packages/ai/src/services/modelRouter.ts +++ b/packages/ai/src/services/modelRouter.ts @@ -1,11 +1,15 @@ import { GeminiModel } from '@ai/services/gemini' import { OllamaModel } from '@ai/services/ollama' +import { MiniMaxModel } from '@ai/services/minimax' import { logger } from '@shared/services/logger' import { GEMINI_API_KEY, GEMINI_MODEL_NAME, + MINIMAX_API_KEY, + MINIMAX_MODEL, OLLAMA_MODEL, USE_GEMINI, + USE_MINIMAX, USE_OLLAMA_MODEL, } from '@ai/constants' import { AIModel } from '@ai/types/ai' @@ -20,11 +24,14 @@ const setupModel = () => { if (USE_OLLAMA_MODEL && OLLAMA_MODEL) { logger.debug(`Using Ollama Model: ${OLLAMA_MODEL}`) activeModel = OllamaModel - } if (GEMINI_API_KEY && USE_GEMINI) { + } else if (MINIMAX_API_KEY && USE_MINIMAX) { + logger.debug(`Using MiniMax Model: ${MINIMAX_MODEL}`) + activeModel = MiniMaxModel + } else if (GEMINI_API_KEY && USE_GEMINI) { logger.debug(`Using Gemini Model: ${GEMINI_MODEL_NAME}`) activeModel = GeminiModel } else { - throw new Error('No valid AI backend found. Set USE_OLLAMA_MODEL + OLLAMA_MODEL or GEMINI_API_KEY + USE_GEMINI.') + throw new Error('No valid AI backend found. Set USE_OLLAMA_MODEL + OLLAMA_MODEL, MINIMAX_API_KEY + USE_MINIMAX, or GEMINI_API_KEY + USE_GEMINI.') } } diff --git a/packages/ai/tests/services/minimax.integration.test.ts b/packages/ai/tests/services/minimax.integration.test.ts new file mode 100644 index 00000000..27277bae --- /dev/null +++ b/packages/ai/tests/services/minimax.integration.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const MINIMAX_API_KEY = process.env.MINIMAX_API_KEY + +const describeIntegration = MINIMAX_API_KEY ? describe : describe.skip + +describeIntegration('MiniMax Integration Tests (requires MINIMAX_API_KEY)', () => { + beforeEach(() => { + vi.resetModules() + + vi.doMock('@shared/services/logger', () => ({ + logger: { error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, + })) + + vi.doMock('@ai/utils', () => ({ + formatHistory: vi.fn().mockReturnValue(''), + })) + + vi.doMock('@shared/schemas/search', () => ({ + VideoSearchParamsSchema: { + parse: vi.fn().mockImplementation((v: unknown) => v ?? {}), + }, + })) + + vi.doMock('@shared/schemas/yearInReview', () => ({ + YearInReviewDataSchema: { + parse: vi.fn().mockImplementation((v: unknown) => v), + }, + })) + + vi.doMock('@ai/constants', () => ({ + MINIMAX_API_KEY, + MINIMAX_MODEL: process.env.MINIMAX_MODEL || 'MiniMax-M1', + })) + }) + + it('should generate a general response from MiniMax API', async () => { + vi.doMock('@ai/constants/prompts', () => ({ + GENERAL_RESPONSE_PROMPT: vi.fn().mockReturnValue('Respond with exactly one word: hello'), + SEARCH_PROMPT: vi.fn(), + ASSISTANT_MESSAGE_PROMPT: vi.fn(), + VIDEO_COMPILATION_MESSAGE_PROMPT: vi.fn(), + YEAR_IN_REVIEW: vi.fn(), + CLASSIFY_INTENT_PROMPT: vi.fn(), + ANALYTICS_RESPONSE_PROMPT: vi.fn(), + })) + + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.generateGeneralResponse('say hello') + + expect(result.data).toBeTruthy() + expect(typeof result.data).toBe('string') + expect(result.tokens).toBeGreaterThan(0) + }, 30000) + + it('should classify intent as JSON', async () => { + vi.doMock('@ai/constants/prompts', () => ({ + CLASSIFY_INTENT_PROMPT: vi.fn().mockReturnValue( + 'You must respond with only valid JSON, no other text. Output: {"type": "general", "needsVideoData": false}' + ), + SEARCH_PROMPT: vi.fn(), + ASSISTANT_MESSAGE_PROMPT: vi.fn(), + VIDEO_COMPILATION_MESSAGE_PROMPT: vi.fn(), + YEAR_IN_REVIEW: vi.fn(), + GENERAL_RESPONSE_PROMPT: vi.fn(), + ANALYTICS_RESPONSE_PROMPT: vi.fn(), + })) + + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.classifyIntent('hello') + + expect(result.data).toHaveProperty('type') + expect(result.tokens).toBeGreaterThan(0) + }, 30000) + + it('should generate action from prompt as JSON', async () => { + vi.doMock('@ai/constants/prompts', () => ({ + SEARCH_PROMPT: vi.fn().mockReturnValue( + 'You must respond with only valid JSON, no other text. Output: {"emotions":[],"objects":["car"],"faces":[],"limit":30}' + ), + ASSISTANT_MESSAGE_PROMPT: vi.fn(), + VIDEO_COMPILATION_MESSAGE_PROMPT: vi.fn(), + YEAR_IN_REVIEW: vi.fn(), + GENERAL_RESPONSE_PROMPT: vi.fn(), + CLASSIFY_INTENT_PROMPT: vi.fn(), + ANALYTICS_RESPONSE_PROMPT: vi.fn(), + })) + + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.generateActionFromPrompt('find cars in my videos') + + expect(result.error).toBeUndefined() + expect(result.tokens).toBeGreaterThan(0) + }, 30000) +}) diff --git a/packages/ai/tests/services/minimax.test.ts b/packages/ai/tests/services/minimax.test.ts new file mode 100644 index 00000000..5fbef831 --- /dev/null +++ b/packages/ai/tests/services/minimax.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockCreate = vi.fn() + +vi.mock('@shared/services/logger', () => ({ + logger: { error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, +})) + +vi.mock('@ai/constants', () => ({ + MINIMAX_API_KEY: 'test-minimax-key', + MINIMAX_MODEL: 'MiniMax-M1', +})) + +vi.mock('openai', () => { + const MockOpenAI = function (this: Record, _config: unknown) { + this.chat = { + completions: { + create: mockCreate, + }, + } + } + return { default: MockOpenAI } +}) + +vi.mock('@ai/utils', () => ({ + formatHistory: vi.fn().mockReturnValue(''), +})) + +vi.mock('@ai/constants/prompts', () => ({ + SEARCH_PROMPT: vi.fn().mockReturnValue('search prompt'), + ASSISTANT_MESSAGE_PROMPT: vi.fn().mockReturnValue('assistant prompt'), + VIDEO_COMPILATION_MESSAGE_PROMPT: vi.fn().mockReturnValue('compilation prompt'), + YEAR_IN_REVIEW: vi.fn().mockReturnValue('year review prompt'), + GENERAL_RESPONSE_PROMPT: vi.fn().mockReturnValue('general prompt'), + CLASSIFY_INTENT_PROMPT: vi.fn().mockReturnValue('classify prompt'), + ANALYTICS_RESPONSE_PROMPT: vi.fn().mockReturnValue('analytics prompt'), +})) + +vi.mock('@shared/schemas/search', () => ({ + VideoSearchParamsSchema: { + parse: vi.fn().mockImplementation((v: unknown) => v ?? {}), + }, +})) + +vi.mock('@shared/schemas/yearInReview', () => ({ + YearInReviewDataSchema: { + parse: vi.fn().mockImplementation((v: unknown) => v), + }, +})) + +describe('MiniMax Model', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should generate action from prompt with JSON response format', async () => { + mockCreate.mockResolvedValue({ + choices: [ + { + message: { + content: JSON.stringify({ + emotions: [], + objects: ['car'], + faces: [], + limit: 30, + }), + }, + }, + ], + usage: { prompt_tokens: 100 }, + }) + + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.generateActionFromPrompt('find cars') + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'MiniMax-M1', + response_format: { type: 'json_object' }, + }) + ) + expect(result.tokens).toBe(100) + expect(result.error).toBeUndefined() + }) + + it('should return fallback for empty query', async () => { + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.generateActionFromPrompt('') + + expect(mockCreate).not.toHaveBeenCalled() + expect(result.tokens).toBe(0) + }) + + it('should generate assistant message', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'Here are 5 scenes matching your request.' } }], + usage: { prompt_tokens: 50 }, + }) + + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.generateAssistantMessage('find beach videos', 5) + + expect(result.data).toBe('Here are 5 scenes matching your request.') + expect(result.tokens).toBe(50) + }) + + it('should generate general response', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'Edit Mind helps you search videos.' } }], + usage: { prompt_tokens: 30 }, + }) + + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.generateGeneralResponse('what is edit mind?') + + expect(result.data).toBe('Edit Mind helps you search videos.') + expect(result.tokens).toBe(30) + }) + + it('should classify intent correctly', async () => { + const intentData = { type: 'analytics', needsVideoData: true } + mockCreate.mockResolvedValue({ + choices: [{ message: { content: JSON.stringify(intentData) } }], + usage: { prompt_tokens: 20 }, + }) + + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.classifyIntent('show me video stats') + + expect(result.data).toEqual(intentData) + expect(result.tokens).toBe(20) + }) + + it('should generate compilation response', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'Your compilation of 3 scenes is ready.' } }], + usage: { prompt_tokens: 40 }, + }) + + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.generateCompilationResponse('compile sunset shots', 3) + + expect(result.data).toBe('Your compilation of 3 scenes is ready.') + expect(result.tokens).toBe(40) + }) + + it('should generate analytics response', async () => { + const analytics = { + totalDuration: 3600, + totalDurationFormatted: '1h', + uniqueVideos: 10, + totalScenes: 50, + dateRange: { oldest: new Date(), newest: new Date() }, + emotionCounts: { happy: 20 }, + faceOccurrences: {}, + objectsOccurrences: { car: 5 }, + averageSceneDuration: 12, + } + + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'Your library has 10 videos totaling 1 hour.' } }], + usage: { prompt_tokens: 80 }, + }) + + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.generateAnalyticsResponse('summarize analytics', analytics) + + expect(result.data).toBe('Your library has 10 videos totaling 1 hour.') + expect(result.tokens).toBe(80) + }) + + it('should generate year in review response', async () => { + const yearData = { + title: 'Your 2024', + sections: [], + } + mockCreate.mockResolvedValue({ + choices: [{ message: { content: JSON.stringify(yearData) } }], + usage: { prompt_tokens: 120 }, + }) + + const stats = { + totalVideos: 10, + totalDuration: 100, + totalScenes: 100, + topEmotions: [], + topFaces: [], + topShotTypes: [], + topObjects: [], + categories: [], + topWords: [], + longestScene: { duration: 10, description: '', videoSource: 'test.mp4' }, + shortestScene: { duration: 1, description: '', videoSource: 'test2.mp4' }, + } + + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.generateYearInReviewResponse(stats, [], 'extra details') + + expect(result.data).toEqual(yearData) + expect(result.tokens).toBe(120) + }) + + it('should strip think tags from response', async () => { + mockCreate.mockResolvedValue({ + choices: [ + { + message: { + content: 'internal reasoningThe actual response.', + }, + }, + ], + usage: { prompt_tokens: 15 }, + }) + + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.generateGeneralResponse('test') + + expect(result.data).toBe('The actual response.') + }) + + it('should handle missing content gracefully', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: null } }], + usage: { prompt_tokens: 5 }, + }) + + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.generateGeneralResponse('test') + + expect(result.data).toBe('') + }) + + it('should return error string for invalid JSON in generateActionFromPrompt', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'not valid json' } }], + usage: { prompt_tokens: 10 }, + }) + + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.generateActionFromPrompt('test query') + + expect(result.error).toBe('Invalid JSON') + expect(result.tokens).toBe(0) + }) + + it('should return null data for invalid year in review JSON', async () => { + const { YearInReviewDataSchema } = await import('@shared/schemas/yearInReview') + ;(YearInReviewDataSchema.parse as ReturnType).mockImplementationOnce(() => { + throw new Error('Invalid schema') + }) + + mockCreate.mockResolvedValue({ + choices: [{ message: { content: '{"invalid": true}' } }], + usage: { prompt_tokens: 10 }, + }) + + const stats = { + totalVideos: 1, + totalDuration: 10, + totalScenes: 5, + topEmotions: [], + topFaces: [], + topShotTypes: [], + topObjects: [], + categories: [], + topWords: [], + longestScene: { duration: 5, description: '', videoSource: 'a.mp4' }, + shortestScene: { duration: 1, description: '', videoSource: 'b.mp4' }, + } + + const { MiniMaxModel } = await import('@ai/services/minimax') + const result = await MiniMaxModel.generateYearInReviewResponse(stats, [], 'details') + + expect(result.data).toBeNull() + expect(result.error).toBe('Invalid JSON response from AI') + }) + + it('should set temperature to 0.7', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'ok' } }], + usage: { prompt_tokens: 1 }, + }) + + const { MiniMaxModel } = await import('@ai/services/minimax') + await MiniMaxModel.generateGeneralResponse('test') + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }) + ) + }) + + it('should use MiniMax-M1 as default model', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'ok' } }], + usage: { prompt_tokens: 1 }, + }) + + const { MiniMaxModel } = await import('@ai/services/minimax') + await MiniMaxModel.generateGeneralResponse('test') + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'MiniMax-M1', + }) + ) + }) +}) diff --git a/packages/ai/tests/services/modelRouter.test.ts b/packages/ai/tests/services/modelRouter.test.ts index 29e269ad..4c707f9a 100644 --- a/packages/ai/tests/services/modelRouter.test.ts +++ b/packages/ai/tests/services/modelRouter.test.ts @@ -24,6 +24,18 @@ const mockOllamaModel = { generateYearInReviewResponse: vi.fn(), cleanUp: vi.fn(), } + +const mockMiniMaxModel = { + generateActionFromPrompt: vi.fn(), + generateAssistantMessage: vi.fn(), + generateCompilationResponse: vi.fn(), + generateGeneralResponse: vi.fn(), + classifyIntent: vi.fn(), + generateAnalyticsResponse: vi.fn(), + generateYearInReviewResponse: vi.fn(), + cleanUp: vi.fn(), +} + const mockLogger = { debug: vi.fn(), error: vi.fn(), @@ -33,12 +45,15 @@ vi.mock('@ai/services/gemini', () => ({ GeminiModel: mockGeminiModel, })) -vi.mock('@ai/services/logger', () => ({ +vi.mock('@shared/services/logger', () => ({ logger: mockLogger, })) vi.mock('@ai/services/ollama', () => ({ OllamaModel: mockOllamaModel, })) +vi.mock('@ai/services/minimax', () => ({ + MiniMaxModel: mockMiniMaxModel, +})) const mockConstants: Record = { GEMINI_API_KEY: null, @@ -46,6 +61,9 @@ const mockConstants: Record = { USE_GEMINI: false, OLLAMA_MODEL: "qwen2.5:7b-instruct", USE_OLLAMA_MODEL: false, + MINIMAX_API_KEY: null, + MINIMAX_MODEL: 'MiniMax-M1', + USE_MINIMAX: false, } vi.mock('@ai/constants', () => mockConstants) @@ -108,6 +126,8 @@ describe('Model Router', () => { mockConstants.USE_GEMINI = false mockConstants.OLLAMA_MODEL = '/name/of/model' mockConstants.USE_OLLAMA_MODEL = true + mockConstants.MINIMAX_API_KEY = null + mockConstants.USE_MINIMAX = false vi.resetModules() const modelRouter = await import('@ai/services/modelRouter') @@ -115,6 +135,21 @@ describe('Model Router', () => { await modelRouter.generateActionFromPrompt('test', dummyHistory) expect(mockOllamaModel.generateActionFromPrompt).toHaveBeenCalledWith('test', dummyHistory) }) + + it('should use MiniMax when MINIMAX_API_KEY and USE_MINIMAX is set', async () => { + mockConstants.GEMINI_API_KEY = null + mockConstants.USE_GEMINI = false + mockConstants.OLLAMA_MODEL = null + mockConstants.USE_OLLAMA_MODEL = false + mockConstants.MINIMAX_API_KEY = 'test-minimax-key' + mockConstants.USE_MINIMAX = true + + vi.resetModules() + const modelRouter = await import('@ai/services/modelRouter') + + await modelRouter.generateActionFromPrompt('test', dummyHistory) + expect(mockMiniMaxModel.generateActionFromPrompt).toHaveBeenCalledWith('test', dummyHistory) + }) }) describe('Function Calls', () => { beforeEach(async () => { diff --git a/packages/ai/vitest.config.ts b/packages/ai/vitest.config.ts index 4199932d..26b103a3 100644 --- a/packages/ai/vitest.config.ts +++ b/packages/ai/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ resolve: { alias: { '@ai': path.resolve(__dirname, './src'), - '@shared': path.resolve(__dirname, '../../packages/shared/dist'), + '@shared': path.resolve(__dirname, '../shared/src'), }, }, })