dakshtaneja commited on
Commit
001f1df
·
1 Parent(s): 6b3d40b

checkpoint: current behavior before latency work

Browse files
backend/app/config.py CHANGED
@@ -11,54 +11,77 @@ class ModelSpec:
11
  """Static description of a model available through OpenRouter."""
12
 
13
  def __init__(self, key: str, openrouter_id: str, display_name: str,
14
- cost_per_mtok_in: float, cost_per_mtok_out: float):
 
15
  self.key = key
16
  self.openrouter_id = openrouter_id
17
  self.display_name = display_name
18
- # USD per 1M tokens (approximate OpenRouter pricing, used for the
19
- # auction cost term and the savings metric)
 
 
 
 
20
  self.cost_per_mtok_in = cost_per_mtok_in
21
  self.cost_per_mtok_out = cost_per_mtok_out
22
-
23
- def estimate_cost(self, tokens_in: int, tokens_out: int) -> float:
 
 
 
 
 
 
24
  return (tokens_in * self.cost_per_mtok_in
25
  + tokens_out * self.cost_per_mtok_out) / 1_000_000
26
 
27
 
28
  # --- Tier 1: cheap bidders -------------------------------------------------
29
  TIER1_MODELS: dict[str, ModelSpec] = {
30
- "gemma": ModelSpec(
31
- key="gemma",
 
 
32
  openrouter_id="google/gemma-4-26b-a4b-it:free",
33
- display_name="Gemma 4 26B",
34
- cost_per_mtok_in=0.0,
35
- cost_per_mtok_out=0.0,
 
 
 
 
 
36
  ),
37
  "deepseek": ModelSpec(
38
  key="deepseek",
39
- openrouter_id="deepseek/deepseek-chat",
40
  display_name="DeepSeek",
41
  cost_per_mtok_in=0.20,
42
  cost_per_mtok_out=0.80,
 
 
 
43
  ),
44
- # Non-thinking instruct variant: thinking models burn the whole
45
- # max_tokens budget on hidden reasoning and return empty content
46
  "qwen": ModelSpec(
47
  key="qwen",
48
- openrouter_id="qwen/qwen3-next-80b-a3b-instruct:free",
49
- display_name="Qwen3 Next 80B",
50
- cost_per_mtok_in=0.0,
51
- cost_per_mtok_out=0.0,
 
 
 
 
52
  ),
53
  }
54
 
55
  # --- Verifier ---------------------------------------------------------------
56
  VERIFIER_MODEL = ModelSpec(
57
  key="verifier",
58
- openrouter_id="google/gemma-4-31b-it:free",
59
- display_name="Gemma 4 31B (Verifier)",
60
- cost_per_mtok_in=0.0,
61
- cost_per_mtok_out=0.0,
62
  )
63
 
64
  # --- Tier 2: frontier escalation target -------------------------------------
@@ -93,6 +116,9 @@ class Settings(BaseSettings):
93
  min_auction_confidence: float = 0.75
94
  verification_threshold: float = 0.80
95
  disagreement_stddev: float = 0.22
 
 
 
96
 
97
  # Default historical accuracy for models with no track record yet
98
  default_historical_accuracy: float = 0.70
@@ -104,9 +130,19 @@ class Settings(BaseSettings):
104
  # (also keeps low-credit OpenRouter keys usable)
105
  max_answer_tokens: int = 2000
106
  max_bid_tokens: int = 300
 
 
 
 
 
 
 
 
 
107
  # Frontier gets extra headroom since reasoning tokens count against
108
- # the cap even at low effort
109
- max_frontier_tokens: int = 4000
 
110
 
111
 
112
  settings = Settings()
 
11
  """Static description of a model available through OpenRouter."""
12
 
13
  def __init__(self, key: str, openrouter_id: str, display_name: str,
14
+ cost_per_mtok_in: float, cost_per_mtok_out: float,
15
+ fallback_id: str | None = None, specialty: str = ""):
16
  self.key = key
17
  self.openrouter_id = openrouter_id
18
  self.display_name = display_name
19
+ # One-line self-description injected into the bid prompt so the
20
+ # model bids according to its actual strengths
21
+ self.specialty = specialty
22
+ # USD per 1M tokens: pricing of the PAID model in this slot (the
23
+ # fallback when the primary is a free variant). Used for the auction
24
+ # cost term and the savings metric.
25
  self.cost_per_mtok_in = cost_per_mtok_in
26
  self.cost_per_mtok_out = cost_per_mtok_out
27
+ # Paid model tried automatically when the free primary is
28
+ # rate-limited (OpenRouter `models` fallback routing)
29
+ self.fallback_id = fallback_id
30
+
31
+ def estimate_cost(self, tokens_in: int, tokens_out: int,
32
+ served_model: str | None = None) -> float:
33
+ if served_model and served_model.endswith(":free"):
34
+ return 0.0
35
  return (tokens_in * self.cost_per_mtok_in
36
  + tokens_out * self.cost_per_mtok_out) / 1_000_000
37
 
38
 
39
  # --- Tier 1: cheap bidders -------------------------------------------------
40
  TIER1_MODELS: dict[str, ModelSpec] = {
41
+ # Bidders/verifier run free-first with an automatic paid fallback when
42
+ # the free pool is rate-limited. Pricing fields = the paid fallback.
43
+ "gemini": ModelSpec(
44
+ key="gemini",
45
  openrouter_id="google/gemma-4-26b-a4b-it:free",
46
+ fallback_id="google/gemini-2.5-flash-lite",
47
+ display_name="Gemma 4 / Gemini Lite",
48
+ cost_per_mtok_in=0.10,
49
+ cost_per_mtok_out=0.40,
50
+ specialty="a fast lightweight generalist: strong at general knowledge, "
51
+ "summaries, and everyday questions; NOT a code specialist and "
52
+ "weak at hard math proofs and complex multi-step reasoning — "
53
+ "defer those to the specialists",
54
  ),
55
  "deepseek": ModelSpec(
56
  key="deepseek",
57
+ openrouter_id="deepseek/deepseek-chat", # no free variant; cheapest paid
58
  display_name="DeepSeek",
59
  cost_per_mtok_in=0.20,
60
  cost_per_mtok_out=0.80,
61
+ specialty="strongest at mathematical reasoning, logic puzzles, and "
62
+ "quantitative problems; solid at code; average at niche "
63
+ "world knowledge",
64
  ),
 
 
65
  "qwen": ModelSpec(
66
  key="qwen",
67
+ openrouter_id="qwen/qwen3-coder:free",
68
+ fallback_id="qwen/qwen3-coder",
69
+ display_name="Qwen3 Coder",
70
+ cost_per_mtok_in=0.22,
71
+ cost_per_mtok_out=1.80,
72
+ specialty="a coding specialist: strongest at writing, debugging, and "
73
+ "explaining code and software architecture; weaker at "
74
+ "non-technical general knowledge",
75
  ),
76
  }
77
 
78
  # --- Verifier ---------------------------------------------------------------
79
  VERIFIER_MODEL = ModelSpec(
80
  key="verifier",
81
+ openrouter_id="openai/gpt-oss-120b",
82
+ display_name="GPT-OSS 120B (Verifier)",
83
+ cost_per_mtok_in=0.04,
84
+ cost_per_mtok_out=0.17,
85
  )
86
 
87
  # --- Tier 2: frontier escalation target -------------------------------------
 
116
  min_auction_confidence: float = 0.75
117
  verification_threshold: float = 0.80
118
  disagreement_stddev: float = 0.22
119
+ # Skip the disagreement check when some bidder is at least this
120
+ # confident (specialists legitimately disagree with generalists)
121
+ disagreement_exempt_confidence: float = 0.85
122
 
123
  # Default historical accuracy for models with no track record yet
124
  default_historical_accuracy: float = 0.70
 
130
  # (also keeps low-credit OpenRouter keys usable)
131
  max_answer_tokens: int = 2000
132
  max_bid_tokens: int = 300
