diff --git a/apps/mentora/src/routes/api/[...path]/+server.ts b/apps/mentora/src/routes/api/[...path]/+server.ts index 7aec892..a96fe5c 100644 --- a/apps/mentora/src/routes/api/[...path]/+server.ts +++ b/apps/mentora/src/routes/api/[...path]/+server.ts @@ -7,15 +7,11 @@ import { firestore } from "$lib/server/firestore"; import { createServerHandler } from "mentora-api/server"; import type { RequestHandler } from "./$types"; -// Expose GEMINI_API_KEY to process.env so @google/genai SDK can read it -if (env.GEMINI_API_KEY && !process.env.GEMINI_API_KEY) { - process.env.GEMINI_API_KEY = env.GEMINI_API_KEY; -} - const useEmulator = PUBLIC_USE_FIREBASE_EMULATOR === "true"; const handler = createServerHandler({ firestore, + geminiApiKey: env.GEMINI_API_KEY || env.GOOGLE_API_KEY, projectId: useEmulator ? "demo-mentora" : PUBLIC_FIREBASE_PROJECT_ID, useEmulator, }); diff --git a/packages/mentora-api/.env.example b/packages/mentora-api/.env.example index 2f9592d..38a23ef 100644 --- a/packages/mentora-api/.env.example +++ b/packages/mentora-api/.env.example @@ -5,7 +5,7 @@ PUBLIC_FIREBASE_AUTH_DOMAIN=mentora-apps.firebaseapp.com PUBLIC_FIREBASE_PROJECT_ID=mentora-apps PUBLIC_FIREBASE_APP_ID=1:37581253555:web:bf735299c38e3e21b079c6 -GOOGLE_GENAI_API_KEY=example-api-key +GEMINI_API_KEY=example-api-key # Secret code required to unlock mentor mode -MENTOR_ACCESS_CODE=your-secret-code-here \ No newline at end of file +MENTOR_ACCESS_CODE=your-secret-code-here diff --git a/packages/mentora-api/src/lib/server/application/container.ts b/packages/mentora-api/src/lib/server/application/container.ts index b3c990f..fd66726 100644 --- a/packages/mentora-api/src/lib/server/application/container.ts +++ b/packages/mentora-api/src/lib/server/application/container.ts @@ -30,13 +30,14 @@ export function createServiceContainer(ctx: RouteContext) { const catalogService = new CatalogService(courseService, courseRepository); const conversationService = new ConversationService( conversationRepository, - new FirestoreConversationLLMGateway(ctx.firestore), - walletRepository + new FirestoreConversationLLMGateway(ctx.firestore, ctx.geminiApiKey), + walletRepository, + ctx.geminiApiKey ); const walletService = new WalletService(walletRepository); const analyticsService = new AnalyticsService(analyticsRepository); const healthService = new HealthService(healthRepository); - const contentGenerationService = new ContentGenerationService(); + const contentGenerationService = new ContentGenerationService(ctx.geminiApiKey); return { announcementService, diff --git a/packages/mentora-api/src/lib/server/application/content-generation-service.ts b/packages/mentora-api/src/lib/server/application/content-generation-service.ts index 4269449..82a38b6 100644 --- a/packages/mentora-api/src/lib/server/application/content-generation-service.ts +++ b/packages/mentora-api/src/lib/server/application/content-generation-service.ts @@ -2,8 +2,10 @@ import { EXECUTOR_MODEL, getContentExecutor } from '../llm/executors.js'; import { TOKEN_USAGE_FEATURES, createTokenUsageReport } from '../llm/token-usage.js'; export class ContentGenerationService { + constructor(private readonly geminiApiKey?: string) {} + async generateAssignmentContent(question: string) { - const contentExecutor = getContentExecutor(); + const contentExecutor = getContentExecutor(this.geminiApiKey); contentExecutor.resetTokenUsage(); const generatedContent = await contentExecutor.generateContent(question); const tokenUsage = createTokenUsageReport([ diff --git a/packages/mentora-api/src/lib/server/application/conversation-service.ts b/packages/mentora-api/src/lib/server/application/conversation-service.ts index 7e18d48..6ee573d 100644 --- a/packages/mentora-api/src/lib/server/application/conversation-service.ts +++ b/packages/mentora-api/src/lib/server/application/conversation-service.ts @@ -68,7 +68,8 @@ export class ConversationService { constructor( private readonly conversationRepository: IConversationRepository, private readonly llmGateway: IConversationLLMGateway, - private readonly walletRepository: IWalletRepository + private readonly walletRepository: IWalletRepository, + private readonly geminiApiKey?: string ) {} private async ensureSubmissionInProgress(assignment: Assignment, userId: string): Promise { @@ -279,44 +280,6 @@ export class ConversationService { ); } - const now = Date.now(); - const userTurnId = randomUUID(); - let asrUsageReport = createTokenUsageReport([]); - let llmUsageReport = createTokenUsageReport([]); - let ttsUsageReport = createTokenUsageReport([]); - let userInputText: string; - - if ('audioBase64' in input) { - try { - const asrExecutor = getASRExecutor(); - asrExecutor.resetTokenUsage(); - userInputText = await asrExecutor.transcribe(input.audioBase64, input.audioMimeType); - userInputText = userInputText.trim(); - if (!userInputText) { - throw errorResponse( - 'No speech detected in the audio. Please try again or use text input.', - HttpStatus.BAD_REQUEST, - ServerErrorCode.INVALID_INPUT - ); - } - asrUsageReport = createTokenUsageReport([ - { - feature: TOKEN_USAGE_FEATURES.CONVERSATION_ASR, - usage: asrExecutor.getTokenUsage() - } - ]); - } catch (error) { - if (error instanceof Response) throw error; - throw errorResponse( - 'Failed to transcribe audio. Please try again or use text input.', - HttpStatus.INTERNAL_SERVER_ERROR, - ServerErrorCode.INTERNAL_ERROR - ); - } - } else { - userInputText = input.text; - } - const assignment = await this.conversationRepository.getAssignment(conversation.assignmentId); if (!assignment) { throw errorResponse('Assignment not found', HttpStatus.NOT_FOUND, ServerErrorCode.NOT_FOUND); @@ -369,6 +332,46 @@ export class ConversationService { } } + const requestApiKey = apiKey ?? this.geminiApiKey; + + const now = Date.now(); + const userTurnId = randomUUID(); + let asrUsageReport = createTokenUsageReport([]); + let llmUsageReport = createTokenUsageReport([]); + let ttsUsageReport = createTokenUsageReport([]); + let userInputText: string; + + if ('audioBase64' in input) { + try { + const asrExecutor = getASRExecutor(requestApiKey); + asrExecutor.resetTokenUsage(); + userInputText = await asrExecutor.transcribe(input.audioBase64, input.audioMimeType); + userInputText = userInputText.trim(); + if (!userInputText) { + throw errorResponse( + 'No speech detected in the audio. Please try again or use text input.', + HttpStatus.BAD_REQUEST, + ServerErrorCode.INVALID_INPUT + ); + } + asrUsageReport = createTokenUsageReport([ + { + feature: TOKEN_USAGE_FEATURES.CONVERSATION_ASR, + usage: asrExecutor.getTokenUsage() + } + ]); + } catch (error) { + if (error instanceof Response) throw error; + throw errorResponse( + 'Failed to transcribe audio. Please try again or use text input.', + HttpStatus.INTERNAL_SERVER_ERROR, + ServerErrorCode.INTERNAL_ERROR + ); + } + } else { + userInputText = input.text; + } + let llmResult: Awaited>; try { llmResult = await this.llmGateway.process({ @@ -377,12 +380,13 @@ export class ConversationService { userInputText, question: assignment.question || '', prompt: assignment.prompt || '', - apiKey + apiKey: requestApiKey }); } catch (error) { if ( error instanceof Error && assignment.courseId && + apiKey && (error.message.includes('401') || error.message.includes('403') || error.message.includes('API_KEY_INVALID')) @@ -406,7 +410,7 @@ export class ConversationService { let aiAudioBase64: string; const aiAudioMimeType = 'audio/mp3'; try { - const ttsExecutor = getTTSExecutor(); + const ttsExecutor = getTTSExecutor(requestApiKey); ttsExecutor.resetTokenUsage(); aiAudioBase64 = await ttsExecutor.synthesize(llmResult.aiMessage); ttsUsageReport = createTokenUsageReport([ @@ -419,6 +423,7 @@ export class ConversationService { if ( error instanceof Error && assignment.courseId && + apiKey && (error.message.includes('401') || error.message.includes('403') || error.message.includes('API_KEY_INVALID')) diff --git a/packages/mentora-api/src/lib/server/application/gateways/conversation-llm-gateway.ts b/packages/mentora-api/src/lib/server/application/gateways/conversation-llm-gateway.ts index e9b0402..b70bace 100644 --- a/packages/mentora-api/src/lib/server/application/gateways/conversation-llm-gateway.ts +++ b/packages/mentora-api/src/lib/server/application/gateways/conversation-llm-gateway.ts @@ -17,7 +17,10 @@ export interface IConversationLLMGateway { } export class FirestoreConversationLLMGateway implements IConversationLLMGateway { - constructor(private readonly firestore: Firestore) {} + constructor( + private readonly firestore: Firestore, + private readonly defaultApiKey?: string + ) {} async process(params: { conversationId: string; @@ -34,7 +37,7 @@ export class FirestoreConversationLLMGateway implements IConversationLLMGateway params.userInputText, params.question, params.prompt, - params.apiKey + params.apiKey ?? this.defaultApiKey ); } diff --git a/packages/mentora-api/src/lib/server/handler.ts b/packages/mentora-api/src/lib/server/handler.ts index 3379fd2..a1259a8 100644 --- a/packages/mentora-api/src/lib/server/handler.ts +++ b/packages/mentora-api/src/lib/server/handler.ts @@ -212,6 +212,7 @@ export class MentoraServerHandler { const ctx: RouteContext = { firestore: this.config.firestore, projectId: this.config.projectId, + geminiApiKey: this.config.geminiApiKey, user, params, query diff --git a/packages/mentora-api/src/lib/server/types.ts b/packages/mentora-api/src/lib/server/types.ts index 858e905..353f958 100644 --- a/packages/mentora-api/src/lib/server/types.ts +++ b/packages/mentora-api/src/lib/server/types.ts @@ -34,6 +34,8 @@ export interface AuthContext { export interface ServerConfig { firestore: Firestore; projectId: string; + /** Default Gemini API key used when a request does not provide a course-specific key. */ + geminiApiKey?: string; /** Set to true when running against Firebase Emulators (skips JWT signature verification) */ useEmulator?: boolean; } @@ -46,6 +48,8 @@ export interface RouteContext { firestore: Firestore; /** Firebase project ID (for JWT verification) */ projectId: string; + /** Default Gemini API key used when no course-specific key is available. */ + geminiApiKey?: string; /** Authenticated user (null if not authenticated) */ user: AuthContext | null; /** URL path parameters (e.g., { id: "abc123" }) */ diff --git a/packages/mentora-api/src/routes/api/[...path]/+server.ts b/packages/mentora-api/src/routes/api/[...path]/+server.ts index 5053914..ccc53f4 100644 --- a/packages/mentora-api/src/routes/api/[...path]/+server.ts +++ b/packages/mentora-api/src/routes/api/[...path]/+server.ts @@ -1,4 +1,5 @@ import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; import { createServerHandler } from '$lib/server'; import { Firestore } from 'fires2rest'; @@ -6,6 +7,7 @@ const firestore = Firestore.useEmulator({ projectId: 'demo-mentora' }); const handler = createServerHandler({ firestore, + geminiApiKey: env.GEMINI_API_KEY || env.GOOGLE_API_KEY, projectId: 'demo-mentora', useEmulator: true }); diff --git a/packages/mentora-api/tests/addTurn-integration.test.ts b/packages/mentora-api/tests/addTurn-integration.test.ts index 115c7c5..7c9c5ac 100644 --- a/packages/mentora-api/tests/addTurn-integration.test.ts +++ b/packages/mentora-api/tests/addTurn-integration.test.ts @@ -151,17 +151,20 @@ describe('addTurn Route Handler (Integration)', () => { describe('Turn Addition and Response', () => { // REMOVED: "should accept first turn from authorized user" - requires LLM API key - it.skipIf(!process.env.GOOGLE_GENAI_API_KEY)('should accept subsequent turns', async () => { - // First turn already added in previous test - // Add second turn - const result = await studentClient.conversations.addTurn( - testConversationId, - 'However, I also consider the collective perspective', - 'followup' - ); + it.skipIf(!process.env.GEMINI_API_KEY && !process.env.GOOGLE_API_KEY)( + 'should accept subsequent turns', + async () => { + // First turn already added in previous test + // Add second turn + const result = await studentClient.conversations.addTurn( + testConversationId, + 'However, I also consider the collective perspective', + 'followup' + ); - expect(result.success).toBe(true); - }); + expect(result.success).toBe(true); + } + ); it('should handle empty text gracefully', async () => { const result = await studentClient.conversations.addTurn(testConversationId, '', 'idea'); @@ -188,7 +191,7 @@ describe('addTurn Route Handler (Integration)', () => { }); describe('Conversation State', () => { - it.skipIf(!process.env.GOOGLE_GENAI_API_KEY)( + it.skipIf(!process.env.GEMINI_API_KEY && !process.env.GOOGLE_API_KEY)( 'should transition conversation state based on dialogue progress', async () => { // Create a fresh conversation to test state transitions @@ -227,7 +230,7 @@ describe('addTurn Route Handler (Integration)', () => { } ); - it.skipIf(!process.env.GOOGLE_GENAI_API_KEY)( + it.skipIf(!process.env.GEMINI_API_KEY && !process.env.GOOGLE_API_KEY)( 'should persist turns in conversation history', async () => { // Create a fresh conversation diff --git a/packages/mentora-api/tests/conversation-service-asr.unit.test.ts b/packages/mentora-api/tests/conversation-service-asr.unit.test.ts index f477924..cdc5326 100644 --- a/packages/mentora-api/tests/conversation-service-asr.unit.test.ts +++ b/packages/mentora-api/tests/conversation-service-asr.unit.test.ts @@ -226,4 +226,54 @@ describe('ConversationService.addTurn – ASR error handling', () => { expect.objectContaining({ userInputText: 'Hello world' }) ); }); + + it('uses the course wallet API key for ASR, LLM, and TTS when available', async () => { + const repo = createMockRepo(); + const gateway = createMockLLMGateway(); + const walletRepository = createMockWalletRepo(); + walletRepository.getWallet = vi.fn().mockResolvedValue({ + status: 'active', + apiKey: 'course-api-key' + } as any); + const service = new ConversationService(repo, gateway, walletRepository, 'global-api-key'); + + mockedGetASRExecutor.mockReturnValue(createMockASRExecutor('Hello world') as any); + mockedGetTTSExecutor.mockReturnValue(createMockTTSExecutor() as any); + + await service.addTurn(user, 'conv-1', { + audioBase64: 'dGVzdA==', + audioMimeType: 'audio/webm' + }); + + expect(mockedGetASRExecutor).toHaveBeenCalledWith('course-api-key'); + expect(gateway.process).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: 'course-api-key', userInputText: 'Hello world' }) + ); + expect(mockedGetTTSExecutor).toHaveBeenCalledWith('course-api-key'); + }); + + it('falls back to the configured global API key when no course wallet key exists', async () => { + const repo = createMockRepo(); + const gateway = createMockLLMGateway(); + const service = new ConversationService( + repo, + gateway, + createMockWalletRepo(), + 'global-api-key' + ); + + mockedGetASRExecutor.mockReturnValue(createMockASRExecutor('Hello world') as any); + mockedGetTTSExecutor.mockReturnValue(createMockTTSExecutor() as any); + + await service.addTurn(user, 'conv-1', { + audioBase64: 'dGVzdA==', + audioMimeType: 'audio/webm' + }); + + expect(mockedGetASRExecutor).toHaveBeenCalledWith('global-api-key'); + expect(gateway.process).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: 'global-api-key', userInputText: 'Hello world' }) + ); + expect(mockedGetTTSExecutor).toHaveBeenCalledWith('global-api-key'); + }); }); diff --git a/packages/mentora-api/tests/emulator-vitest-setup.ts b/packages/mentora-api/tests/emulator-vitest-setup.ts index 697bef4..bb6d6cb 100644 --- a/packages/mentora-api/tests/emulator-vitest-setup.ts +++ b/packages/mentora-api/tests/emulator-vitest-setup.ts @@ -25,6 +25,7 @@ async function getBackendHandler() { return createServerHandler({ firestore: Firestore.useEmulator({ projectId: 'demo-mentora' }), + geminiApiKey: process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY, projectId: 'demo-mentora', useEmulator: true });