Spaces:
Running
Running
Commit ·
001f1df
1
Parent(s): 6b3d40b
checkpoint: current behavior before latency work
Browse files- backend/app/config.py +59 -23
- backend/app/llm.py +96 -6
- backend/app/main.py +23 -2
- backend/app/pipeline.py +198 -20
- backend/app/prompts.py +87 -13
- backend/app/schemas.py +6 -0
- frontend/components/BidArcade.tsx +1 -1
- frontend/components/Chat.tsx +118 -22
- frontend/components/MetricsDashboard.tsx +3 -3
- frontend/lib/api.ts +52 -3
- frontend/lib/types.ts +5 -0
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 |
-
#
|
| 19 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
self.cost_per_mtok_in = cost_per_mtok_in
|
| 21 |
self.cost_per_mtok_out = cost_per_mtok_out
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 31 |
-
|
|
|
|
|
|
|
| 32 |
openrouter_id="google/gemma-4-26b-a4b-it:free",
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
),
|
| 53 |
}
|
| 54 |
|
| 55 |
# --- Verifier ---------------------------------------------------------------
|
| 56 |
VERIFIER_MODEL = ModelSpec(
|
| 57 |
key="verifier",
|
| 58 |
-
openrouter_id="
|
| 59 |
-
display_name="
|
| 60 |
-
cost_per_mtok_in=0.
|
| 61 |
-
cost_per_mtok_out=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
|
| 109 |
-
max_frontier_tokens: int =
|
|
|
|
| 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,
|
|
|
|
| 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
|
|
|
|
| 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,
|
|
|
|
| 46 |
spec = TIER1_MODELS[model_key]
|
| 47 |
-
hist =
|
| 48 |
try:
|
| 49 |
-
resp = await chat(spec, prompts.BID_SYSTEM,
|
|
|
|
| 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 |
-
|
| 76 |
results = await asyncio.gather(
|
| 77 |
-
*(_get_bid(key, state["query"], history
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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.
|
| 174 |
max_tokens=settings.max_frontier_tokens,
|
| 175 |
-
reasoning_effort=
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
Be skeptical.
|
|
|
|
| 31 |
|
| 32 |
Respond with ONLY a JSON object:
|
| 33 |
-
{"
|
| 34 |
-
"
|
| 35 |
-
"
|
|
|
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
-
def verify_user(query: str, answer: str) -> str:
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 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 [
|
| 27 |
const bottomRef = useRef<HTMLDivElement>(null);
|
| 28 |
|
|
|
|
|
|
|
|
|
|
| 29 |
async function send() {
|
| 30 |
const query = input.trim();
|
| 31 |
-
if (!query ||
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
setInput("");
|
| 33 |
-
setBusy(true);
|
| 34 |
setMessages((m) => [...m, { role: "user", text: query }]);
|
| 35 |
-
|
|
|
|
| 36 |
try {
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
} catch (e) {
|
| 41 |
setMessages((m) => [...m, { role: "error", text: String(e) }]);
|
| 42 |
} finally {
|
| 43 |
-
|
| 44 |
-
|
| 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 |
-
<
|
| 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 |
-
</
|
| 102 |
</div>
|
| 103 |
),
|
| 104 |
)}
|
| 105 |
-
{
|
| 106 |
-
<div className="flex
|
| 107 |
-
<
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
</div>
|
| 112 |
)}
|
| 113 |
<div ref={bottomRef} />
|
|
@@ -129,7 +225,7 @@ export function Chat({
|
|
| 129 |
/>
|
| 130 |
<button
|
| 131 |
onClick={send}
|
| 132 |
-
disabled={
|
| 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 |
-
|
| 10 |
-
deepseek: "DeepSeek",
|
| 11 |
-
qwen: "Qwen3
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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;
|