From 384270f785eda0d34c24428a1e711ca4e228faa9 Mon Sep 17 00:00:00 2001 From: Prathik Shetty Date: Thu, 2 Jul 2026 23:57:07 +0530 Subject: [PATCH 1/7] feat(backend): add OpenRouter as a selectable AI provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an OpenRouter provider (AI_PROVIDER=openrouter) that routes chat completions through openrouter.ai using the OpenAI-compatible API, so one OPENROUTER_API_KEY unlocks many underlying models by slug (Anthropic, OpenAI, Google, Meta). It mirrors the existing provider contract — streaming chat and JSON-mode test-case generation — and is dispatched from services/ai.py. Because the interviewer, tutor, and voice mode all stream through the same ai.stream_chat path, selecting OpenRouter makes the entire app model-agnostic. - services/providers/openrouter_provider.py: new provider (base_url, key, optional HTTP-Referer / X-Title attribution headers, curated model list) - services/ai.py: dispatch AI_PROVIDER=openrouter - config.py: OPENROUTER_* settings and defaults - .env.example: document the provider and its keys Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/.env.example | 26 ++++- backend/config.py | 17 ++- backend/services/ai.py | 4 +- .../services/providers/openrouter_provider.py | 108 ++++++++++++++++++ 4 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 backend/services/providers/openrouter_provider.py diff --git a/backend/.env.example b/backend/.env.example index 1fd6d84..6ad415f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,9 +9,10 @@ DATABASE_URL=postgresql+psycopg://o1prep:o1prep@localhost:5433/o1prep # deployment, e.g.: python -c "import secrets; print(secrets.token_hex(32))" SECRET_KEY=dev-secret-change-me -# AI engine for text interviews and the tutor: -# claude -> uses your local Claude Code CLI and existing login (no API key) -# openai -> uses the OpenAI API (requires OPENAI_API_KEY below) +# AI engine for text interviews, the tutor, and voice mode: +# claude -> uses your local Claude Code CLI and existing login (no API key) +# openai -> uses the OpenAI API (requires OPENAI_API_KEY below) +# openrouter -> uses openrouter.ai; one key unlocks many models (see below) AI_PROVIDER=claude # --- Claude engine (AI_PROVIDER=claude) --- @@ -21,10 +22,27 @@ AI_PROVIDER=claude # CLAUDE_BIN=claude # CLAUDE_TIMEOUT=180 -# --- OpenAI (AI_PROVIDER=openai, and always required for Voice mode) --- +# --- OpenAI (AI_PROVIDER=openai) --- # Get a key at https://platform.openai.com/api-keys OPENAI_API_KEY=your-api-key-here +# --- OpenRouter (AI_PROVIDER=openrouter) --- +# One key for many models (Anthropic, OpenAI, Google, Meta, ...). +# Get a key at https://openrouter.ai/keys and browse slugs at +# https://openrouter.ai/models. Voice mode's STT -> LLM -> TTS pipeline uses +# this same provider, so voice runs through OpenRouter when it's selected. +OPENROUTER_API_KEY=your-openrouter-key-here +# Optional overrides: +# OPENROUTER_MODEL=anthropic/claude-3.5-sonnet +# OPENROUTER_SITE_URL=https://your-deployment.example.com +# OPENROUTER_SITE_NAME=O(1) Prep + +# --- Voice mode --- +# Speech-to-text and text-to-speech run in the browser (Web Speech API), so no +# extra keys are needed. The spoken interview is powered by AI_PROVIDER above. +# The legacy OpenAI Realtime path (routes/realtime.py) still requires +# OPENAI_API_KEY if you use it instead. + # --- Code execution (multi-language) --- # Python runs via the app's interpreter. JavaScript needs Node.js and TypeScript # needs a TS runner; override these only if they are not on PATH. diff --git a/backend/config.py b/backend/config.py index 276f4e8..990d209 100644 --- a/backend/config.py +++ b/backend/config.py @@ -22,9 +22,12 @@ # random value in any real deployment. SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me") -# AI provider for text interviews and the tutor: 'claude' drives the locally -# installed Claude Code CLI using your existing login (no API key); 'openai' -# uses the OpenAI API. Voice mode always uses OpenAI Realtime regardless. +# AI provider for text interviews, the tutor, and voice mode: +# 'claude' -> local Claude Code CLI using your existing login (no API key) +# 'openai' -> the OpenAI API (OPENAI_API_KEY) +# 'openrouter' -> openrouter.ai, one key for many models (OPENROUTER_API_KEY) +# Voice mode reuses the same text-streaming path, so it runs on whichever +# provider is selected here (the STT -> LLM -> TTS pipeline calls this LLM). AI_PROVIDER = os.environ.get("AI_PROVIDER", "claude").strip().lower() # Claude Code CLI provider @@ -38,6 +41,14 @@ REALTIME_MODEL = "gpt-4o-realtime-preview" TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe" +# OpenRouter (AI_PROVIDER=openrouter). One key, many models by slug; browse them +# at https://openrouter.ai/models. SITE_URL/SITE_NAME are optional attribution +# headers OpenRouter uses for its dashboard and model rankings. +OPENROUTER_API_URL = "https://openrouter.ai/api/v1" +OPENROUTER_MODEL = os.environ.get("OPENROUTER_MODEL", "anthropic/claude-3.5-sonnet").strip() +OPENROUTER_SITE_URL = os.environ.get("OPENROUTER_SITE_URL", "").strip() +OPENROUTER_SITE_NAME = os.environ.get("OPENROUTER_SITE_NAME", "O(1) Prep").strip() + # Chat parameters CHAT_TEMPERATURE = 0.7 CHAT_MAX_TOKENS = 4000 diff --git a/backend/services/ai.py b/backend/services/ai.py index 05ca5fe..51db07a 100644 --- a/backend/services/ai.py +++ b/backend/services/ai.py @@ -14,12 +14,14 @@ import json import config -from services.providers import claude_provider, openai_provider +from services.providers import claude_provider, openai_provider, openrouter_provider def _provider(): if config.AI_PROVIDER == "claude": return claude_provider + if config.AI_PROVIDER == "openrouter": + return openrouter_provider return openai_provider diff --git a/backend/services/providers/openrouter_provider.py b/backend/services/providers/openrouter_provider.py new file mode 100644 index 0000000..f69d6b9 --- /dev/null +++ b/backend/services/providers/openrouter_provider.py @@ -0,0 +1,108 @@ +"""OpenRouter provider — routes chat completions through openrouter.ai. + +OpenRouter exposes an OpenAI-compatible API, so this reuses the OpenAI SDK +pointed at OpenRouter's base URL. The single OPENROUTER_API_KEY unlocks many +underlying models (Anthropic, OpenAI, Google, Meta, ...) selectable by slug. + +Because both the text interviewer/tutor and voice mode stream through the same +`ai.stream_chat` path, selecting AI_PROVIDER=openrouter makes the entire app — +including the STT -> LLM -> TTS voice pipeline — OpenRouter-powered. +""" + +import json +import os + +import config + +# Curated models offered in the UI settings modal. OpenRouter has hundreds more +# (see https://openrouter.ai/models); override the default via OPENROUTER_MODEL. +# Reasoning-effort is model-specific on OpenRouter and not exposed here. +MODELS = [ + {"id": "anthropic/claude-3.5-sonnet", "label": "Claude 3.5 Sonnet"}, + {"id": "openai/gpt-4o", "label": "GPT-4o"}, + {"id": "openai/gpt-4o-mini", "label": "GPT-4o mini — faster"}, + {"id": "google/gemini-2.0-flash-001", "label": "Gemini 2.0 Flash"}, + {"id": "meta-llama/llama-3.3-70b-instruct", "label": "Llama 3.3 70B"}, +] +EFFORTS = [] +SUPPORTS_EFFORT = False + + +def default_model(): + return config.OPENROUTER_MODEL + + +def default_effort(): + return None + + +def valid_model(model): + # Accept any curated model, plus whatever the deployment configured as the + # default (so OPENROUTER_MODEL can point at a slug not in the short list). + return model == config.OPENROUTER_MODEL or any(m["id"] == model for m in MODELS) + + +def _extra_headers(): + # Optional attribution headers OpenRouter uses for its dashboard/rankings. + headers = {} + if config.OPENROUTER_SITE_URL: + headers["HTTP-Referer"] = config.OPENROUTER_SITE_URL + if config.OPENROUTER_SITE_NAME: + headers["X-Title"] = config.OPENROUTER_SITE_NAME + return headers + + +class OpenRouterClient: + def __init__(self, api_key, model=None): + from openai import OpenAI # lazy: only needed when this provider is used + + self._client = OpenAI( + api_key=api_key, + base_url=config.OPENROUTER_API_URL, + default_headers=_extra_headers() or None, + ) + self.model = model or config.OPENROUTER_MODEL + + def stream_chat(self, messages, temperature=None, max_tokens=None): + stream = self._client.chat.completions.create( + model=self.model, + messages=messages, + stream=True, + temperature=temperature if temperature is not None else config.CHAT_TEMPERATURE, + max_tokens=max_tokens if max_tokens is not None else config.CHAT_MAX_TOKENS, + ) + for chunk in stream: + if not chunk.choices: + continue + delta = chunk.choices[0].delta + if delta and delta.content: + yield delta.content + + def generate_test_cases(self, messages): + try: + response = self._client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": config.TEST_GEN_PROMPT}, + *[m for m in messages if m["role"] != "system"], + ], + response_format={"type": "json_object"}, + temperature=config.TEST_GEN_TEMPERATURE, + max_tokens=config.TEST_GEN_MAX_TOKENS, + ) + result = json.loads(response.choices[0].message.content) + fn = result.get("function_name") + cases = result.get("test_cases", []) + if not fn or not cases: + return None, [] + return fn, cases + except Exception: + return None, [] + + +def get_client(model=None, effort=None): + api_key = os.environ.get("OPENROUTER_API_KEY", "") + if not api_key: + return None + m = model if valid_model(model) else config.OPENROUTER_MODEL + return OpenRouterClient(api_key, m) From 25afe3c57c1e3fafb159eb12b6e7f57cfbb69d7f Mon Sep 17 00:00:00 2001 From: Prathik Shetty Date: Thu, 2 Jul 2026 23:57:26 +0530 Subject: [PATCH 2/7] feat(frontend): add turn-based voice mode (browser STT, LLM, TTS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a voice interview mode that wraps the existing text pipeline in a speech loop: mic -> STT -> useInterview.send() -> streamed LLM reply -> TTS. The LLM turn is not re-implemented — send()/start() run the same streamSSE path, and a registered voice sink speaks assistant tokens as they arrive, so voice runs on whichever AI_PROVIDER is configured (OpenAI, Claude, OpenRouter). STT and TTS use the browser Web Speech API (Chrome/Edge, no extra keys) behind a thin seam so a premium provider (Deepgram/ElevenLabs) can drop in later. - lib/voice/stt.ts, tts.ts: Web Speech adapters; TTS is sentence-buffered for low latency and strips markdown so it doesn't read backticks aloud - lib/voice/sink.ts: one-slot registry decoupling the interview store from voice - store/voice.ts: listening -> thinking -> speaking state machine; mic pauses while the interviewer speaks to avoid echo - store/interview.ts: feed streamed assistant deltas to the sink (no-op in text) - components/interview/VoiceControls.tsx: status strip, interim transcript, mute - components/interview/InterviewTopBar.tsx: voice toggle; ChatPanel.tsx: mount Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/interview/ChatPanel.tsx | 3 + .../components/interview/InterviewTopBar.tsx | 10 ++ .../components/interview/VoiceControls.tsx | 93 ++++++++++++ frontend/src/lib/voice/sink.ts | 24 +++ frontend/src/lib/voice/stt.ts | 118 +++++++++++++++ frontend/src/lib/voice/tts.ts | 130 +++++++++++++++++ frontend/src/store/interview.ts | 23 ++- frontend/src/store/voice.ts | 138 ++++++++++++++++++ 8 files changed, 536 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/interview/VoiceControls.tsx create mode 100644 frontend/src/lib/voice/sink.ts create mode 100644 frontend/src/lib/voice/stt.ts create mode 100644 frontend/src/lib/voice/tts.ts create mode 100644 frontend/src/store/voice.ts diff --git a/frontend/src/components/interview/ChatPanel.tsx b/frontend/src/components/interview/ChatPanel.tsx index 5aaf075..9bdedc3 100644 --- a/frontend/src/components/interview/ChatPanel.tsx +++ b/frontend/src/components/interview/ChatPanel.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { useInterview, type ChatItem } from "@/store/interview"; import { Markdown } from "./Markdown"; import { TestResults } from "./TestResults"; +import { VoiceControls } from "./VoiceControls"; function MessageBlock({ item }: { item: ChatItem }) { if (item.role === "tests") { @@ -72,6 +73,8 @@ export function ChatPanel() { )} + +