diff --git a/README.md b/README.md index f8d2e96..1ea718d 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue?logo=python&logoColor=white)](https://www.python.org/downloads/) +[![Next.js](https://img.shields.io/badge/next.js-16-000000?logo=nextdotjs&logoColor=white)](https://nextjs.org/) [![PostgreSQL](https://img.shields.io/badge/postgres-16-336791?logo=postgresql&logoColor=white)](https://www.postgresql.org/) -[![Powered by GPT-4o](https://img.shields.io/badge/powered%20by-GPT--4o-412991?logo=openai&logoColor=white)](https://platform.openai.com/) +[![AI: Claude / OpenAI / OpenRouter](https://img.shields.io/badge/AI-Claude%20%2F%20OpenAI%20%2F%20OpenRouter-ffb000)]() [![Self-hostable](https://img.shields.io/badge/self--hostable-brightgreen?logo=docker&logoColor=white)]() -[![Voice support](https://img.shields.io/badge/voice-WebRTC-blueviolet?logo=googlemeet&logoColor=white)]()
@@ -19,7 +19,7 @@ Most interview platforms are puzzle grinders. O(1) Prep puts you in a real inter - **A back-and-forth, not a quiz** - the interviewer follows up, pushes back on your reasoning, and adds constraints mid-session just like a real one would - **Feedback that actually tells you something** - you get written scores and specific critique on your approach, code quality, communication, and tradeoffs - not just pass/fail - **Your own account** - sign in and your sessions, history, and code are saved to your account in the app's database; self-host it and the data stays on infrastructure you control -- **Practice out loud** - voice mode lets you talk through your solution the way you would in an actual interview +- **Bring your own engine** - runs on your local Claude Code CLI by default (no API key), or point it at OpenAI or OpenRouter (one key, hundreds of models) - **150+ problems grounded in real engineering** - each one has a real-world scenario, not just "given an array..." --- @@ -38,12 +38,14 @@ python3 -m venv venv && source venv/bin/activate pip install -r requirements.txt ``` -Copy `backend/.env.example` to `backend/.env` and set your values (database URL, a secret key, and your OpenAI key): +Copy `backend/.env.example` to `backend/.env` and set your values. By default the AI engine is your local **Claude Code CLI** — no API key, just run `claude` once to sign in. Set `AI_PROVIDER` to `openai` or `openrouter` to use those instead (see `.env.example` for their keys). ``` DATABASE_URL=postgresql+psycopg://o1prep:o1prep@localhost:5433/o1prep SECRET_KEY=change-me -OPENAI_API_KEY=sk-your-key-here +AI_PROVIDER=claude # claude (default) | openai | openrouter +# OPENAI_API_KEY=sk-... # only when AI_PROVIDER=openai +# OPENROUTER_API_KEY=sk-or-... # only when AI_PROVIDER=openrouter ``` Create the schema, load the problems, and run — all from `backend/`: @@ -64,20 +66,20 @@ API. In a second terminal: ```bash cd frontend -npm install -npm run dev # http://localhost:3000 +pnpm install +pnpm dev # http://localhost:3000 ``` Open **http://localhost:3000**. The dev server proxies `/api/*` to Flask on `127.0.0.1:5000` (see `frontend/next.config.ts`), so the session cookie stays same-origin and auth works with no CORS setup. Point it elsewhere with -`API_ORIGIN=http://host:port npm run dev`. +`API_ORIGIN=http://host:port pnpm dev`. > On macOS, the AirPlay Receiver squats on port 5000 over IPv6 — the proxy > targets `127.0.0.1` (IPv4) specifically to avoid it. If you change the Flask > port, update `API_ORIGIN`. -> **Prerequisites:** Python 3.11+, Node.js 20+, Docker (for PostgreSQL), a paid OpenAI API key, and a modern browser (Chrome/Firefox/Safari). +> **Prerequisites:** Python 3.11+, Node.js 20+ and [pnpm](https://pnpm.io), Docker (for PostgreSQL), an AI engine (the Claude Code CLI needs no key; OpenAI/OpenRouter need an API key), and a modern browser (Chrome/Firefox/Safari). --- @@ -138,7 +140,7 @@ Read a problem fully and chat with an AI tutor before you practice - useful when ![Study mode with problem details and research chat](docs/screenshots/StudyMode.png) -Two resizable panels side by side. The left panel has the full problem - scenario, constraints, examples, key skills, and follow-up challenges. The right panel is a tutor chat where you can ask anything about the problem without being handed the solution. +Two panels side by side — drag the divider to resize the tutor chat, and its width is remembered. The left panel has the full problem - scenario, constraints, examples, key skills, and follow-up challenges. The right panel is a tutor chat where you can ask anything about the problem without being handed the solution. Good things to ask the tutor: @@ -154,7 +156,7 @@ When you're ready, go back and hit **Practice** to start the interview. ![Interview mode with chat, code editor, test results, and tutor sidebar](docs/screenshots/PracticeAndEvaluateResult.png) -Three panels: the interviewer chat on the left, your code editor in the center, and an optional tutor sidebar on the right. All dividers are draggable. +Three panels: the interviewer chat on the left, your code editor in the center, and an optional tutor sidebar on the right. On a narrow screen, tabs switch between the chat and the editor. The interview runs like a real one - you'll clarify the problem, talk through your approach, then implement. The interviewer watches what you submit and responds to it. You can submit code multiple times; each submission gets reviewed. @@ -178,27 +180,15 @@ The interviewer gives a structured debrief with a hire/no-hire rating, scores ac ## Voice Interviews -Practice talking through your solution the way you actually would in an interview. +> **Status: temporarily unavailable in the web client.** Voice mode is being reworked to route through a natural-sounding neural TTS provider — the browser's built-in speech synthesis sounded too robotic for a realistic interview. The pipeline (browser speech-to-text → your chosen LLM → text-to-speech) is in place behind the scenes and will return once a good TTS voice is wired in. -![Voice interview mode with live transcript banner](docs/screenshots/VoiceModeToEmulateRealInterviews.png) - -Switch to **Voice** mode in the toggle before starting. Once the interview begins, allow microphone access and wait for the connection. Your speech is transcribed live, the interviewer responds through your speakers, and everything is saved to your history just like a text session. - -You can still write and submit code while in voice mode - just click **Submit Code** as normal. - -### Troubleshooting - -**Mic access denied** - Go to browser site settings for `localhost`, allow microphone, then reload. - -**No audio from interviewer** - Check system audio output. Audio routes through the browser. - -**Connection drops** - End the session and start a new one. Your previous session is preserved in History. +The intent: switch to **Voice** mode before starting and talk through your solution out loud — your speech is transcribed live, the interviewer replies through your speakers, and the whole session is saved to your history just like a text one, all while you can still write and **Submit Code**. --- ## Code Editor & Execution -Multi-language editor (Python or JavaScript, picked from the language selector) with syntax highlighting, auto-closing brackets, line numbers, and smart indentation. Switching language loads that language's starter code and runs against its test suite. Your code is auto-saved every 2 seconds - you won't lose it if you navigate away. +A CodeMirror editor (Python, JavaScript, or TypeScript, picked from the language selector) with syntax highlighting, auto-closing brackets, line numbers, and smart indentation. Switching language loads that language's starter code and runs against its test suite. Your code is auto-saved as you type - you won't lose it if you navigate away. | Button | What it does | | --------- | ------------------------------------------------------------------ | @@ -313,11 +303,11 @@ Each category shows a progress bar and lists the problems you've attempted with ## FAQ -**Do I need a paid OpenAI account?** -Yes. The interviewer uses GPT-4o, which requires a paid API key. Voice mode additionally uses the Realtime API. +**Do I need a paid AI account?** +Not necessarily. The default engine is your local **Claude Code CLI**, which runs on your existing Claude sign-in with no separate API key. If you prefer, set `AI_PROVIDER=openai` or `openrouter` and supply that key instead. **How much does each interview cost?** -Roughly $0.10-$0.50 per text session depending on length. Voice sessions cost more due to Realtime API audio pricing. +On the default Claude CLI engine it draws on your Claude usage rather than a per-call charge. On OpenAI or OpenRouter it's pay-per-token — roughly $0.10-$0.50 per text session depending on length and the model you pick. **Can I use other programming languages?** Yes. Pick Python or JavaScript from the language selector in the editor; the code @@ -326,6 +316,9 @@ it, or set `NODE_BIN`). TypeScript is also wired up (via `tsx`/`TS_CMD`). Problems are translated per language, so the selector offers whichever languages a given problem has been authored for. +**Can I pick a different model?** +Yes. The **Settings** (gear) menu lets you choose the model and reasoning effort for new interviews and tutor chats. On OpenRouter you can search its live catalog of hundreds of models; on Claude and OpenAI you pick from that provider's list. + **What happens if I close the browser mid-interview?** The session is auto-saved every time you send a message or submit code. Open History to resume from where you left off. @@ -336,7 +329,7 @@ Yes. Multiple sessions on the same problem are tracked separately. The status do That's what makes it useful. Vague feedback doesn't help you improve. The debrief is written the way a hiring committee would actually talk about your performance. **Where is my data stored?** -In the application's PostgreSQL database, scoped to your account. If you self-host, that database runs on infrastructure you control. Your messages and code are sent to OpenAI's API to power the interviewer. +In the application's PostgreSQL database, scoped to your account. If you self-host, that database runs on infrastructure you control. Your messages and code are sent to your configured AI provider (Claude, OpenAI, or OpenRouter) to power the interviewer. **Can I add my own problems?** Yes - see [CONTRIBUTING.md](CONTRIBUTING.md) for the full problem YAML format. @@ -353,4 +346,4 @@ This project is a fork of [codingprep](https://github.com/amrutha97/codingprep) ## Contact -Questions or feedback? Reach out on [LinkedIn](https://github.com/shettydev). +Questions or feedback? Reach out on [LinkedIn](https://www.linkedin.com/in/shettydev/). 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..1a481df 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,16 @@ 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" +# Public catalog endpoint (no API key required) used to populate the model list. +OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models" +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/routes/__init__.py b/backend/routes/__init__.py index f88be6b..d7f3616 100644 --- a/backend/routes/__init__.py +++ b/backend/routes/__init__.py @@ -1,5 +1,6 @@ from routes.auth import bp as auth_bp from routes.code import bp as code_bp +from routes.models import bp as models_bp from routes.problems import bp as problems_bp from routes.realtime import bp as realtime_bp from routes.research import bp as research_bp @@ -10,6 +11,7 @@ problems_bp, sessions_bp, code_bp, + models_bp, realtime_bp, research_bp, ] diff --git a/backend/routes/models.py b/backend/routes/models.py new file mode 100644 index 0000000..841baaf --- /dev/null +++ b/backend/routes/models.py @@ -0,0 +1,27 @@ +"""Model catalog search. + +Backs the settings-modal model picker. The OpenRouter catalog is ~300+ models, +so the client never receives the whole list: it renders the curated 'popular +few' from /api/config and calls this endpoint (debounced) to search the rest. +Filtering happens server-side against the in-process catalog cache; results are +bounded by `limit`. +""" + +from flask import Blueprint, jsonify, request +from flask_login import login_required + +from services import ai + +bp = Blueprint("models", __name__) + + +@bp.route("/api/models/search", methods=["GET"]) +@login_required +def search(): + query = request.args.get("q", "") + try: + limit = int(request.args.get("limit", 25)) + except (TypeError, ValueError): + limit = 25 + limit = max(1, min(limit, 50)) + return jsonify({"models": ai.search_models(query, limit)}) diff --git a/backend/services/ai.py b/backend/services/ai.py index 05ca5fe..c457812 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 @@ -33,14 +35,21 @@ def config_info(): p = _provider() return { "provider": config.AI_PROVIDER, - "models": p.MODELS, + "models": p.models(), "default_model": p.default_model(), "supports_effort": p.SUPPORTS_EFFORT, "efforts": p.EFFORTS, "default_effort": p.default_effort(), + "supports_search": getattr(p, "SUPPORTS_SEARCH", False), } +def search_models(query, limit=25): + """Bounded, provider-aware model search for the settings UI. For OpenRouter + this filters the live catalog; other providers filter their static list.""" + return _provider().search_models(query, limit) + + def missing_client_message(): """Human-readable reason the current provider isn't usable, for API errors.""" if config.AI_PROVIDER == "claude": diff --git a/backend/services/providers/claude_provider.py b/backend/services/providers/claude_provider.py index 6b80284..316e2b1 100644 --- a/backend/services/providers/claude_provider.py +++ b/backend/services/providers/claude_provider.py @@ -49,6 +49,18 @@ ] EFFORTS = ["low", "medium", "high", "max"] SUPPORTS_EFFORT = True +# Fixed three-model list, filtered client-side; no live search endpoint needed. +SUPPORTS_SEARCH = False + + +def models(): + return MODELS + + +def search_models(query, limit=25): + q = (query or "").strip().lower() + hits = [m for m in MODELS if not q or q in m["id"].lower() or q in m["label"].lower()] + return hits[:limit] def available(): diff --git a/backend/services/providers/openai_provider.py b/backend/services/providers/openai_provider.py index f149457..0c3f30d 100644 --- a/backend/services/providers/openai_provider.py +++ b/backend/services/providers/openai_provider.py @@ -16,6 +16,19 @@ ] EFFORTS = [] SUPPORTS_EFFORT = False +# OpenAI's list is tiny and static, so the UI filters it client-side; no live +# search endpoint is needed (unlike OpenRouter's ~300-model catalog). +SUPPORTS_SEARCH = False + + +def models(): + return MODELS + + +def search_models(query, limit=25): + q = (query or "").strip().lower() + hits = [m for m in MODELS if not q or q in m["id"].lower() or q in m["label"].lower()] + return hits[:limit] def default_model(): diff --git a/backend/services/providers/openrouter_provider.py b/backend/services/providers/openrouter_provider.py new file mode 100644 index 0000000..147137f --- /dev/null +++ b/backend/services/providers/openrouter_provider.py @@ -0,0 +1,228 @@ +"""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. + +Model list is fetched LIVE from OpenRouter's public /models catalog (no key +needed) and cached in-process. The UI shows only a curated "popular few"; the +full ~300+ catalog is searched server-side (see search_models) so the client +never has to hold the whole list. If the catalog fetch fails we fall back to a +small hardcoded seed so the app keeps working offline. + +Because the interviewer, tutor, and voice mode all stream through the same +ai.stream_chat path, selecting OpenRouter makes the entire app model-agnostic. +""" + +import json +import os +import time + +import requests as http_requests + +import config + +# OpenRouter's public /models list carries no popularity ranking, so "popular" +# is a curated, ordered allowlist of prominent slugs. It is resolved against the +# live catalog (fresh names/context/pricing); slugs no longer offered drop off. +POPULAR_SLUGS = [ + "anthropic/claude-sonnet-5", + "anthropic/claude-3.7-sonnet", + "anthropic/claude-3.5-sonnet", + "anthropic/claude-3.5-haiku", + "openai/gpt-4o", + "openai/gpt-4o-mini", + "google/gemini-2.5-pro", + "google/gemini-2.5-flash", + "meta-llama/llama-3.3-70b-instruct", + "deepseek/deepseek-chat", + "mistralai/mistral-large", +] + +# Offline fallback used only when the live catalog can't be fetched. +SEED_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"}, + {"id": "google/gemini-2.5-flash", "label": "Gemini 2.5 Flash"}, + {"id": "meta-llama/llama-3.3-70b-instruct", "label": "Llama 3.3 70B"}, +] + +EFFORTS = [] +SUPPORTS_EFFORT = False +SUPPORTS_SEARCH = True + +CATALOG_TTL = 3600 # seconds +_catalog_cache = {"ts": 0.0, "models": None} # models: list[dict] | None + + +def _fetch_catalog(): + resp = http_requests.get(config.OPENROUTER_MODELS_URL, timeout=10) + resp.raise_for_status() + return resp.json().get("data", []) + + +def catalog(): + """The live OpenRouter model catalog, cached with a TTL. Returns [] if it + has never been fetched successfully (callers then use SEED_MODELS).""" + now = time.time() + fresh = _catalog_cache["models"] is not None and now - _catalog_cache["ts"] <= CATALOG_TTL + if not fresh: + try: + _catalog_cache["models"] = _fetch_catalog() + _catalog_cache["ts"] = now + except Exception: + # Keep any stale cache; only fall through to [] if we never had one. + if _catalog_cache["models"] is None: + return [] + return _catalog_cache["models"] or [] + + +def _price_per_million(pricing): + # OpenRouter prices are per-token USD strings; surface $/1M tokens for the UI. + try: + prompt = round(float(pricing.get("prompt", 0)) * 1_000_000, 2) + completion = round(float(pricing.get("completion", 0)) * 1_000_000, 2) + return {"prompt": prompt, "completion": completion} + except (TypeError, ValueError): + return None + + +def _to_option(m): + return { + "id": m["id"], + "label": m.get("name") or m["id"], + "context_length": m.get("context_length"), + "pricing": _price_per_million(m.get("pricing") or {}), + } + + +def models(): + """The curated 'popular few', resolved against the live catalog.""" + cat = catalog() + if not cat: + return SEED_MODELS + by_id = {m["id"]: m for m in cat} + out = [_to_option(by_id[slug]) for slug in POPULAR_SLUGS if slug in by_id] + # Always surface the configured default, even if it's not in the curated set. + if config.OPENROUTER_MODEL in by_id and not any(o["id"] == config.OPENROUTER_MODEL for o in out): + out.insert(0, _to_option(by_id[config.OPENROUTER_MODEL])) + return out or SEED_MODELS + + +def search_models(query, limit=25): + """Substring search over the live catalog, ranked prefix/family-first and + bounded to `limit` so the client never receives the whole list. An empty + query returns the curated popular few.""" + q = (query or "").strip().lower() + cat = catalog() + if not cat: + return [m for m in SEED_MODELS if not q or q in m["id"].lower() or q in m["label"].lower()][:limit] + if not q: + return models() + + scored = [] + for m in cat: + mid = m["id"].lower() + name = (m.get("name") or "").lower() + if q not in mid and q not in name: + continue + if mid.startswith(q) or name.startswith(q): + score = 2 + elif q in mid.rsplit("/", 1)[-1] or q in name: + score = 1 + else: + score = 0 + scored.append((score, m)) + scored.sort(key=lambda t: (-t[0], t[1]["id"])) + return [_to_option(m) for _, m in scored[:limit]] + + +def default_effort(): + return None + + +def default_model(): + cat = catalog() + if cat and any(m["id"] == config.OPENROUTER_MODEL for m in cat): + return config.OPENROUTER_MODEL + # Self-heal: the configured default was retired — fall back to the first + # curated model that still exists (or the configured value when offline). + ms = models() + return ms[0]["id"] if ms else config.OPENROUTER_MODEL + + +def valid_model(model): + if not model: + return False + cat = catalog() + if cat: + return any(m["id"] == model for m in cat) + # Offline: accept the configured default or any seed slug. + return model == config.OPENROUTER_MODEL or any(s["id"] == model for s in SEED_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 default_model() + return OpenRouterClient(api_key, m) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a7abb4f..fbbf21d 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -80,6 +80,18 @@ html { scrollbar-gutter: stable; } +/* On large screens the app shell pins to the viewport (lg:h-dvh) and each panel + scrolls on its own, so the document itself must never scroll. Without this the + reserved gutter renders as a dead scrollbar track and sub-pixel rounding of the + viewport-height shell leaves a thin strip of empty space below the fold. On + small screens the page still scrolls normally. */ +@media (min-width: 64rem) { + html, + body { + overflow: hidden; + } +} + /* During the cold-boot caret flight the logo holds an empty (invisible) caret slot — the travelling cursor IS the only caret on screen until it lands. */ html.caret-flying .caret { diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ffa6002..be560a3 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -34,7 +34,7 @@ export default function Home() { }, []); return ( -
+
{/* On desktop the page is pinned to the viewport and each panel scrolls on its own; on mobile it falls back to normal page scroll. */} diff --git a/frontend/src/components/ModelCombobox.tsx b/frontend/src/components/ModelCombobox.tsx new file mode 100644 index 0000000..3843d48 --- /dev/null +++ b/frontend/src/components/ModelCombobox.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { ModelOption } from "@/lib/types"; +import { searchModels } from "@/lib/api"; + +function fmtCtx(n?: number | null): string | null { + if (!n) return null; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M ctx`; + if (n >= 1000) return `${Math.round(n / 1000)}K ctx`; + return `${n} ctx`; +} + +function fmtPrice(p?: { prompt: number; completion: number } | null): string | null { + if (!p) return null; + return `$${p.prompt}/$${p.completion} per M`; +} + +interface Props { + value: string; + onChange: (id: string) => void; + /** The curated "popular few" from /api/config — the default list. */ + options: ModelOption[]; + /** When true, typing hits the live server-side catalog search. */ + supportsSearch: boolean; +} + +/** + * Searchable model picker. Shows the popular few by default; typing runs a + * debounced server search (OpenRouter's ~300-model catalog) that returns a + * bounded result set, so the client never holds the full list. Falls back to + * client-side filtering of `options` when the provider has no search endpoint. + */ +export function ModelCombobox({ value, onChange, options, supportsSearch }: Props) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [serverResults, setServerResults] = useState([]); + const [loading, setLoading] = useState(false); + const [highlight, setHighlight] = useState(0); + const [selectedLabel, setSelectedLabel] = useState( + () => options.find((m) => m.id === value)?.label ?? value, + ); + const rootRef = useRef(null); + const seq = useRef(0); // latest-query-wins guard for async results + + const trimmed = query.trim(); + // Results are derived, not stored: only the async server search needs state. + const results: ModelOption[] = !trimmed + ? options + : supportsSearch + ? serverResults + : options.filter((m) => { + const lc = trimmed.toLowerCase(); + return m.id.toLowerCase().includes(lc) || m.label.toLowerCase().includes(lc); + }); + const activeIdx = results.length ? Math.min(highlight, results.length - 1) : -1; + const searching = loading && !!trimmed && supportsSearch; + + // Close when clicking outside the widget. + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + // Debounced live catalog search. All state writes happen inside the timeout / + // promise callbacks, so the effect body itself never sets state synchronously. + useEffect(() => { + if (!open || !supportsSearch || !trimmed) return; + const mine = ++seq.current; + const timer = setTimeout(() => { + setLoading(true); + searchModels(trimmed, 25) + .then((r) => { + if (mine !== seq.current) return; + setServerResults(r); + setHighlight(0); + }) + .catch(() => { + if (mine === seq.current) setServerResults([]); + }) + .finally(() => { + if (mine === seq.current) setLoading(false); + }); + }, 250); + return () => clearTimeout(timer); + }, [trimmed, open, supportsSearch]); + + const openPanel = () => { + setQuery(""); + setHighlight(0); + setOpen(true); + }; + + const pick = (m: ModelOption) => { + onChange(m.id); + setSelectedLabel(m.label); + setOpen(false); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlight((h) => Math.min(h + 1, results.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlight((h) => Math.max(h - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const m = results[activeIdx]; + if (m) pick(m); + } else if (e.key === "Escape") { + setOpen(false); + } + }; + + return ( +
+ + + {open && ( +
+ setQuery(e.target.value)} + onKeyDown={onKeyDown} + placeholder={supportsSearch ? "search models…" : "filter models…"} + className="w-full border-b border-line bg-bg-inset px-2 py-1.5 text-[12px] text-text placeholder:text-text-faint focus:outline-none" + /> +
    + {searching &&
  • searching…
  • } + {!searching && results.length === 0 && ( +
  • no models found
  • + )} + {!searching && + results.map((m, i) => { + const sub = [fmtCtx(m.context_length), fmtPrice(m.pricing)] + .filter(Boolean) + .join(" · "); + const active = m.id === value; + return ( +
  • + +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e51abe3..ec85af8 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -5,6 +5,7 @@ import type { EngineConfig } from "@/lib/types"; import { useSettings } from "@/store/settings"; import { useUI } from "@/store/ui"; import { Modal } from "./Modal"; +import { ModelCombobox } from "./ModelCombobox"; function Field({ label, children }: { label: string; children: React.ReactNode }) { return ( @@ -53,13 +54,12 @@ function SettingsForm({ config, onClose }: { config: EngineConfig; onClose: () = - + {config.supports_effort && config.efforts.length > 0 && ( diff --git a/frontend/src/components/interview/ChatPanel.tsx b/frontend/src/components/interview/ChatPanel.tsx index 5aaf075..f75d1fc 100644 --- a/frontend/src/components/interview/ChatPanel.tsx +++ b/frontend/src/components/interview/ChatPanel.tsx @@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from "react"; import { useInterview, type ChatItem } from "@/store/interview"; import { Markdown } from "./Markdown"; import { TestResults } from "./TestResults"; +// TODO(voice): disabled pending a natural TTS provider (see InterviewTopBar). +// import { VoiceControls } from "./VoiceControls"; function MessageBlock({ item }: { item: ChatItem }) { if (item.role === "tests") { @@ -72,6 +74,8 @@ export function ChatPanel() { )} + {/* TODO(voice): re-enable when neural TTS lands. */} +