133
+
134
+ # Conversation history caps per pipeline stage (turns are single
135
+ # messages, so 4 turns = 2 user/assistant exchanges)
136
+ history_max_turns_bid: int = 4
137
+ history_max_chars_bid: int = 1600
138
+ history_max_turns_answer: int = 12
139
+ history_max_chars_answer: int = 12000
140
+ history_max_turns_verify: int = 6
141
+ history_max_chars_verify: int = 4000
142
  # Frontier gets extra headroom since reasoning tokens count against
143
+ # the cap (medium effort thinks longer than low)
144
+ max_frontier_tokens: int = 16000
145
+ frontier_reasoning_effort: str = "medium"
146
 
147
 
148
  settings = Settings()
backend/app/llm.py CHANGED
@@ -16,11 +16,13 @@ class LLMError(Exception):
16
 
17
 
18
  class LLMResponse:
19
- def __init__(self, content: str, tokens_in: int, tokens_out: int, latency_ms: int):
 
20
  self.content = content
21
  self.tokens_in = tokens_in
22
  self.tokens_out = tokens_out
23
  self.latency_ms = latency_ms
 
24
 
25
 
26
  _client: httpx.AsyncClient | None = None
@@ -48,21 +50,31 @@ async def close_client() -> None:
48
  _client = None
49
 
50
 
 
 
 
 
 
 
 
 
 
51
  async def chat(model: ModelSpec, system: str, user: str,
52
  timeout: float | None = None,
53
  max_tokens: int | None = None,
54
- reasoning_effort: str | None = None) -> LLMResponse:
 
55
  start = time.monotonic()
56
  body: dict = {
57
  "model": model.openrouter_id,
58
- "messages": [
59
- {"role": "system", "content": system},
60
- {"role": "user", "content": user},
61
- ],
62
  "max_tokens": max_tokens or settings.max_answer_tokens,
63
  }
64
  if reasoning_effort:
65
  body["reasoning"] = {"effort": reasoning_effort}
 
 
 
66
 
67
  # Free-tier models often 429 transiently ("rate-limited upstream,
68
  # retry shortly"), so retry a couple of times honoring Retry-After.
@@ -94,9 +106,87 @@ async def chat(model: ModelSpec, system: str, user: str,
94
  tokens_in=usage.get("prompt_tokens", 0),
95
  tokens_out=usage.get("completion_tokens", 0),
96
  latency_ms=latency_ms,
 
97
  )
98
 
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  def extract_json(text: str) -> dict[str, Any]:
101
  """Pull the first JSON object out of a model response.
102
 
 
16
 
17
 
18
  class LLMResponse:
19
+ def __init__(self, content: str, tokens_in: int, tokens_out: int,
20
+ latency_ms: int, served_model: str = ""):
21
  self.content = content
22
  self.tokens_in = tokens_in
23
  self.tokens_out = tokens_out
24
  self.latency_ms = latency_ms
25
+ self.served_model = served_model # which model actually answered
26
 
27
 
28
  _client: httpx.AsyncClient | None = None
 
50
  _client = None
51
 
52
 
53
+ def _build_messages(system: str, user: str,
54
+ history: list[dict] | None) -> list[dict]:
55
+ return [
56
+ {"role": "system", "content": system},
57
+ *({"role": t["role"], "content": t["content"]} for t in (history or [])),
58
+ {"role": "user", "content": user},
59
+ ]
60
+
61
+
62
  async def chat(model: ModelSpec, system: str, user: str,
63
  timeout: float | None = None,
64
  max_tokens: int | None = None,
65
+ reasoning_effort: str | None = None,
66
+ history: list[dict] | None = None) -> LLMResponse:
67
  start = time.monotonic()
68
  body: dict = {
69
  "model": model.openrouter_id,
70
+ "messages": _build_messages(system, user, history),
 
 
 
71
  "max_tokens": max_tokens or settings.max_answer_tokens,
72
  }
73
  if reasoning_effort:
74
  body["reasoning"] = {"effort": reasoning_effort}
75
+ if model.fallback_id:
76
+ # OpenRouter fallback routing: try free primary, then paid fallback
77
+ body["models"] = [model.openrouter_id, model.fallback_id]
78
 
79
  # Free-tier models often 429 transiently ("rate-limited upstream,
80
  # retry shortly"), so retry a couple of times honoring Retry-After.
 
106
  tokens_in=usage.get("prompt_tokens", 0),
107
  tokens_out=usage.get("completion_tokens", 0),
108
  latency_ms=latency_ms,
109
+ served_model=data.get("model", model.openrouter_id),
110
  )
111
 
112
 
113
+ async def chat_stream(model: ModelSpec, system: str, user: str,
114
+ timeout: float | None = None,
115
+ max_tokens: int | None = None,
116
+ reasoning_effort: str | None = None,
117
+ history: list[dict] | None = None):
118
+ """Streaming variant of chat().
119
+
120
+ Yields {"type": "delta", "text": ...} per token chunk, then a final
121
+ {"type": "final", "response": LLMResponse} with full content and usage.
122
+ """
123
+ body: dict = {
124
+ "model": model.openrouter_id,
125
+ "messages": _build_messages(system, user, history),
126
+ "max_tokens": max_tokens or settings.max_answer_tokens,
127
+ "stream": True,
128
+ "stream_options": {"include_usage": True},
129
+ }
130
+ if reasoning_effort:
131
+ body["reasoning"] = {"effort": reasoning_effort}
132
+ if model.fallback_id:
133
+ body["models"] = [model.openrouter_id, model.fallback_id]
134
+
135
+ start = time.monotonic()
136
+ parts: list[str] = []
137
+ tokens_in = tokens_out = 0
138
+ served = model.openrouter_id
139
+
140
+ attempts = 3
141
+ for attempt in range(attempts):
142
+ async with get_client().stream(
143
+ "POST", "/chat/completions", json=body,
144
+ timeout=timeout or settings.request_timeout_s,
145
+ ) as resp:
146
+ if resp.status_code == 429 and attempt < attempts - 1:
147
+ retry_after = min(float(resp.headers.get("Retry-After", 2)), 5.0)
148
+ await asyncio.sleep(retry_after)
149
+ continue
150
+ if resp.status_code != 200:
151
+ text = (await resp.aread()).decode(errors="replace")
152
+ raise LLMError(f"{model.openrouter_id}: HTTP {resp.status_code}: {text[:300]}")
153
+ async for line in resp.aiter_lines():
154
+ if not line.startswith("data: "):
155
+ continue
156
+ payload = line[len("data: "):].strip()
157
+ if payload == "[DONE]":
158
+ break
159
+ try:
160
+ data = json.loads(payload)
161
+ except json.JSONDecodeError:
162
+ continue
163
+ if "error" in data:
164
+ raise LLMError(f"{model.openrouter_id}: {str(data['error'])[:300]}")
165
+ served = data.get("model", served)
166
+ usage = data.get("usage")
167
+ if usage:
168
+ tokens_in = usage.get("prompt_tokens", tokens_in)
169
+ tokens_out = usage.get("completion_tokens", tokens_out)
170
+ choices = data.get("choices") or []
171
+ if choices:
172
+ piece = (choices[0].get("delta") or {}).get("content") or ""
173
+ if piece:
174
+ parts.append(piece)
175
+ yield {"type": "delta", "text": piece}
176
+ break
177
+
178
+ yield {
179
+ "type": "final",
180
+ "response": LLMResponse(
181
+ content="".join(parts),
182
+ tokens_in=tokens_in,
183
+ tokens_out=tokens_out,
184
+ latency_ms=int((time.monotonic() - start) * 1000),
185
+ served_model=served,
186
+ ),
187
+ }
188
+
189
+
190
  def extract_json(text: str) -> dict[str, Any]:
191
  """Pull the first JSON object out of a model response.
192
 
backend/app/main.py CHANGED
@@ -11,9 +11,13 @@ from fastapi import FastAPI, HTTPException # noqa: E402
11
  from fastapi.middleware.cors import CORSMiddleware # noqa: E402
