/** * OpenRouter AI Service Provider * * Provides unified integration with OpenRouter API for streaming roleplay dialogue, * real-time clinical supervision, scenario generation, and vocal speech synthesis/transcription. */ import { Message, Scenario, AnalysisResult } from '../types'; import { getOpenRouterApiKey, DEFAULT_OPENROUTER_MODEL, DEFAULT_SUPERVISOR_MODEL } from './apiConfig'; import { SYSTEM_ANALYSIS_PROMPT, SYSTEM_SUMMARY_ANALYSIS_PROMPT } from '../constants'; const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; export interface OpenRouterOptions { model?: string; temperature?: number; maxTokens?: number; apiKey?: string; } export interface StreamChatCallbacks { onChunk: (chunkText: string) => void; onComplete?: (fullText: string) => void; onError?: (error: any) => void; } /** * Streams roleplay chat completion from OpenRouter using Server-Sent Events (SSE). */ export async function streamOpenRouterChat( messages: Message[], systemInstruction: string, callbacks: StreamChatCallbacks, options: OpenRouterOptions = {} ): Promise { const apiKey = getOpenRouterApiKey(options.apiKey); if (!apiKey) { const error = new Error("OpenRouter API key is missing. Please set your OpenRouter API Key in Settings."); callbacks.onError?.(error); throw error; } const model = options.model || DEFAULT_OPENROUTER_MODEL; const formattedMessages = [ { role: 'system', content: systemInstruction }, ...messages.map(m => ({ role: m.role === 'model' ? 'assistant' : m.role, content: m.text })) ]; try { const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://cognitive-clarity.app', 'X-Title': 'Cognitive Clarity' }, body: JSON.stringify({ model, messages: formattedMessages, temperature: options.temperature ?? 0.7, max_tokens: options.maxTokens ?? 1200, stream: true }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`OpenRouter API error (${response.status}): ${errorText}`); } const reader = response.body?.getReader(); if (!reader) throw new Error("Unable to read response stream."); const decoder = new TextDecoder('utf-8'); let fullText = ''; let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith(':')) continue; if (trimmed === 'data: [DONE]') continue; if (trimmed.startsWith('data: ')) { try { const data = JSON.parse(trimmed.substring(6)); const delta = data.choices?.[0]?.delta?.content || ''; if (delta) { fullText += delta; callbacks.onChunk(delta); } } catch (e) { // Partial JSON buffer ignored } } } } callbacks.onComplete?.(fullText); return fullText; } catch (err) { callbacks.onError?.(err); throw err; } } /** * Executes a single-turn structured CBT turn analysis via OpenRouter JSON mode. */ export async function analyzeTurnWithOpenRouter( turnContext: string, scenarioTitle: string, scenarioDescription: string, cbtFocus: string[], options: OpenRouterOptions = {} ): Promise { const apiKey = getOpenRouterApiKey(options.apiKey); if (!apiKey) { throw new Error("OpenRouter API key is missing. Please set your API key in Settings."); } const model = options.model || DEFAULT_SUPERVISOR_MODEL; const systemPrompt = `${SYSTEM_ANALYSIS_PROMPT} SCENARIO CONTEXT: Title: ${scenarioTitle} Description: ${scenarioDescription} CBT Focus Areas: ${cbtFocus.join(', ')} [OUTPUT REQUIREMENTS] Return ONLY a valid JSON object matching this schema: { "analysis": { "distortions": ["string"], "isAdaptive": boolean, "evidence": "string" }, "rating": { "score": number (1-5), "reason": "string" }, "suggestions": { "alternativeThought": "string", "behavioralStep": "string" } }`; const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://cognitive-clarity.app', 'X-Title': 'Cognitive Clarity' }, body: JSON.stringify({ model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: turnContext } ], response_format: { type: 'json_object' }, temperature: 0.2 }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`OpenRouter Clinical Analysis failed (${response.status}): ${errorText}`); } const data = await response.json(); const rawContent = data.choices?.[0]?.message?.content || '{}'; return JSON.parse(rawContent) as AnalysisResult; } /** * Generates a comprehensive post-session summary report via OpenRouter JSON mode. */ export async function generateSessionSummaryReport( messages: Message[], scenarioTitle: string, scenarioDescription: string, options: OpenRouterOptions = {} ): Promise { const apiKey = getOpenRouterApiKey(options.apiKey); if (!apiKey) { throw new Error("OpenRouter API key is missing."); } const model = options.model || DEFAULT_SUPERVISOR_MODEL; const transcriptText = messages.map(m => `[${m.role.toUpperCase()}]: ${m.text}`).join('\n'); const systemPrompt = `${SYSTEM_SUMMARY_ANALYSIS_PROMPT} SCENARIO CONTEXT: ${scenarioTitle} - ${scenarioDescription} [OUTPUT REQUIREMENTS] Return ONLY a valid JSON object conforming exactly to this schema: { "analysis": { "distortions": ["string"], "isAdaptive": boolean, "evidence": "string" }, "rating": { "score": number (1-5), "reason": "string" }, "suggestions": { "alternativeThought": "string", "behavioralStep": "string" } }`; const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://cognitive-clarity.app', 'X-Title': 'Cognitive Clarity' }, body: JSON.stringify({ model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: `SESSION TRANSCRIPT:\n${transcriptText}` } ], response_format: { type: 'json_object' }, temperature: 0.2 }) }); if (!response.ok) { const errText = await response.text(); throw new Error(`OpenRouter summary analysis failed (${response.status}): ${errText}`); } const data = await response.json(); const rawContent = data.choices?.[0]?.message?.content || '{}'; return JSON.parse(rawContent) as AnalysisResult; } /** * Generates a new validated CBT roleplay scenario via OpenRouter JSON mode. */ export async function generateScenarioWithOpenRouter( prompt: string, selectedConcepts: string[], difficulty: Scenario['difficulty'], options: OpenRouterOptions = {} ): Promise { const apiKey = getOpenRouterApiKey(options.apiKey); if (!apiKey) { throw new Error("OpenRouter API key is missing. Please enter your key in Settings."); } const model = options.model || DEFAULT_OPENROUTER_MODEL; const selectedContext = selectedConcepts.length > 0 ? `Focus specifically on these CBT concepts: ${selectedConcepts.join(', ')}.` : ''; const systemInstruction = ` [CONTEXT] You are a Clinical CBT Scenario Architect. The user requires a structured clinical simulation designed to address specific cognitive distortions and behavioral challenges. Clinical Focus: ${selectedContext} [TASK] Transform the user's prompt into a cohesive, structured clinical scenario that sets up a targeted roleplay environment. [CONSTRAINTS] - CATEGORIZATION: You MUST categorize the scenario into one of: 'Social Anxiety', 'Workplace', 'Relationships', 'Self-Talk'. - DIFFICULTY CALIBRATION: The requested difficulty is '${difficulty}'. - 'Beginner': Low stakes, high support, clear-cut distortions, AI character is cooperative. - 'Intermediate': Ambiguous stakes, moderate social friction, subtle distortions, AI character has specific needs. - 'Advanced': High emotional triggers, persistent clinical resistance, complex cognitive patterns. - INTERNAL LOGIC: Provide a detailed "INTERNAL CBT LOGIC" section instructing the AI character how to react to the user's progress or cognitive lapses during the future roleplay. - CHARACTER VOICE: Define a realistic, emotionally consistent voice profile. [OUTPUT REQUIREMENTS] Return ONLY a valid JSON object matching this schema: { "id": "string", "title": "string", "characterName": "string", "description": "string", "category": "Social Anxiety" | "Workplace" | "Relationships" | "Self-Talk", "difficulty": "Beginner" | "Intermediate" | "Advanced", "systemInstruction": "string", "initialMessage": "string", "cbtFocus": ["string"] } `; const response = await fetch(`${OPENROUTER_BASE_URL}/chat/completions`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://cognitive-clarity.app', 'X-Title': 'Cognitive Clarity' }, body: JSON.stringify({ model, messages: [ { role: 'system', content: systemInstruction }, { role: 'user', content: `Generate a clinical scenario for: ${prompt || 'A scenario exploring the chosen CBT concepts.'}` } ], response_format: { type: 'json_object' }, temperature: 0.7 }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`OpenRouter scenario generation failed (${response.status}): ${errorText}`); } const data = await response.json(); const rawContent = data.choices?.[0]?.message?.content || '{}'; return JSON.parse(rawContent) as Scenario; } /** * Synthesizes spoken audio from text using OpenRouter's speech endpoint. */ export async function synthesizeSpeechWithOpenRouter( text: string, voice = 'alloy', apiKey?: string ): Promise { const effectiveKey = getOpenRouterApiKey(apiKey); if (!effectiveKey) { throw new Error("OpenRouter API key is missing."); } const response = await fetch(`${OPENROUTER_BASE_URL}/audio/speech`, { method: 'POST', headers: { 'Authorization': `Bearer ${effectiveKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://cognitive-clarity.app', 'X-Title': 'Cognitive Clarity' }, body: JSON.stringify({ model: 'openai/tts-1', input: text, voice: voice }) }); if (!response.ok) { throw new Error(`OpenRouter TTS synthesis failed with status ${response.status}`); } return await response.arrayBuffer(); }