# Spec: Frontier-vs-Local "Compare" view

Add a third mode ("Compare") alongside the existing Text/Voice tabs in the MentorCore
web chat UI. It sends one user message to BOTH LLM providers (Anthropic frontier model
and the local LMStudio model) for the same persona, and shows the two replies streaming
side by side in two panes, so a user can directly compare frontier vs local output for
the same persona/question.

## Constraints (hard project rules — do not violate)

- All LLM calls already go through `runtime/providers/` (`AnthropicProvider`,
  `LMStudioProvider` in `runtime/runtime/providers/anthropic_provider.py`, selected via
  `runtime/runtime/providers/factory.py::get_provider()`). Do not instantiate a provider
  client anywhere else.
- Tests must run offline (FakeProvider + recorded fixtures); `FAKE_PROVIDER=1` env must
  keep working exactly as it does today and must always win regardless of any new
  provider-selection param (existing tests in `tests/runtime/test_chat_api.py` and
  `tests/runtime/test_providers.py` rely on this — read them first, do not break them).
- Model IDs stay centralized in `shared/shared/config.py` (`MODELS` dataclass) — don't
  hardcode a frontier model id anywhere new.
- Don't touch `runtime/conversation/session.py`, `runtime/conversation/prompt.py`, or
  `runtime/conversation/retrieval.py` — this feature does not need session-format or
  prompt-assembly changes, only provider selection + a new UI view.

## Current architecture (read before writing code)

- `runtime/runtime/providers/factory.py::get_provider() -> ChatProvider` currently takes
  no arguments; it picks Anthropic vs LMStudio purely from the `LLM_PROVIDER` env var
  (or `FakeProvider` if `FAKE_PROVIDER=1`).
- `runtime/runtime/api/chat.py`: `POST /api/chat/{slug}` takes a `ChatRequest{message,
  session_id}`, calls `_get_provider()` (which just calls `get_provider()`), assembles
  the system prompt, and streams SSE events (`session`, `text`, `done`) back. Each call
  creates/reuses a session via `SessionStore` keyed by `session_id`.
- `web/app/page.tsx`: top-level page with a sidebar `PersonaPicker` and a tab bar
  (`Text` / `Voice`) that swaps between `ChatPane` and `VoicePane`, each driven by its
  own `{messages, sessionId}` conversation state per persona slug.
- `web/app/components/ChatPane.tsx`: a single chat column — POSTs to `/api/chat/{slug}`,
  reads the SSE stream by hand (splits on `\n`, parses `data: ` lines, switches on
  `payload.type`), and renders a scrolling message list + input row. This is the pattern
  to reuse for each column of the new compare view.

## Required backend changes

1. `runtime/runtime/providers/factory.py`: change `get_provider()` to
   `get_provider(name: str | None = None) -> ChatProvider`. When `FAKE_PROVIDER=1` is
   set, ALWAYS return the fake provider regardless of `name` (existing behavior, must
   not change — check this first, before looking at `name`). Otherwise, if `name` is
   given, use it directly (`"lmstudio"` → `LMStudioProvider()`, anything else →
   `AnthropicProvider()`); if `name` is not given, fall back to the existing
   `LLM_PROVIDER` env var behavior exactly as today. This must be backward compatible:
   every existing call site that calls `get_provider()` with no arguments must behave
   identically to before.