12
  from fastapi.staticfiles import StaticFiles # noqa: E402
13
 
 
 
 
 
14
  from .config import TIER1_MODELS, TIER2_MODEL, VERIFIER_MODEL, settings # noqa: E402
15
  from .llm import close_client # noqa: E402
16
- from .pipeline import run_query # noqa: E402
17
  from .schemas import MetricsSummary, QueryRequest, RunResult # noqa: E402
18
  from .store import get_store # noqa: E402
19
 
@@ -50,7 +54,24 @@ async def health():
50
  async def query(req: QueryRequest):
51
  if not settings.openrouter_api_key:
52
  raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY is not set")
53
- return await run_query(req.query)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
 
56
  @app.get("/api/runs", response_model=list[RunResult])
 
11
  from fastapi.middleware.cors import CORSMiddleware # noqa: E402
12
  from fastapi.staticfiles import StaticFiles # noqa: E402
13
 
14
+ import json # noqa: E402
15
+
16
+ from fastapi.responses import StreamingResponse # noqa: E402
17
+
18
  from .config import TIER1_MODELS, TIER2_MODEL, VERIFIER_MODEL, settings # noqa: E402
19
  from .llm import close_client # noqa: E402
20
+ from .pipeline import run_query, run_query_stream # noqa: E402
21
  from .schemas import MetricsSummary, QueryRequest, RunResult # noqa: E402
22
  from .store import get_store # noqa: E402
23
 
 
54
  async def query(req: QueryRequest):
55
  if not settings.openrouter_api_key:
56
  raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY is not set")
57
+ return await run_query(req.query, [t.model_dump() for t in req.history])
58
+
59
+
60
+ @app.post("/api/query/stream")
61
+ async def query_stream(req: QueryRequest):
62
+ if not settings.openrouter_api_key:
63
+ raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY is not set")
64
+
65
+ history = [t.model_dump() for t in req.history]
66
+
67
+ async def gen():
68
+ try:
69
+ async for event in run_query_stream(req.query, history):
70
+ yield json.dumps(event) + "\n"
71
+ except Exception as e: # surface pipeline crashes to the client
72
+ yield json.dumps({"type": "error", "message": str(e)[:300]}) + "\n"
73
+
74
+ return StreamingResponse(gen(), media_type="application/x-ndjson")
75
 
76
 
77
  @app.get("/api/runs", response_model=list[RunResult])
backend/app/pipeline.py CHANGED
@@ -9,6 +9,7 @@ verifier gates the draft; failures escalate to the frontier model.
9
  """
10
 
11
  import asyncio
 
12
  import statistics
13
  import time
14
  import uuid
@@ -25,6 +26,7 @@ from .store import get_store
25
 
26
  class RouterState(TypedDict, total=False):
27
  query: str
 
28
  bids: list[Bid]
29
  winner: Optional[str]
30
  draft_answer: Optional[str]
@@ -42,11 +44,13 @@ def _clamp(x: float) -> float:
42
  return max(0.0, min(1.0, float(x)))
43
 
44
 
45
- async def _get_bid(model_key: str, query: str, history: dict[str, float]) -> tuple[Bid, Optional[Usage]]:
 
46
  spec = TIER1_MODELS[model_key]
47
- hist = history.get(model_key, settings.default_historical_accuracy)
48
  try:
49
- resp = await chat(spec, prompts.BID_SYSTEM, prompts.bid_user(query),
 
50
  timeout=settings.bid_timeout_s,
51
  max_tokens=settings.max_bid_tokens)
52
  data = extract_json(resp.content)
@@ -61,7 +65,7 @@ async def _get_bid(model_key: str, query: str, history: dict[str, float]) -> tup
61
  usage = Usage(
62
  model_key=model_key, model_name=spec.display_name, stage="bid",
63
  tokens_in=resp.tokens_in, tokens_out=resp.tokens_out,
64
- cost_usd=spec.estimate_cost(resp.tokens_in, resp.tokens_out),
65
  latency_ms=resp.latency_ms,
66
  )
67
  return bid, usage
@@ -72,15 +76,21 @@ async def _get_bid(model_key: str, query: str, history: dict[str, float]) -> tup
72
 
73
 
74
  async def bid_collection(state: RouterState) -> RouterState:
75
- history = await get_store().historical_accuracy()
76
  results = await asyncio.gather(
77
- *(_get_bid(key, state["query"], history) for key in TIER1_MODELS)
 
78
  )
79
  bids = [b for b, _ in results]
80
  usages = [u for _, u in results if u is not None]
81
 
82
- # Normalize per-model output cost to 0..1 for the auction's cost term
83
- costs = {k: m.cost_per_mtok_out for k, m in TIER1_MODELS.items()}
 
 
 
 
 
84
  max_cost = max(costs.values()) or 1.0
85
  for bid in bids:
86
  bid.cost_factor = costs[bid.model_key] / max_cost
@@ -105,7 +115,11 @@ async def auction(state: RouterState) -> RouterState:
105
  return {"escalated": True,
106
  "escalation_reason": f"Low auction confidence (max {max_conf:.2f} < {settings.min_auction_confidence})"}
107
 
108
- if len(confidences) >= 2:
 
 
 
 
109
  spread = statistics.pstdev(confidences)
110
  if spread > settings.disagreement_stddev:
111
  return {"escalated": True,
@@ -118,7 +132,8 @@ async def auction(state: RouterState) -> RouterState:
118
  async def draft(state: RouterState) -> RouterState:
119
  spec = TIER1_MODELS[state["winner"]]
120
  try:
121
- resp = await chat(spec, prompts.ANSWER_SYSTEM, state["query"])
 
122
  except LLMError as e:
123
  return {"escalated": True, "escalation_reason": f"Winner failed to answer: {str(e)[:150]}"}
124
  if not resp.content.strip():
@@ -133,15 +148,35 @@ async def draft(state: RouterState) -> RouterState:
133
  return {"draft_answer": resp.content, "usages": state["usages"] + [usage]}
134
 
135
 
 
 
 
 
 
 
 
 
 
136
  async def verify(state: RouterState) -> RouterState:
137
  try:
138
  resp = await chat(VERIFIER_MODEL, prompts.VERIFY_SYSTEM,
139
- prompts.verify_user(state["query"], state["draft_answer"]))
 
 
140
  data = extract_json(resp.content)
141
  score = _clamp(data.get("score", 0))
 
 
 
 
 
 
 
142
  verification = Verification(
143
  score=score,
144
- passed=bool(data.get("pass", score >= settings.verification_threshold)),
 
 
145
  feedback=str(data.get("feedback", ""))[:500],
146
  )
147
  except (LLMError, ValueError) as e:
@@ -150,6 +185,18 @@ async def verify(state: RouterState) -> RouterState:
150
  feedback=f"Verifier error: {str(e)[:150]}")
151
  resp = None
152
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  usages = state["usages"]
154
  if resp is not None:
155
  usages = usages + [Usage(
@@ -170,9 +217,10 @@ async def verify(state: RouterState) -> RouterState:
170
 
171
  async def escalate(state: RouterState) -> RouterState:
172
  try:
173
- resp = await chat(TIER2_MODEL, prompts.ANSWER_SYSTEM, state["query"],
174
  max_tokens=settings.max_frontier_tokens,
175
- reasoning_effort="low")
 
176
  if not resp.content.strip():
177
  raise LLMError(f"{TIER2_MODEL.openrouter_id}: empty response "
178
  "(reasoning consumed the token budget)")
@@ -253,11 +301,7 @@ def get_graph():
253
  return _graph
254
 
255
 
256
- async def run_query(query: str) -> RunResult:
257
- start = time.monotonic()
258
- state: RouterState = {"query": query, "usages": [], "escalated": False}
259
- final = await get_graph().ainvoke(state)
260
-
261
  usages = final.get("usages", [])
262
  total_cost = sum(u.cost_usd for u in usages)
263
  # Baseline: the same in/out volume sent straight to the frontier model
@@ -266,7 +310,7 @@ async def run_query(query: str) -> RunResult:
266
  baseline_cost = BASELINE_MODEL.estimate_cost(
267
  max(answer_tokens_in, 100), max(answer_tokens_out, 300))
268
 
269
- run = RunResult(
270
  id=uuid.uuid4().hex[:12],
271
  query=query,
272
  answer=final.get("final_answer", ""),
@@ -283,5 +327,139 @@ async def run_query(query: str) -> RunResult:
283
  baseline_cost_usd=round(baseline_cost, 6),
284
  latency_ms=int((time.monotonic() - start) * 1000),
285
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  await get_store().save_run(run)
287
  return run
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  """
10
 
