Add openrouter

This commit is contained in:
Joey Yakimowich-Payne 2026-01-15 12:28:51 -07:00
commit 36b686bbd4
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
8 changed files with 380 additions and 61 deletions

View file

@ -2,12 +2,13 @@ import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { X, Key, Eye, EyeOff, Loader2 } from 'lucide-react'; import { X, Key, Eye, EyeOff, Loader2 } from 'lucide-react';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import type { AIProvider, UserPreferences } from '../types';
interface ApiKeyModalProps { interface ApiKeyModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
apiKey: string | undefined; preferences: UserPreferences;
onSave: (key: string | undefined) => Promise<void>; onSave: (prefs: Partial<UserPreferences>) => Promise<void>;
saving: boolean; saving: boolean;
hasAIAccess: boolean; hasAIAccess: boolean;
} }
@ -15,25 +16,37 @@ interface ApiKeyModalProps {
export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
isOpen, isOpen,
onClose, onClose,
apiKey, preferences,
onSave, onSave,
saving, saving,
hasAIAccess, hasAIAccess,
}) => { }) => {
useBodyScrollLock(isOpen); useBodyScrollLock(isOpen);
const [localApiKey, setLocalApiKey] = useState(apiKey || ''); const [localProvider, setLocalProvider] = useState<AIProvider>(preferences.aiProvider || 'gemini');
const [showApiKey, setShowApiKey] = useState(false); const [localGeminiKey, setLocalGeminiKey] = useState(preferences.geminiApiKey || '');
const [localOpenRouterKey, setLocalOpenRouterKey] = useState(preferences.openRouterApiKey || '');
const [localOpenRouterModel, setLocalOpenRouterModel] = useState(preferences.openRouterModel || '');
const [showGeminiKey, setShowGeminiKey] = useState(false);
const [showOpenRouterKey, setShowOpenRouterKey] = useState(false);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setLocalApiKey(apiKey || ''); setLocalProvider(preferences.aiProvider || 'gemini');
setLocalGeminiKey(preferences.geminiApiKey || '');
setLocalOpenRouterKey(preferences.openRouterApiKey || '');
setLocalOpenRouterModel(preferences.openRouterModel || '');
} }
}, [isOpen, apiKey]); }, [isOpen, preferences]);
if (!isOpen) return null; if (!isOpen) return null;
const handleSave = async () => { const handleSave = async () => {
await onSave(localApiKey || undefined); await onSave({
aiProvider: localProvider,
geminiApiKey: localGeminiKey || undefined,
openRouterApiKey: localOpenRouterKey || undefined,
openRouterModel: localOpenRouterModel || undefined,
});
onClose(); onClose();
}; };
@ -58,8 +71,8 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
<Key size={24} /> <Key size={24} />
</div> </div>
<div> <div>
<h2 className="text-xl font-black">Account Settings</h2> <h2 className="text-xl font-black">AI Settings</h2>
<p className="text-sm opacity-80">Manage your API access</p> <p className="text-sm opacity-80">Configure your AI provider</p>
</div> </div>
</div> </div>
<button <button
@ -70,33 +83,114 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
</button> </button>
</div> </div>
<div className="p-6 bg-gray-50"> <div className="p-6 bg-gray-50 space-y-4">
{hasAIAccess ? ( {hasAIAccess ? (
<div> <>
<h3 className="font-bold text-gray-800 mb-3 flex items-center gap-2"> <div>
<Key size={18} /> <label className="block font-bold text-gray-800 mb-2">AI Provider</label>
Custom Gemini API Key <div className="flex bg-gray-200 p-1 rounded-xl">
</h3> <button
<p className="text-sm text-gray-500 mb-3"> type="button"
Use your own API key for quiz generation. Leave empty to use the system key. onClick={() => setLocalProvider('gemini')}
</p> className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all ${
<div className="relative"> localProvider === 'gemini'
<input ? 'bg-white shadow-sm text-gray-800'
type={showApiKey ? 'text' : 'password'} : 'text-gray-500 hover:text-gray-700'
value={localApiKey} }`}
onChange={(e) => setLocalApiKey(e.target.value)} >
placeholder="AIza..." Gemini
className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800" </button>
/> <button
<button type="button"
type="button" onClick={() => setLocalProvider('openrouter')}
onClick={() => setShowApiKey(!showApiKey)} className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all ${
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" localProvider === 'openrouter'
> ? 'bg-white shadow-sm text-gray-800'
{showApiKey ? <EyeOff size={20} /> : <Eye size={20} />} : 'text-gray-500 hover:text-gray-700'
</button> }`}
>
OpenRouter
</button>
</div>
</div> </div>
</div>
{localProvider === 'gemini' ? (
<div>
<label className="block font-bold text-gray-800 mb-2">
Gemini API Key
</label>
<p className="text-sm text-gray-500 mb-2">
Leave empty to use the system key.
</p>
<div className="relative">
<input
type={showGeminiKey ? 'text' : 'password'}
value={localGeminiKey}
onChange={(e) => setLocalGeminiKey(e.target.value)}
placeholder="AIza..."
className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
/>
<button
type="button"
onClick={() => setShowGeminiKey(!showGeminiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showGeminiKey ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
) : (
<>
<div>
<label className="block font-bold text-gray-800 mb-2">
OpenRouter API Key
</label>
<p className="text-sm text-gray-500 mb-2">
Get your key from{' '}
<a
href="https://openrouter.ai/keys"
target="_blank"
rel="noopener noreferrer"
className="text-theme-primary hover:underline"
>
openrouter.ai/keys
</a>
</p>
<div className="relative">
<input
type={showOpenRouterKey ? 'text' : 'password'}
value={localOpenRouterKey}
onChange={(e) => setLocalOpenRouterKey(e.target.value)}
placeholder="sk-or-..."
className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
/>
<button
type="button"
onClick={() => setShowOpenRouterKey(!showOpenRouterKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showOpenRouterKey ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
<div>
<label className="block font-bold text-gray-800 mb-2">
Model
</label>
<p className="text-sm text-gray-500 mb-2">
Default: google/gemini-3-flash-preview
</p>
<input
type="text"
value={localOpenRouterModel}
onChange={(e) => setLocalOpenRouterModel(e.target.value)}
placeholder="google/gemini-3-flash-preview"
className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
/>
</div>
</>
)}
</>
) : ( ) : (
<div className="text-center py-4"> <div className="text-center py-4">
<Key size={32} className="mx-auto mb-3 text-gray-400" /> <Key size={32} className="mx-auto mb-3 text-gray-400" />

View file

@ -15,8 +15,18 @@ import type { Quiz, GameConfig } from '../types';
type GenerateMode = 'topic' | 'document'; type GenerateMode = 'topic' | 'document';
import type { AIProvider } from '../types';
interface LandingProps { interface LandingProps {
onGenerate: (options: { topic?: string; questionCount?: number; files?: File[]; useOcr?: boolean }) => void; onGenerate: (options: {
topic?: string;
questionCount?: number;
files?: File[];
useOcr?: boolean;
aiProvider?: AIProvider;
apiKey?: string;
openRouterModel?: string;
}) => void;
onCreateManual: () => void; onCreateManual: () => void;
onLoadQuiz: (quiz: Quiz, quizId?: string) => void; onLoadQuiz: (quiz: Quiz, quizId?: string) => void;
onJoin: (pin: string, name: string) => void; onJoin: (pin: string, name: string) => void;
@ -103,7 +113,10 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig(); const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig();
const { preferences, hasAIAccess, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences(); const { preferences, hasAIAccess, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences();
const canUseAI = auth.isAuthenticated && (hasAIAccess || preferences.geminiApiKey); const hasValidApiKey = preferences.aiProvider === 'openrouter'
? !!preferences.openRouterApiKey
: (hasAIAccess || !!preferences.geminiApiKey);
const canUseAI = auth.isAuthenticated && hasValidApiKey;
const { const {
quizzes, quizzes,
@ -205,11 +218,19 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const handleHostSubmit = (e: React.FormEvent) => { const handleHostSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (canGenerate && !isLoading) { if (canGenerate && !isLoading) {
const aiProvider = preferences.aiProvider || 'gemini';
const apiKey = aiProvider === 'openrouter'
? preferences.openRouterApiKey
: preferences.geminiApiKey;
onGenerate({ onGenerate({
topic: generateMode === 'topic' ? topic.trim() : undefined, topic: generateMode === 'topic' ? topic.trim() : undefined,
questionCount, questionCount,
files: generateMode === 'document' ? selectedFiles : undefined, files: generateMode === 'document' ? selectedFiles : undefined,
useOcr: generateMode === 'document' && showOcrOption ? useOcr : undefined useOcr: generateMode === 'document' && showOcrOption ? useOcr : undefined,
aiProvider,
apiKey,
openRouterModel: aiProvider === 'openrouter' ? preferences.openRouterModel : undefined,
}); });
} }
}; };
@ -597,9 +618,9 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
<ApiKeyModal <ApiKeyModal
isOpen={accountSettingsOpen} isOpen={accountSettingsOpen}
onClose={() => setAccountSettingsOpen(false)} onClose={() => setAccountSettingsOpen(false)}
apiKey={preferences.geminiApiKey} preferences={preferences}
onSave={async (key) => { onSave={async (prefs) => {
await savePreferences({ ...preferences, geminiApiKey: key }); await savePreferences({ ...preferences, ...prefs });
}} }}
saving={savingPrefs} saving={savingPrefs}
hasAIAccess={hasAIAccess} hasAIAccess={hasAIAccess}

View file

@ -600,7 +600,15 @@ export const useGame = () => {
return response.json(); return response.json();
}; };
const startQuizGen = async (options: { topic?: string; questionCount?: number; files?: File[]; useOcr?: boolean }) => { const startQuizGen = async (options: {
topic?: string;
questionCount?: number;
files?: File[];
useOcr?: boolean;
aiProvider?: 'gemini' | 'openrouter';
apiKey?: string;
openRouterModel?: string;
}) => {
try { try {
setGameState('GENERATING'); setGameState('GENERATING');
setError(null); setError(null);
@ -616,7 +624,10 @@ export const useGame = () => {
const generateOptions: GenerateQuizOptions = { const generateOptions: GenerateQuizOptions = {
topic: options.topic, topic: options.topic,
questionCount: options.questionCount, questionCount: options.questionCount,
documents documents,
aiProvider: options.aiProvider,
apiKey: options.apiKey,
openRouterModel: options.openRouterModel,
}; };
const generatedQuiz = await generateQuiz(generateOptions); const generatedQuiz = await generateQuiz(generateOptions);

View file

@ -6,6 +6,7 @@ import { COLOR_SCHEMES } from '../types';
const DEFAULT_PREFERENCES: UserPreferences = { const DEFAULT_PREFERENCES: UserPreferences = {
colorScheme: 'blue', colorScheme: 'blue',
aiProvider: 'gemini',
}; };
export const applyColorScheme = (schemeId: string) => { export const applyColorScheme = (schemeId: string) => {
@ -42,7 +43,10 @@ export const useUserPreferences = (): UseUserPreferencesReturn => {
const data = await response.json(); const data = await response.json();
const prefs: UserPreferences = { const prefs: UserPreferences = {
colorScheme: data.colorScheme || 'blue', colorScheme: data.colorScheme || 'blue',
aiProvider: data.aiProvider || 'gemini',
geminiApiKey: data.geminiApiKey || undefined, geminiApiKey: data.geminiApiKey || undefined,
openRouterApiKey: data.openRouterApiKey || undefined,
openRouterModel: data.openRouterModel || undefined,
}; };
setPreferences(prefs); setPreferences(prefs);
setHasAIAccess(data.hasAIAccess || false); setHasAIAccess(data.hasAIAccess || false);

View file

@ -72,6 +72,24 @@ const runMigrations = () => {
db.exec("ALTER TABLE users ADD COLUMN gemini_api_key TEXT"); db.exec("ALTER TABLE users ADD COLUMN gemini_api_key TEXT");
console.log("Migration: Added gemini_api_key to users"); console.log("Migration: Added gemini_api_key to users");
} }
const hasAiProvider = userTableInfo2.some(col => col.name === "ai_provider");
if (!hasAiProvider) {
db.exec("ALTER TABLE users ADD COLUMN ai_provider TEXT DEFAULT 'gemini'");
console.log("Migration: Added ai_provider to users");
}
const hasOpenRouterKey = userTableInfo2.some(col => col.name === "openrouter_api_key");
if (!hasOpenRouterKey) {
db.exec("ALTER TABLE users ADD COLUMN openrouter_api_key TEXT");
console.log("Migration: Added openrouter_api_key to users");
}
const hasOpenRouterModel = userTableInfo2.some(col => col.name === "openrouter_model");
if (!hasOpenRouterModel) {
db.exec("ALTER TABLE users ADD COLUMN openrouter_model TEXT");
console.log("Migration: Added openrouter_model to users");
}
}; };
runMigrations(); runMigrations();

View file

@ -105,35 +105,50 @@ router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
const userSub = req.user!.sub; const userSub = req.user!.sub;
const user = db.prepare(` const user = db.prepare(`
SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey,
ai_provider as aiProvider, openrouter_api_key as openRouterApiKey,
openrouter_model as openRouterModel
FROM users FROM users
WHERE id = ? WHERE id = ?
`).get(userSub) as { colorScheme: string | null; geminiApiKey: string | null } | undefined; `).get(userSub) as {
colorScheme: string | null;
geminiApiKey: string | null;
aiProvider: string | null;
openRouterApiKey: string | null;
openRouterModel: string | null;
} | undefined;
const groups = req.user!.groups || []; const groups = req.user!.groups || [];
const hasAIAccess = groups.includes('kaboot-ai-access'); const hasAIAccess = groups.includes('kaboot-ai-access');
res.json({ res.json({
colorScheme: user?.colorScheme || 'blue', colorScheme: user?.colorScheme || 'blue',
aiProvider: user?.aiProvider || 'gemini',
geminiApiKey: decryptForUser(user?.geminiApiKey || null, userSub), geminiApiKey: decryptForUser(user?.geminiApiKey || null, userSub),
openRouterApiKey: decryptForUser(user?.openRouterApiKey || null, userSub),
openRouterModel: user?.openRouterModel || null,
hasAIAccess, hasAIAccess,
}); });
}); });
router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => { router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
const userSub = req.user!.sub; const userSub = req.user!.sub;
const { colorScheme, geminiApiKey } = req.body; const { colorScheme, geminiApiKey, aiProvider, openRouterApiKey, openRouterModel } = req.body;
const encryptedApiKey = encryptForUser(geminiApiKey || null, userSub); const encryptedGeminiKey = encryptForUser(geminiApiKey || null, userSub);
const encryptedOpenRouterKey = encryptForUser(openRouterApiKey || null, userSub);
const encryptedEmail = encryptForUser(req.user!.email || null, userSub); const encryptedEmail = encryptForUser(req.user!.email || null, userSub);
const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub); const encryptedDisplayName = encryptForUser(req.user!.name || null, userSub);
const upsertUser = db.prepare(` const upsertUser = db.prepare(`
INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, last_login) INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, ai_provider, openrouter_api_key, openrouter_model, last_login)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
color_scheme = ?, color_scheme = ?,
gemini_api_key = ?, gemini_api_key = ?,
ai_provider = ?,
openrouter_api_key = ?,
openrouter_model = ?,
last_login = CURRENT_TIMESTAMP last_login = CURRENT_TIMESTAMP
`); `);
@ -143,9 +158,15 @@ router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
encryptedEmail, encryptedEmail,
encryptedDisplayName, encryptedDisplayName,
colorScheme || 'blue', colorScheme || 'blue',
encryptedApiKey, encryptedGeminiKey,
aiProvider || 'gemini',
encryptedOpenRouterKey,
openRouterModel || null,
colorScheme || 'blue', colorScheme || 'blue',
encryptedApiKey encryptedGeminiKey,
aiProvider || 'gemini',
encryptedOpenRouterKey,
openRouterModel || null
); );
res.json({ success: true }); res.json({ success: true });

View file

@ -1,15 +1,18 @@
import { GoogleGenAI, Type, createUserContent, createPartFromUri } from "@google/genai"; import { GoogleGenAI, Type, createUserContent, createPartFromUri } from "@google/genai";
import { Quiz, Question, AnswerOption, GenerateQuizOptions, ProcessedDocument } from "../types"; import { Quiz, Question, AnswerOption, GenerateQuizOptions, ProcessedDocument, AIProvider } from "../types";
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
const getClient = () => { const getGeminiClient = (apiKey?: string) => {
const apiKey = process.env.API_KEY; const key = apiKey || process.env.API_KEY;
if (!apiKey) { if (!key) {
throw new Error("API_KEY environment variable is missing"); throw new Error("Gemini API key is missing");
} }
return new GoogleGenAI({ apiKey }); return new GoogleGenAI({ apiKey: key });
}; };
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3-flash-preview';
const QUIZ_SCHEMA = { const QUIZ_SCHEMA = {
type: Type.OBJECT, type: Type.OBJECT,
properties: { properties: {
@ -40,13 +43,35 @@ const QUIZ_SCHEMA = {
required: ["title", "questions"] required: ["title", "questions"]
}; };
function buildPrompt(options: GenerateQuizOptions, hasDocuments: boolean): string { function buildPrompt(options: GenerateQuizOptions, hasDocuments: boolean, includeJsonExample: boolean = false): string {
const questionCount = options.questionCount || 10; const questionCount = options.questionCount || 10;
const baseInstructions = `Create ${questionCount} engaging multiple-choice questions. Each question must have exactly 4 options, and exactly one correct answer. Vary the difficulty. let baseInstructions = `Create ${questionCount} engaging multiple-choice questions. Each question must have exactly 4 options, and exactly one correct answer. Vary the difficulty.
IMPORTANT: For each option's reason, write as if you are directly explaining facts - never reference "the document", "the text", "the material", or "the source". Write explanations as standalone factual statements.`; IMPORTANT: For each option's reason, write as if you are directly explaining facts - never reference "the document", "the text", "the material", or "the source". Write explanations as standalone factual statements.`;
if (includeJsonExample) {
baseInstructions += `
You MUST respond with a single JSON object in this exact structure:
{
"title": "Quiz Title Here",
"questions": [
{
"text": "Question text here?",
"options": [
{ "text": "Option A", "isCorrect": false, "reason": "Explanation why this is wrong" },
{ "text": "Option B", "isCorrect": true, "reason": "Explanation why this is correct" },
{ "text": "Option C", "isCorrect": false, "reason": "Explanation why this is wrong" },
{ "text": "Option D", "isCorrect": false, "reason": "Explanation why this is wrong" }
]
}
]
}
Return ONLY valid JSON with no additional text before or after.`;
}
if (hasDocuments) { if (hasDocuments) {
const topicContext = options.topic const topicContext = options.topic
? ` Focus on aspects related to "${options.topic}".` ? ` Focus on aspects related to "${options.topic}".`
@ -118,8 +143,38 @@ async function uploadNativeDocument(ai: GoogleGenAI, doc: ProcessedDocument): Pr
return { uri: uploadedFile.uri, mimeType: uploadedFile.mimeType }; return { uri: uploadedFile.uri, mimeType: uploadedFile.mimeType };
} }
export const generateQuiz = async (options: GenerateQuizOptions): Promise<Quiz> => { const JSON_SCHEMA_FOR_OPENROUTER = {
const ai = getClient(); type: "object",
properties: {
title: { type: "string", description: "A catchy title for the quiz" },
questions: {
type: "array",
items: {
type: "object",
properties: {
text: { type: "string", description: "The question text" },
options: {
type: "array",
items: {
type: "object",
properties: {
text: { type: "string" },
isCorrect: { type: "boolean" },
reason: { type: "string", description: "Brief explanation of why this answer is correct or incorrect" }
},
required: ["text", "isCorrect", "reason"]
},
}
},
required: ["text", "options"]
}
}
},
required: ["title", "questions"]
};
async function generateQuizWithGemini(options: GenerateQuizOptions): Promise<Quiz> {
const ai = getGeminiClient(options.apiKey);
const docs = options.documents || []; const docs = options.documents || [];
const hasDocuments = docs.length > 0; const hasDocuments = docs.length > 0;
@ -160,4 +215,91 @@ export const generateQuiz = async (options: GenerateQuizOptions): Promise<Quiz>
const data = JSON.parse(response.text); const data = JSON.parse(response.text);
return transformToQuiz(data); return transformToQuiz(data);
}
async function generateQuizWithOpenRouter(options: GenerateQuizOptions): Promise<Quiz> {
const apiKey = options.apiKey;
if (!apiKey) {
throw new Error("OpenRouter API key is missing");
}
const docs = options.documents || [];
const hasDocuments = docs.length > 0;
const prompt = buildPrompt(options, hasDocuments, true);
let fullPrompt = prompt;
// For OpenRouter, we can't upload files directly - include text content in the prompt
if (hasDocuments) {
const textParts: string[] = [];
for (const doc of docs) {
if (doc.type === 'text') {
textParts.push(doc.content as string);
} else if (doc.type === 'native') {
// For native documents, they should have been converted to text on the backend
// If not, skip them with a warning
console.warn('Native document type not supported with OpenRouter - document will be skipped');
}
}
if (textParts.length > 0) {
fullPrompt = `Here is the content to create a quiz from:\n\n${textParts.join('\n\n---\n\n')}\n\n${prompt}`;
}
}
const model = options.openRouterModel || DEFAULT_OPENROUTER_MODEL;
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'Kaboot Quiz Generator',
},
body: JSON.stringify({
model,
messages: [
{
role: 'user',
content: fullPrompt
}
],
response_format: {
type: 'json_schema',
json_schema: {
name: 'quiz',
strict: true,
schema: JSON_SCHEMA_FOR_OPENROUTER
}
}
})
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } }));
throw new Error(error.error?.message || `OpenRouter API error: ${response.status}`);
}
const result = await response.json();
const content = result.choices?.[0]?.message?.content;
// console.log('[OpenRouter] Raw response:', content);
if (!content) {
throw new Error("Failed to generate quiz content from OpenRouter");
}
const data = JSON.parse(content);
return transformToQuiz(data);
}
export const generateQuiz = async (options: GenerateQuizOptions): Promise<Quiz> => {
const provider = options.aiProvider || 'gemini';
if (provider === 'openrouter') {
return generateQuizWithOpenRouter(options);
}
return generateQuizWithGemini(options);
}; };

View file

@ -36,9 +36,14 @@ export const COLOR_SCHEMES: ColorScheme[] = [
{ id: 'rose', name: 'Rose', primary: '#e11d48', primaryDark: '#be123c', primaryDarker: '#5f1a2a' }, { id: 'rose', name: 'Rose', primary: '#e11d48', primaryDark: '#be123c', primaryDarker: '#5f1a2a' },
]; ];
export type AIProvider = 'gemini' | 'openrouter';
export interface UserPreferences { export interface UserPreferences {
colorScheme: string; colorScheme: string;
aiProvider?: AIProvider;
geminiApiKey?: string; geminiApiKey?: string;
openRouterApiKey?: string;
openRouterModel?: string;
} }
export type GameRole = 'HOST' | 'CLIENT'; export type GameRole = 'HOST' | 'CLIENT';
@ -127,6 +132,9 @@ export interface GenerateQuizOptions {
topic?: string; topic?: string;
questionCount?: number; questionCount?: number;
documents?: ProcessedDocument[]; documents?: ProcessedDocument[];
aiProvider?: AIProvider;
apiKey?: string;
openRouterModel?: string;
} }
export interface PointsBreakdown { export interface PointsBreakdown {