2. `runtime/runtime/api/chat.py`:
   - Add `provider: Literal["anthropic", "lmstudio"] | None = None` to the `ChatRequest`
     pydantic model (import `Literal` from `typing`). Sending an invalid value should
     404/422 automatically via pydantic — no manual validation needed.
   - Change `_get_provider()` to `_get_provider(name: str | None = None)` which just
     calls `get_provider(name)`.
   - In the `chat()` handler, call `provider = _get_provider(req.provider)` instead of
     `_get_provider()` with no args.
   - Add a new lightweight `GET /api/providers` endpoint returning JSON
     `{"frontier_model": <str>, "local_model": <str>}` so the frontend can label the two
     panes with real model ids instead of hardcoded guesses. `frontier_model` should come
     from `MODELS.runtime_conversation` (already imported in this file). `local_model`
     should come from `os.environ.get("LMSTUDIO_MODEL", "lmstudio-community/gpt-oss-20b")`
     (matches the default already hardcoded inside `LMStudioProvider.__init__` in
     `anthropic_provider.py` — reuse that same default string, don't invent a new one).
     Define a small `ProviderInfo(BaseModel)` with those two fields for the response
     model.

3. Session semantics: each pane in the compare view is its own independent conversation
   — do NOT try to make one user message create two entries in the same session. The
   simplest correct approach: the frontend calls `POST /api/chat/{slug}` TWICE for one
   user turn (once with `provider: "anthropic"`, once with `provider: "lmstudio"`), each
   with ITS OWN `session_id` that it tracks independently (starts null, gets set from
   the first `session` SSE event exactly like `ChatPane` already does). No backend
   session-store changes are needed — this already works because each POST creates or
   reuses whatever session_id it's given.

## Required frontend changes

1. New component `web/app/components/ComparePane.tsx`:
   - Props: `{ persona: PersonaInfo; apiBase: string }` (reuse the `PersonaInfo` type
     from `./PersonaPicker` and the `Message` type from `./ChatPane`).
   - Internal state: two independent columns, keyed `"anthropic"` and `"lmstudio"`, each
     holding its own `{ messages: Message[], sessionId: string | null, streaming: boolean
     }`. On mount, `GET /api/providers` to get real model ids for the two column headers
     (fall back to a generic label like "Frontier" / "Local" if the fetch fails — don't
     let a failed label fetch break the rest of the UI).
   - ONE shared textarea + "Send to both" button. On send: append the user's message to
     BOTH columns' message lists, then fire two independent SSE-consuming fetches (one
     per column, passing that column's own `session_id` and the matching `provider`
     field), each updating only its own column's state as `text`/`done` events arrive.
     The two fetches must run concurrently, not one-after-the-other, and a slow/error
     one must not block or corrupt the other column's state.
   - Render two columns side by side (equal width, independently scrollable message
     lists), each labeled with which provider it is ("Frontier" / "Local") and the
     model id from `/api/providers`, each rendering its own messages using the same
     bubble/role/streaming-cursor visual pattern `ChatPane.tsx` already uses (copy that
     visual style, don't invent a new one — the point is a fair side-by-side, not a
     redesign).
   - Handle per-column errors independently (an error in one column shows an inline
     error in that column only, the other column keeps working).

2. `web/app/page.tsx`:
   - Add `"compare"` to the `Mode` type (`"text" | "voice" | "compare"`).
   - Add a third tab button ("Compare") next to Text/Voice in the tab bar, following the
     exact same active/inactive styling pattern as the existing two tabs.
   - When `mode === "compare"`, render `<ComparePane key={selected.slug} persona=
     {selected} apiBase={API_BASE} />` (the `key` on persona slug matters: it resets the
     compare view's internal state when the user switches personas, instead of leaking
     the previous persona's conversation into the new one).
   - Do not change how Text/Voice modes work.

## Tests to add (offline, FakeProvider-based, mirror existing test style exactly)

- `tests/runtime/test_providers.py`: add tests for `get_provider(name=...)` — explicit
  `"lmstudio"` returns `LMStudioProvider`, explicit `"anthropic"` returns
  `AnthropicProvider`, no `name` falls back to the `LLM_PROVIDER` env var (existing
  behavior), and `FAKE_PROVIDER=1` wins even when a `name` is passed.
- `tests/runtime/test_chat_api.py`: add tests that `POST /api/chat/{slug}` with
  `{"provider": "anthropic"}` and with `{"provider": "lmstudio"}` both still return a
  200 SSE stream ending in a `done` event (with `FAKE_PROVIDER=1` set by the existing
  `client` fixture, both providers resolve to the same FakeProvider, so this only tests
  that the field is accepted and doesn't break the request — it does NOT prove real
  provider routing, that's out of scope for an offline test). Also add a test that
  `GET /api/providers` returns 200 with `frontier_model` and `local_model` keys. Also
  add a test that an invalid `provider` value returns 422.
- Do not delete or weaken any existing test in either file.

## Frontend build/lint gate

```bash
cd /Users/jameslopez/projects/MentorCore/web
npm run lint
npm run build
```
Both must succeed (lint: 0 errors, warnings are acceptable but prefer 0; build: succeeds
with no TypeScript errors).

## Backend test gate

```bash
cd /Users/jameslopez/projects/MentorCore
.venv/bin/python -m pytest tests/runtime/test_providers.py tests/runtime/test_chat_api.py -q
```
All tests (existing + new) must pass.

## Non-goals (do not do these)

- Do not persist compare-mode conversations across tab switches or add them to the
  `conversations` state already in `page.tsx` for Text mode — a self-contained
  internal state in `ComparePane` (reset via the `key` prop) is sufficient and simpler.
- Do not add a way to send different messages to each column — one shared input is the
  point of a fair comparison.
- Do not touch `VoicePane.tsx` or the voice API.