11
  import asyncio
12
+ import re
13
  import statistics
14
  import time
15
  import uuid
 
26
 
27
  class RouterState(TypedDict, total=False):
28
  query: str
29
+ history: list[dict]
30
  bids: list[Bid]
31
  winner: Optional[str]
32
  draft_answer: Optional[str]
 
44
  return max(0.0, min(1.0, float(x)))
45
 
46
 
47
+ async def _get_bid(model_key: str, query: str, accuracy: dict[str, float],
48
+ chat_history: list[dict]) -> tuple[Bid, Optional[Usage]]:
49
  spec = TIER1_MODELS[model_key]
50
+ hist = accuracy.get(model_key, settings.default_historical_accuracy)
51
  try:
52
+ resp = await chat(spec, prompts.BID_SYSTEM,
53
+ prompts.bid_user(query, chat_history, spec.specialty),
54
  timeout=settings.bid_timeout_s,
55
  max_tokens=settings.max_bid_tokens)
56
  data = extract_json(resp.content)
 
65
  usage = Usage(
66
  model_key=model_key, model_name=spec.display_name, stage="bid",
67
  tokens_in=resp.tokens_in, tokens_out=resp.tokens_out,
68
+ cost_usd=spec.estimate_cost(resp.tokens_in, resp.tokens_out, resp.served_model),
69
  latency_ms=resp.latency_ms,
70
  )
71
  return bid, usage
 
76
 
77
 
78
  async def bid_collection(state: RouterState) -> RouterState:
79
+ accuracy = await get_store().historical_accuracy()
80
  results = await asyncio.gather(
81
+ *(_get_bid(key, state["query"], accuracy, state.get("history", []))
82
+ for key in TIER1_MODELS)
83
  )
84
  bids = [b for b, _ in results]
85
  usages = [u for _, u in results if u is not None]
86
 
87
+ # Normalize per-model output cost to 0..1 for the auction's cost term.
88
+ # Free-primary models are discounted: they only cost their fallback
89
+ # price when the free pool is congested (~30% of the time).
90
+ costs = {
91
+ k: m.cost_per_mtok_out * (0.3 if m.openrouter_id.endswith(":free") else 1.0)
92
+ for k, m in TIER1_MODELS.items()
93
+ }
94
  max_cost = max(costs.values()) or 1.0
95
  for bid in bids:
96
  bid.cost_factor = costs[bid.model_key] / max_cost
 
115
  return {"escalated": True,
116
  "escalation_reason": f"Low auction confidence (max {max_conf:.2f} < {settings.min_auction_confidence})"}
117
 
118
+ # Disagreement only matters when nobody is sure: with specialist
119
+ # bidders, a wide spread (coder bids 0.3 on a trivia question) is the
120
+ # system working, not a red flag — so skip the check when a model is
121
+ # highly confident.
122
+ if len(confidences) >= 2 and max_conf < settings.disagreement_exempt_confidence:
123
  spread = statistics.pstdev(confidences)
124
  if spread > settings.disagreement_stddev:
125
  return {"escalated": True,
 
132
  async def draft(state: RouterState) -> RouterState:
133
  spec = TIER1_MODELS[state["winner"]]
134
  try:
135
+ resp = await chat(spec, prompts.ANSWER_SYSTEM, state["query"],
136
+ history=state.get("history"))
137
  except LLMError as e:
138
  return {"escalated": True, "escalation_reason": f"Winner failed to answer: {str(e)[:150]}"}
139
  if not resp.content.strip():
 
148
  return {"draft_answer": resp.content, "usages": state["usages"] + [usage]}
149
 
150
 
151
+ # Leaked chain-of-thought in a "final" answer means the model was struggling;
152
+ # the verifier should never pass it even if the end value happens to be right.
153
+ _THINKING_ARTIFACTS = re.compile(
154
+ r"(?im)^\s*(wait|hmm+|hold on)\b"
155
+ r"|\b(wait,? (?:no|but|that)|hmm+,|let me (?:recalculate|recheck|reconsider|try again|start over)"
156
+ r"|actually,? (?:no|wait|that'?s (?:wrong|not right))|scratch that|i made an? (?:error|mistake))\b"
157
+ )
158
+
159
+
160
  async def verify(state: RouterState) -> RouterState:
161
  try:
162
  resp = await chat(VERIFIER_MODEL, prompts.VERIFY_SYSTEM,
163
+ prompts.verify_user(state["query"], state["draft_answer"],
164
+ state.get("history")),
165
+ reasoning_effort="medium")
166
  data = extract_json(resp.content)
167
  score = _clamp(data.get("score", 0))
168
+ # Enforce score = min(subscores) server-side; models sometimes
169
+ # report an optimistic overall despite a low dimension
170
+ subscores = [_clamp(data[k]) for k in
171
+ ("correctness", "completeness", "commitment", "presentation")
172
+ if k in data]
173
+ if subscores:
174
+ score = min(score, *subscores)
175
  verification = Verification(
176
  score=score,
177
+ # our (possibly stricter) score overrides the model's own verdict
178
+ passed=score >= settings.verification_threshold
179
+ and bool(data.get("pass", True)),
180
  feedback=str(data.get("feedback", ""))[:500],
181
  )
182
  except (LLMError, ValueError) as e:
 
185
  feedback=f"Verifier error: {str(e)[:150]}")
186
  resp = None
187
 
188
+ # Deterministic guard: cap the score when the draft contains
189
+ # thinking-out-loud artifacts, independent of the verifier's judgment
190
+ artifacts = _THINKING_ARTIFACTS.findall(state["draft_answer"] or "")
191
+ if artifacts and verification.score > 0.5:
192
+ verification = Verification(
193
+ score=0.5,
194
+ passed=False,
195
+ feedback="Draft contains unresolved reasoning artifacts "
196
+ "(thinking out loud / self-corrections). "
197
+ + verification.feedback,
198
+ )
199
+
200
  usages = state["usages"]
201
  if resp is not None:
