From c00d5344b822854b80f1d46cadfb7cd971a7839c Mon Sep 17 00:00:00 2001 From: JacobLinCool Date: Wed, 8 Apr 2026 05:03:59 +0800 Subject: [PATCH] fix: add GEMINI_API_KEY support and pass through to services Introduce GEMINI_API_KEY as the default LLM API key and thread it through the server stack. The server config and RouteContext now accept an optional geminiApiKey which is provided by SvelteKit handlers and emulator test setup. Conversation and content-generation services, and the Firestore LLM gateway, now accept and use a default API key while allowing per-course wallet API keys to override it. Updated .env.example, tests, and route handlers to use GEMINI_API_KEY (with GOOGLE_API_KEY fallback) and adjusted integration/unit tests to assert key selection and skip conditions accordingly. --- .../src/routes/api/[...path]/+server.ts | 6 +- packages/mentora-api/.env.example | 4 +- .../src/lib/server/application/container.ts | 7 +- .../application/content-generation-service.ts | 4 +- .../application/conversation-service.ts | 87 ++++++++++--------- .../gateways/conversation-llm-gateway.ts | 7 +- .../mentora-api/src/lib/server/handler.ts | 1 + packages/mentora-api/src/lib/server/types.ts | 4 + .../src/routes/api/[...path]/+server.ts | 2 + .../tests/addTurn-integration.test.ts | 27 +++--- .../conversation-service-asr.unit.test.ts | 50 +++++++++++ .../tests/emulator-vitest-setup.ts | 1 + 12 files changed, 134 insertions(+), 66 deletions(-) diff --git a/apps/mentora/src/routes/api/[...path]/+server.ts b/apps/mentora/src/routes/api/[...path]/+server.ts index 7aec8929..a96fe5c7 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 2f9592d0..38a23efa 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 b3c990f7..fd66726f 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 4269449b..82a38b68 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 7e18d488..6ee573d4 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 e9b04026..b70baceb 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 3379fd27..a1259a80 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 858e905d..353f9582 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 50539142..ccc53f4a 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 115c7c59..7c9c5ac7 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 f477924d..cdc5326a 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 697bef48..bb6d6cb3 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 });