Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions apps/mentora/src/routes/api/[...path]/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
4 changes: 2 additions & 2 deletions packages/mentora-api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
MENTOR_ACCESS_CODE=your-secret-code-here
Comment on lines +8 to +11

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.env.example documents GEMINI_API_KEY, but the runtime/test code also supports falling back to GOOGLE_API_KEY (and several tests skip based on that). Consider documenting the GOOGLE_API_KEY fallback here (or adding a short comment) so local setup matches the supported configuration.

Copilot uses AI. Check for mistakes.
7 changes: 4 additions & 3 deletions packages/mentora-api/src/lib/server/application/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Comment on lines +335 to +347

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requestApiKey is now passed into ASR/LLM/TTS executor factories for every request. In llm/executors.ts, providing an apiKey bypasses the singleton client and creates a fresh GoogleGenAI instance each time; this means a single addTurn can allocate multiple clients (ASR + prompt + TTS), and this will happen on every request when a global key is configured.

To avoid a perf/GC regression, consider caching clients by apiKey (e.g., a Map keyed by apiKey) or otherwise reusing a shared client for the configured default key while still allowing per-course overrides.

Copilot uses AI. Check for mistakes.
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
);
Comment on lines +344 to +369

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the audio (ASR) path, invalid course wallet keys are not flagged as invalid_key the way they are for LLM/TTS failures. If the course wallet API key is wrong, ASR will repeatedly fail with a 500 and the wallet stays active.

Consider mirroring the LLM/TTS invalid-key detection in the ASR catch (for assignment.courseId && apiKey and 401/403/API_KEY_INVALID) so the wallet gets marked invalid_key consistently across ASR/LLM/TTS.

Copilot uses AI. Check for mistakes.
}
} else {
userInputText = input.text;
}

let llmResult: Awaited<ReturnType<IConversationLLMGateway['process']>>;
try {
llmResult = await this.llmGateway.process({
Expand All @@ -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'))
Expand All @@ -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([
Expand All @@ -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'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,7 +37,7 @@ export class FirestoreConversationLLMGateway implements IConversationLLMGateway
params.userInputText,
params.question,
params.prompt,
params.apiKey
params.apiKey ?? this.defaultApiKey
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/mentora-api/src/lib/server/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/mentora-api/src/lib/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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" }) */
Expand Down
2 changes: 2 additions & 0 deletions packages/mentora-api/src/routes/api/[...path]/+server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { createServerHandler } from '$lib/server';
import { Firestore } from 'fires2rest';

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
});
Expand Down
27 changes: 15 additions & 12 deletions packages/mentora-api/tests/addTurn-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions packages/mentora-api/tests/conversation-service-asr.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
1 change: 1 addition & 0 deletions packages/mentora-api/tests/emulator-vitest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
Expand Down
Loading