202
  usages = usages + [Usage(
 
217
 
218
  async def escalate(state: RouterState) -> RouterState:
219
  try:
220
+ resp = await chat(TIER2_MODEL, prompts.FRONTIER_SYSTEM, state["query"],
221
  max_tokens=settings.max_frontier_tokens,
222
+ reasoning_effort=settings.frontier_reasoning_effort,
223
+ history=state.get("history"))
224
  if not resp.content.strip():
225
  raise LLMError(f"{TIER2_MODEL.openrouter_id}: empty response "
226
  "(reasoning consumed the token budget)")
 
301
  return _graph
302
 
303
 
304
+ def _make_run(query: str, final: dict, start: float) -> RunResult:
 
 
 
 
305
  usages = final.get("usages", [])
306
  total_cost = sum(u.cost_usd for u in usages)
307
  # Baseline: the same in/out volume sent straight to the frontier model
 
310
  baseline_cost = BASELINE_MODEL.estimate_cost(
311
  max(answer_tokens_in, 100), max(answer_tokens_out, 300))
312
 
313
+ return RunResult(
314
  id=uuid.uuid4().hex[:12],
315
  query=query,
316
  answer=final.get("final_answer", ""),
 
327
  baseline_cost_usd=round(baseline_cost, 6),
328
  latency_ms=int((time.monotonic() - start) * 1000),
329
  )
330
+
331
+
332
+ def _trim_history(history: list[dict] | None) -> list[dict]:
333
+ """Answer-level cap: most recent turns, per-turn char truncation."""
334
+ turns = (history or [])[-settings.history_max_turns_answer:]
335
+ per_turn = settings.history_max_chars_answer // max(len(turns), 1)
336
+ return [{"role": t["role"], "content": t["content"][:max(per_turn, 500)]}
337
+ for t in turns]
338
+
339
+
340
+ async def run_query(query: str, history: list[dict] | None = None) -> RunResult:
341
+ start = time.monotonic()
342
+ state: RouterState = {"query": query, "history": _trim_history(history),
343
+ "usages": [], "escalated": False}
344
+ final = await get_graph().ainvoke(state)
345
+ run = _make_run(query, final, start)
346
  await get_store().save_run(run)
347
  return run
348
+
349
+
350
+ async def run_query_stream(query: str, history: list[dict] | None = None):
351
+ """Streaming twin of the LangGraph pipeline.
352
+
353
+ Reuses the same node functions but drives them imperatively so token
354
+ deltas and stage transitions can be pushed to the client as they happen.
355
+ Yields JSON-serializable event dicts; ends with {"type": "done", "run": ...}.
356
+ """
357
+ from .llm import chat_stream
358
+
359
+ start = time.monotonic()
360
+ state: dict = {"query": query, "history": _trim_history(history),
361
+ "usages": [], "escalated": False}
362
+
363
+ yield {"type": "stage", "stage": "bidding"}
364
+ state.update(await bid_collection(state))
365
+ state.update(await auction(state))
366
+ yield {
367
+ "type": "auction",
368
+ "bids": [b.model_dump() for b in state["bids"]],
369
+ "winner": state.get("winner"),
370
+ "escalated": state.get("escalated", False),
371
+ "reason": state.get("escalation_reason"),
372
+ }
373
+
374
+ if not state.get("escalated"):
375
+ spec = TIER1_MODELS[state["winner"]]
376
+ yield {"type": "stage", "stage": "drafting", "model": spec.display_name}
377
+ try:
378
+ # Draft tokens are NOT forwarded to the client: the draft isn't
379
+ # final until verification passes, and streaming text that later
380
+ # gets replaced by the frontier answer is a confusing UX.
381
+ resp = None
382
+ async for ev in chat_stream(spec, prompts.ANSWER_SYSTEM, query,
383
+ history=state["history"]):
384
+ if ev["type"] == "final":
385
+ resp = ev["response"]
386
+ if resp is None or not resp.content.strip():
387
+ state["escalated"] = True
388
+ state["escalation_reason"] = f"{spec.display_name} returned an empty draft"
389
+ else:
390
+ state["draft_answer"] = resp.content
391
+ state["usages"] = state["usages"] + [Usage(
392
+ model_key=spec.key, model_name=spec.display_name, stage="draft",
393
+ tokens_in=resp.tokens_in, tokens_out=resp.tokens_out,
394
+ cost_usd=spec.estimate_cost(resp.tokens_in, resp.tokens_out,
395
+ resp.served_model),
396
+ latency_ms=resp.latency_ms,
397
+ )]
398
+ except LLMError as e:
399
+ state["escalated"] = True
400
+ state["escalation_reason"] = f"Winner failed to answer: {str(e)[:150]}"
401
+
402
+ if state.get("draft_answer"):
403
+ yield {"type": "stage", "stage": "verifying"}
404
+ state.update(await verify(state))
405
+ yield {
406
+ "type": "verification",
407
+ **state["verification"].model_dump(),
408
+ "escalated": state.get("escalated", False),
409
+ "reason": state.get("escalation_reason"),
410
+ }
411
+ if not state.get("escalated"):
412
+ # Draft is now verified-final: stream it to the client in
413
+ # chunks (it was generated silently during the draft stage)
414
+ yield {"type": "stage", "stage": "delivering",
415
+ "model": spec.display_name}
416
+ text = state["draft_answer"]
417
+ step = 80
418
+ for i in range(0, len(text), step):
419
+ yield {"type": "token", "text": text[i:i + step]}
420
+ await asyncio.sleep(0.02)
421
+
422
+ if state.get("escalated"):
423
+ yield {"type": "stage", "stage": "escalating",
424
+ "model": TIER2_MODEL.display_name,
425
+ "reason": state.get("escalation_reason")}
426
+ try:
427
+ resp = None
428
+ async for ev in chat_stream(TIER2_MODEL, prompts.FRONTIER_SYSTEM, query,
429
+ max_tokens=settings.max_frontier_tokens,
430
+ reasoning_effort=settings.frontier_reasoning_effort,
431
+ history=state["history"]):
432
+ if ev["type"] == "delta":
433
+ yield {"type": "token", "text": ev["text"]}
434
+ else:
435
+ resp = ev["response"]
436
+ if resp is None or not resp.content.strip():
437
+ raise LLMError(f"{TIER2_MODEL.openrouter_id}: empty response")
438
+ state["final_answer"] = resp.content
439
+ state["answered_by"] = TIER2_MODEL.display_name
440
+ state["tier"] = 2
441
+ state["usages"] = state["usages"] + [Usage(
442
+ model_key=TIER2_MODEL.key, model_name=TIER2_MODEL.display_name,
443
+ stage="escalate", tokens_in=resp.tokens_in, tokens_out=resp.tokens_out,
444
+ cost_usd=TIER2_MODEL.estimate_cost(resp.tokens_in, resp.tokens_out,
445
+ resp.served_model),
446
+ latency_ms=resp.latency_ms,
447
+ )]
448
+ except LLMError as e:
449
+ if state.get("draft_answer"):
450
+ spec = TIER1_MODELS[state["winner"]]
451
+ state["final_answer"] = state["draft_answer"]
452
+ state["answered_by"] = f"{spec.display_name} (frontier unavailable)"
453
+ state["tier"] = 1
454
+ state["escalation_reason"] = (state.get("escalation_reason") or "") \
455
+ + f" | frontier failed: {str(e)[:150]}"
456
+ yield {"type": "frontier_failed", "reason": str(e)[:200]}
457
+ else:
458
+ yield {"type": "error", "message": str(e)[:300]}
459
+ return
460
+ else:
461
+ state.update(await finalize(state))
462
+
463
+ run = _make_run(query, state, start)
464
+ await get_store().save_run(run)
465
+ yield {"type": "done", "run": run.model_dump(mode="json")}
backend/app/prompts.py CHANGED
@@ -1,39 +1,113 @@
1
  """Prompt templates for bidding, answering, and verification."""
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  BID_SYSTEM = """You are a bidding agent for a specific language model competing in an \
4
  auction to answer a user query. Assess honestly how well YOUR model would handle the \
5
  query. Overbidding hurts you: your answer will be checked by an independent verifier, \
6
  and failures lower your historical accuracy in future auctions.
7
 
 
 
 
 
 
 
 
 
 
8
  Respond with ONLY a JSON object:
9
  {"confidence": <0.0-1.0, probability you produce a correct and complete answer>,
10
  "estimated_difficulty": <0.0-1.0, how hard this query is for any model>,
11
  "reason": "<one short sentence explaining your bid>"}"""
12
 
13
 
14
- def bid_user(query: str) -> str:
15
- return f"User query to bid on:\n\n{query}"
 
 
 
 
 
 
 
 
16
 
17
 
18
  ANSWER_SYSTEM = """You are a helpful expert assistant. Answer the user's query \
19
  accurately and completely. Be concise. If you are unsure about a fact, say so \
20
  rather than guessing."""
21
 
 
 
 
 
 
 
 
 
22
 
23
  VERIFY_SYSTEM = """You are a strict answer verifier. You will receive a user question \
24
- and a candidate answer produced by another model. Evaluate:
25
- 1. Correctness — are the facts and logic right?
26
- 2. Completeness does it fully address the question?
27
- 3. Reasoning quality is the reasoning sound?
28
- 4. Hallucination risk — any invented facts, citations, or details?
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- Be skeptical. A confident-sounding wrong answer must fail.
 
31
 
32
  Respond with ONLY a JSON object:
33
- {"score": <0.0-1.0 overall quality>,
34
- "pass": <true if score >= 0.80 and no hallucinations detected>,
35
- "feedback": "<one or two sentences justifying the score>"}"""
 
 
36
 
37
 
38
- def verify_user(query: str, answer: str) -> str:
39
- return f"QUESTION:\n{query}\n\nCANDIDATE ANSWER:\n{answer}"
 
 
 
 
 
1
  """Prompt templates for bidding, answering, and verification."""
2
 
3
+
4
+ def format_history(history: list[dict], max_turns: int, max_chars: int) -> str:
5
+ """Render recent conversation turns as a compact transcript.
6
+
7
+ Takes the most recent `max_turns` turns, truncates long turns so the
8
+ whole transcript fits in `max_chars`, and returns "" for no history.
9
+ """
10
+ turns = history[-max_turns:] if history else []
11
+ if not turns:
12
+ return ""
13
+ per_turn = max(200, max_chars // len(turns))
14
+ lines = []
15
+ for t in turns:
16
+ content = t["content"]
17
+ if len(content) > per_turn:
18
+ content = content[:per_turn] + " […truncated]"
19
+ lines.append(f"{t['role'].upper()}: {content}")
20
+ return "\n".join(lines)[:max_chars]
21
+
22
  BID_SYSTEM = """You are a bidding agent for a specific language model competing in an \
23
  auction to answer a user query. Assess honestly how well YOUR model would handle the \
24
  query. Overbidding hurts you: your answer will be checked by an independent verifier, \
25
  and failures lower your historical accuracy in future auctions.
26
 
27
+ A conversation transcript may precede the query; you are bidding on answering \
28
+ the LATEST user message in that conversation, which may depend on the context.
29
+
30
+ Calibration rules — you will be given your model's profile:
31
+ - Bid 0.9+ ONLY if the query is squarely within your stated strengths.
32
+ - If the query is outside your profile, bid 0.6 or lower even if you could \
33
+ probably produce a passable answer — another specialist will do it better.
34
+ - If the query is genuinely hard for any model of your size, bid below 0.5.
35
+
36
  Respond with ONLY a JSON object:
37
  {"confidence": <0.0-1.0, probability you produce a correct and complete answer>,
38
  "estimated_difficulty": <0.0-1.0, how hard this query is for any model>,
39
  "reason": "<one short sentence explaining your bid>"}"""
40
 
41
 
42
+ def bid_user(query: str, history: list[dict] | None = None,
43
+ specialty: str = "") -> str:
44
+ from .config import settings
45
+ profile = f"YOUR MODEL'S PROFILE: {specialty}\n\n" if specialty else ""
46
+ transcript = format_history(history or [], settings.history_max_turns_bid,
47
+ settings.history_max_chars_bid)
48
+ if transcript:
49
+ return (f"{profile}CONVERSATION SO FAR:\n{transcript}\n\n"
50
+ f"LATEST user message to bid on:\n\n{query}")
51
+ return f"{profile}User query to bid on:\n\n{query}"
52
 
53
 
54
  ANSWER_SYSTEM = """You are a helpful expert assistant. Answer the user's query \
55
  accurately and completely. Be concise. If you are unsure about a fact, say so \
56
  rather than guessing."""
57
 
58
+ # Escalated queries are the hard ones — the frontier model should show its
59
+ # work rather than compress
60
+ FRONTIER_SYSTEM = """You are an expert assistant handling a question that \
61
+ smaller models could not answer reliably. Give a thorough, well-structured \
62
+ answer: show the key reasoning steps, state the final result clearly, and \
63
+ note any assumptions. Prefer completeness over brevity, but do not pad. If \
64
+ you are unsure about a fact, say so rather than guessing."""
65
+
66
 
67
  VERIFY_SYSTEM = """You are a strict answer verifier. You will receive a user question \
68
+ and a candidate answer produced by another model.
69
+
70
+ A conversation transcript may precede the question; the answer must make sense \
71
+ as a reply to the LATEST question in that context. An answer that ignores the \
72
+ established context fails completeness.
73
+
74
+ Step 1 — before reading the answer, list what the question actually demands: \
75
+ every specific quantity, proof, or conclusion it asks for. The hardest \
76
+ sub-question is the one that matters most.
77
+
78
+ Step 2 — grade the answer against that list on four dimensions, each 0.0-1.0:
79
+ - correctness: are the facts and logic right? Verify calculations yourself.
80
+ - completeness: is every demand met? An answer that covers easy parts but \
81
+ never delivers the hard part (e.g. a section titled with the question that \
82
+ never states the result) scores LOW here, no matter how polished it looks.
83
+ - commitment: does it state results plainly and prove them? Hedging \
84
+ ("provided we are clever", "this is equivalent, however..."), restating the \
85
+ question instead of answering it, or never landing on a final result is a \
86
+ failure of commitment.
87
+ - presentation: leaked thinking-out-loud ("Wait...", "Hmm...", "let me \
88
+ recalculate", abandoned attempts, self-contradictions, conflicting final \
89
+ values) caps this at 0.5.
90
+
91
+ The overall score is the MINIMUM of the four — an answer is only as good as \
92
+ its weakest dimension. Calibration: 0.9+ means an expert would sign off on it \
93
+ as complete and correct; 0.7 means right but with real gaps; 0.5 means \
94
+ correct skeleton, core question not actually answered; below 0.3 means wrong \
95
+ or off-topic.
96
 
97
+ Be skeptical. Confident-sounding and shallow must fail. Correct-but-evasive \
98
+ must fail.
99
 
100
  Respond with ONLY a JSON object:
101
+ {"correctness": <0.0-1.0>, "completeness": <0.0-1.0>, "commitment": <0.0-1.0>,
102
+ "presentation": <0.0-1.0>,
103
+ "score": <minimum of the four>,
104
+ "pass": <true if score >= 0.80>,
105
+ "feedback": "<two or three sentences: what the question demanded, what was missing or wrong>"}"""
106
 
107
 
108
+ def verify_user(query: str, answer: str, history: list[dict] | None = None) -> str:
109
+ from .config import settings
110
+ transcript = format_history(history or [], settings.history_max_turns_verify,
111
+ settings.history_max_chars_verify)
112
+ prefix = f"CONVERSATION SO FAR:\n{transcript}\n\n" if transcript else ""
113
+ return f"{prefix}QUESTION:\n{query}\n\nCANDIDATE ANSWER:\n{answer}"
backend/app/schemas.py CHANGED
@@ -6,8 +6,14 @@ from typing import Literal, Optional
6
  from pydantic import BaseModel, Field
7
 
8
 
 
 
 
 
 
9
  class QueryRequest(BaseModel):
10
  query: str = Field(min_length=1, max_length=8000)
 
11
 
12
 
13
  class Bid(BaseModel):
 
6
  from pydantic import BaseModel, Field
7
 
8
 
9
+ class ChatTurn(BaseModel):
10
+ role: Literal["user", "assistant"]
11
+ content: str = Field(max_length=8000)
12
+
13
+
14
  class QueryRequest(BaseModel):
15
  query: str = Field(min_length=1, max_length=8000)
16
+ history: list[ChatTurn] = Field(default=[], max_length=20)
17
 
18
 
19
  class Bid(BaseModel):
frontend/components/BidArcade.tsx CHANGED
@@ -8,7 +8,7 @@ import { useEffect, useState } from "react";
8
  const BOTS = [
9
  { name: "GEMMA", color: "#fb923c", dark: "#9a3412" },
10
  { name: "DEEPSEEK", color: "#38bdf8", dark: "#075985" },
11
- { name: "QWEN", color: "#a3e635", dark: "#3f6212" },
12
  ];
13
 
14
  // 8x8 robot sprite: 0 empty, 1 body, 2 eye, 3 antenna
 
8
  const BOTS = [
9
  { name: "GEMMA", color: "#fb923c", dark: "#9a3412" },
10
  { name: "DEEPSEEK", color: "#38bdf8", dark: "#075985" },
11
+ { name: "QWEN-CODER", color: "#a3e635", dark: "#3f6212" },
12
  ];
13
 
14
  // 8x8 robot sprite: 0 empty, 1 body, 2 eye, 3 antenna
frontend/components/Chat.tsx CHANGED
@@ -1,8 +1,8 @@
1
  "use client";
2
 
3
  import { useRef, useState } from "react";
4
- import { submitQuery } from "@/lib/api";
5
- import type { RunResult } from "@/lib/types";
6
  import { Badge } from "./ui";
7
  import { Markdown } from "./Markdown";
8
 
@@ -12,6 +12,35 @@ interface ChatMessage {
12
  run?: RunResult;
13
  }
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  export function Chat({
16
  onRun,
17
  selectedRunId,
@@ -23,32 +52,85 @@ export function Chat({
23
  }) {
24
  const [messages, setMessages] = useState<ChatMessage[]>([]);
25
  const [input, setInput] = useState("");
26
- const [busy, setBusy] = useState(false);
27
  const bottomRef = useRef<HTMLDivElement>(null);
28
 
 
 
 
29
  async function send() {
30
  const query = input.trim();
31
- if (!query || busy) return;
 
 
 
 
 
 
 
 
32
  setInput("");
33
- setBusy(true);
34
  setMessages((m) => [...m, { role: "user", text: query }]);
35
- setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: "smooth" }), 0);
 
36
  try {
37
- const run = await submitQuery(query);
38
- setMessages((m) => [...m, { role: "assistant", text: run.answer, run }]);
39
- onRun(run);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  } catch (e) {
41
  setMessages((m) => [...m, { role: "error", text: String(e) }]);
42
  } finally {
43
- setBusy(false);
44
- setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: "smooth" }), 0);
45
  }
46
  }
47
 
48
  return (
49
  <div className="flex h-full flex-col">
50
  <div className="flex-1 space-y-4 overflow-y-auto pr-2">
51
- {messages.length === 0 && (
52
  <div className="flex h-full items-center justify-center">
53
  <div className="max-w-md border-2 border-dashed border-stone-700 p-5 text-center text-stone-500">
54
  <div className="mb-2 font-[family-name:var(--font-pixel)] text-[10px] text-orange-500">
@@ -77,9 +159,9 @@ export function Chat({
77
  </div>
78
  ) : (
79
  <div key={i} className="flex justify-start">
80
- <button
81
  onClick={() => msg.run && onSelectRun(msg.run)}
82
- className={`max-w-[85%] cursor-pointer border-2 px-3 py-2 text-left shadow-[3px_3px_0_0_#000] ${
83
  msg.run && msg.run.id === selectedRunId
84
  ? "border-orange-500 bg-stone-900"
85
  : "border-stone-700 bg-stone-950 hover:border-stone-500"
@@ -94,20 +176,34 @@ export function Chat({
94
  ${msg.run?.total_cost_usd.toFixed(5)} ·{" "}
95
  {((msg.run?.latency_ms ?? 0) / 1000).toFixed(1)}s
96
  </span>
 
97
  </div>
98
  <div className="text-stone-200">
99
  <Markdown>{msg.text}</Markdown>
100
  </div>
101
- </button>
102
  </div>
103
  ),
104
  )}
105
- {busy && (
106
- <div className="flex items-center gap-2 text-orange-500">
107
- <span className="blink">▓</span>
108
- <span className="text-sm text-stone-400">
109
- auction → draft → verify…
110
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  </div>
112
  )}
113
  <div ref={bottomRef} />
@@ -129,7 +225,7 @@ export function Chat({
129
  />
130
  <button
131
  onClick={send}
132
- disabled={busy || !input.trim()}
133
  className="pixel-btn bg-orange-950 px-5 font-[family-name:var(--font-pixel)] text-[10px] uppercase text-orange-400 disabled:cursor-not-allowed disabled:text-stone-600"
134
  >
135
  send
 
1
  "use client";
2
 
3
  import { useRef, useState } from "react";
4
+ import { streamQuery } from "@/lib/api";
5
+ import type { ChatTurn, RunResult } from "@/lib/types";
6
  import { Badge } from "./ui";
7
  import { Markdown } from "./Markdown";
8
 
 
12
  run?: RunResult;
13
  }
14
 
15
+ interface LiveState {
16
+ status: string;
17
+ text: string;
18
+ escalating: boolean;
19
+ }
20
+
21
+ function CopyButton({ text }: { text: string }) {
22
+ const [copied, setCopied] = useState(false);
23
+ return (
24
+ <button
25
+ onClick={(e) => {
26
+ e.stopPropagation();
27
+ navigator.clipboard.writeText(text).then(() => {
28
+ setCopied(true);
29
+ setTimeout(() => setCopied(false), 1500);
30
+ });
31
+ }}
32
+ className={`ml-auto border px-1.5 font-[family-name:var(--font-pixel)] text-[7px] uppercase leading-4 ${
33
+ copied
34
+ ? "border-green-600 text-green-400"
35
+ : "border-stone-600 text-stone-500 hover:border-orange-500 hover:text-orange-400"
36
+ }`}
37
+ title="copy answer"
38
+ >
39
+ {copied ? "✓ copied" : "copy"}
40
+ </button>
41
+ );
42
+ }
43
+
44
  export function Chat({
45
  onRun,
46
  selectedRunId,
 
52
  }) {
53
  const [messages, setMessages] = useState<ChatMessage[]>([]);
54
  const [input, setInput] = useState("");
55
+ const [live, setLive] = useState<LiveState | null>(null);
56
  const bottomRef = useRef<HTMLDivElement>(null);
57
 
58
+ const scroll = () =>
59
+ setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: "smooth" }), 0);
60
+
61
  async function send() {
62
  const query = input.trim();
63
+ if (!query || live) return;
64
+ // Prior turns (excluding errors) give the pipeline conversation context
65
+ const history: ChatTurn[] = messages
66
+ .filter((m) => m.role !== "error")
67
+ .slice(-12)
68
+ .map((m) => ({
69
+ role: m.role as "user" | "assistant",
70
+ content: m.text.slice(0, 8000),
71
+ }));
72
  setInput("");
 
73
  setMessages((m) => [...m, { role: "user", text: query }]);
74
+ setLive({ status: "⚡ AUCTION IN PROGRESS…", text: "", escalating: false });
75
+ scroll();
76
  try {
77
+ await streamQuery(query, history, (ev) => {
78
+ switch (ev.type) {
79
+ case "stage":
80
+ if (ev.stage === "bidding")
81
+ setLive((l) => l && { ...l, status: "⚡ AUCTION IN PROGRESS…" });
82
+ else if (ev.stage === "drafting")
83
+ setLive((l) => l && { ...l, status: `✍ ${ev.model} DRAFTING…` });
84
+ else if (ev.stage === "verifying")
85
+ setLive((l) => l && { ...l, status: "🔍 VERIFIER JUDGING…" });
86
+ else if (ev.stage === "delivering")
87
+ setLive((l) => l && { ...l, status: `✓ VERIFIED — ${ev.model}` });
88
+ else if (ev.stage === "escalating")
89
+ // frontier rewrites from scratch: clear the failed draft
90
+ setLive((l) => l && {
91
+ status: `⚔ BOSS FIGHT: ${ev.model}…`,
92
+ text: "",
93
+ escalating: true,
94
+ });
95
+ break;
96
+ case "token":
97
+ setLive((l) => l && { ...l, text: l.text + (ev.text ?? "") });
98
+ break;
99
+ case "verification":
100
+ if (!ev.passed)
101
+ setLive((l) => l && {
102
+ ...l,
103
+ status: `✖ VERIFICATION FAILED (${ev.score?.toFixed(2)})`,
104
+ });
105
+ break;
106
+ case "frontier_failed":
107
+ setLive((l) => l && { ...l, status: "⚠ FRONTIER UNAVAILABLE — USING DRAFT" });
108
+ break;
109
+ case "error":
110
+ throw new Error(ev.message);
111
+ case "done":
112
+ if (ev.run) {
113
+ const run = ev.run;
114
+ setMessages((m) => [...m, { role: "assistant", text: run.answer, run }]);
115
+ onRun(run);
116
+ }
117
+ setLive(null);
118
+ scroll();
119
+ break;
120
+ }
121
+ });
122
  } catch (e) {
123
  setMessages((m) => [...m, { role: "error", text: String(e) }]);
124
  } finally {
125
+ setLive(null);
126
+ scroll();
127
  }
128
  }
129
 
130
  return (
131
  <div className="flex h-full flex-col">
132
  <div className="flex-1 space-y-4 overflow-y-auto pr-2">
133
+ {messages.length === 0 && !live && (
134
  <div className="flex h-full items-center justify-center">
135
  <div className="max-w-md border-2 border-dashed border-stone-700 p-5 text-center text-stone-500">
136
  <div className="mb-2 font-[family-name:var(--font-pixel)] text-[10px] text-orange-500">
 
159
  </div>
160
  ) : (
161
  <div key={i} className="flex justify-start">
162
+ <div
163
  onClick={() => msg.run && onSelectRun(msg.run)}
164
+ className={`max-w-[85%] cursor-pointer select-text border-2 px-3 py-2 text-left shadow-[3px_3px_0_0_#000] ${
165
  msg.run && msg.run.id === selectedRunId
166
  ? "border-orange-500 bg-stone-900"
167
  : "border-stone-700 bg-stone-950 hover:border-stone-500"
 
176
  ${msg.run?.total_cost_usd.toFixed(5)} ·{" "}
177
  {((msg.run?.latency_ms ?? 0) / 1000).toFixed(1)}s
178
  </span>
179
+ <CopyButton text={msg.text} />
180
  </div>
181
  <div className="text-stone-200">
182
  <Markdown>{msg.text}</Markdown>
183
  </div>
184
+ </div>
185
  </div>
186
  ),
187
  )}
188
+ {live && (
189
+ <div className="flex justify-start">
190
+ <div
191
+ className={`max-w-[85%] border-2 px-3 py-2 shadow-[3px_3px_0_0_#000] ${
192
+ live.escalating
193
+ ? "border-orange-600 bg-orange-950/20"
194
+ : "border-stone-700 bg-stone-950"
195
+ }`}
196
+ >
197
+ <div className="mb-1.5 flex items-center gap-2 font-[family-name:var(--font-pixel)] text-[8px] text-orange-400">
198
+ <span className="blink">▓</span>
199
+ {live.status}
200
+ </div>
201
+ {live.text && (
202
+ <div className="text-stone-200">
203
+ <Markdown>{live.text}</Markdown>
204
+ </div>
205
+ )}
206
+ </div>
207
  </div>
208
  )}
209
  <div ref={bottomRef} />
 
225
  />
226
  <button
227
  onClick={send}
228
+ disabled={!!live || !input.trim()}
229
  className="pixel-btn bg-orange-950 px-5 font-[family-name:var(--font-pixel)] text-[10px] uppercase text-orange-400 disabled:cursor-not-allowed disabled:text-stone-600"
230
  >
231
  send
frontend/components/MetricsDashboard.tsx CHANGED
@@ -6,9 +6,9 @@ import type { MetricsSummary } from "@/lib/types";
6
  import { Bar, Card, Stat } from "./ui";
7
 
8
  const MODEL_LABELS: Record<string, string> = {
9
- gemma: "Gemma 4 26B",
10
- deepseek: "DeepSeek",
11
- qwen: "Qwen3 Next 80B",
12
  };
13
 
14
  export function MetricsDashboard({ refreshKey }: { refreshKey: number }) {
 
6
  import { Bar, Card, Stat } from "./ui";
7
 
8
  const MODEL_LABELS: Record<string, string> = {
9
+ gemini: "Gemma 4 (general)",
10
+ deepseek: "DeepSeek (math)",
11
+ qwen: "Qwen3 Coder (code)",
12
  };
13
 
14
  export function MetricsDashboard({ refreshKey }: { refreshKey: number }) {
frontend/lib/api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { MetricsSummary, RunResult } from "./types";
2
 
3
  // Same origin in production (FastAPI serves the static export);
4
  // the local backend during `next dev`.
@@ -15,14 +15,63 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
15
  return res.json();
16
  }
17
 
18
- export function submitQuery(query: string): Promise<RunResult> {
 
 
 
19
  return request<RunResult>("/api/query", {
20
  method: "POST",
21
  headers: { "Content-Type": "application/json" },
22
- body: JSON.stringify({ query }),
23
  });
24
  }
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  export function fetchMetrics(): Promise<MetricsSummary> {
27
  return request<MetricsSummary>("/api/metrics");
28
  }
 
1
+ import type { ChatTurn, MetricsSummary, RunResult } from "./types";
2
 
3
  // Same origin in production (FastAPI serves the static export);
4
  // the local backend during `next dev`.
 
15
  return res.json();
16
  }
17
 
18
+ export function submitQuery(
19
+ query: string,
20
+ history: ChatTurn[] = [],
21
+ ): Promise<RunResult> {
22
  return request<RunResult>("/api/query", {
23
  method: "POST",
24
  headers: { "Content-Type": "application/json" },
25
+ body: JSON.stringify({ query, history }),
26
  });
27
  }
28
 
29
+ export interface StreamEvent {
30
+ type: "stage" | "auction" | "token" | "verification" | "frontier_failed" | "error" | "done";
31
+ stage?: "bidding" | "drafting" | "verifying" | "delivering" | "escalating";
32
+ model?: string;
33
+ text?: string;
34
+ reason?: string | null;
35
+ message?: string;
36
+ score?: number;
37
+ passed?: boolean;
38
+ feedback?: string;
39
+ winner?: string | null;
40
+ escalated?: boolean;
41
+ run?: RunResult;
42
+ }
43
+
44
+ // POST + NDJSON reader (EventSource is GET-only)
45
+ export async function streamQuery(
46
+ query: string,
47
+ history: ChatTurn[],
48
+ onEvent: (ev: StreamEvent) => void,
49
+ ): Promise<void> {
50
+ const res = await fetch(`${API_BASE}/api/query/stream`, {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify({ query, history }),
54
+ });
55
+ if (!res.ok || !res.body) {
56
+ const body = await res.text().catch(() => "");
57
+ throw new Error(`${res.status}: ${body.slice(0, 300)}`);
58
+ }
59
+ const reader = res.body.getReader();
60
+ const decoder = new TextDecoder();
61
+ let buffer = "";
62
+ for (;;) {
63
+ const { done, value } = await reader.read();
64
+ if (done) break;
65
+ buffer += decoder.decode(value, { stream: true });
66
+ let nl;
67
+ while ((nl = buffer.indexOf("\n")) >= 0) {
68
+ const line = buffer.slice(0, nl).trim();
69
+ buffer = buffer.slice(nl + 1);
70
+ if (line) onEvent(JSON.parse(line) as StreamEvent);
71
+ }
72
+ }
73
+ }
74
+
75
  export function fetchMetrics(): Promise<MetricsSummary> {
76
  return request<MetricsSummary>("/api/metrics");
77
  }
frontend/lib/types.ts CHANGED
@@ -1,3 +1,8 @@
 
 
 
 
 
1
  export interface Bid {
2
  model_key: string;
3
  model_name: string;
 
1
+ export interface ChatTurn {
2
+ role: "user" | "assistant";
3
+ content: string;
4
+ }
5
+
6
  export interface Bid {
7
  model_key: string;
8
  model_name: string;