dakshtaneja Claude Opus 5 commited on
Commit
567b94e
·
1 Parent(s): 4546097

pipeline: Granite web-search gate, racing the hedge

Browse files

A tiny model (ibm-granite/granite-4.1-8b) answers "does this need a web
search?" at t=0, OR'd into the existing regex and bidder signals.

Motivation is recall, not speed. The regex scores 100% on its own test
file but 62.5% on held-out phrasing -- it misses ordinary questions like
"did the fed cut rates" and "what did apple announce". The gate model
scored 100% on the same held-out set (evals/bench_web_gate.py).

It runs in PARALLEL with the hedge, not before it. The gate's TTFT
(~450-500ms measured) is about the same as the hedge model's, so putting
it on the critical path would roughly double time-to-first-token on the
majority of queries, which need no search at all. Racing instead, a
"yes" cancels the hedge ~500ms in rather than at auction time (2-4s),
so the user sees far less doomed text before the reset.

Fails open: an outage or unparseable reply returns None, which callers
treat as "no opinion", so the regex and bidder flags still decide. Cost
is ~$0.000007/query, tracked as a new "gate" usage stage.

Verified live: all three regex misses now search, non-web queries are
untouched. A/B on time-to-first-token for non-web queries was
inconclusive -- variance (469-4040ms) dwarfs any gate effect at n=6, so
"no measurable regression" is as strong a claim as the data supports.

Revert with: git checkout main

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

backend/app/config.py CHANGED
@@ -101,6 +101,17 @@ VERIFIER_MODEL = ModelSpec(
101
  cost_per_mtok_out=0.30,
102
  )
103
 
 
 
 
 
 
 
 
 
 
 
 
104
  class Settings(BaseSettings):
105
  model_config = SettingsConfigDict(env_file=".env", extra="ignore")
106
 
@@ -120,6 +131,17 @@ class Settings(BaseSettings):
120
  # models' knowledge cutoff. Bump this as the underlying models advance.
121
  model_knowledge_cutoff_year: int = 2024
122
 
 
 
 
 
 
 
 
 
 
 
 
123
  # Image search (Tavily). The OpenRouter web plugin returns no images, so
124
  # image-worthy web queries get one extra Tavily call of our own. An empty
125
  # key disables the feature silently — nothing else changes.
@@ -235,6 +257,8 @@ class Settings(BaseSettings):
235
 
236
  settings = Settings()
237
 
 
 
238
 
239
  # --- Tier 2: frontier escalation target -------------------------------------
240
  # (display name, $/Mtok in, $/Mtok out) per known frontier choice; unknown
 
101
  cost_per_mtok_out=0.30,
102
  )
103
 
104
+ def _web_gate_model() -> "ModelSpec":
105
+ """Built after Settings so the model id stays overridable by env."""
106
+ return ModelSpec(
107
+ key="webgate",
108
+ openrouter_id=settings.web_gate_model_id,
109
+ display_name="Web gate",
110
+ cost_per_mtok_in=0.05,
111
+ cost_per_mtok_out=0.10,
112
+ )
113
+
114
+
115
  class Settings(BaseSettings):
116
  model_config = SettingsConfigDict(env_file=".env", extra="ignore")
117
 
 
131
  # models' knowledge cutoff. Bump this as the underlying models advance.
132
  model_knowledge_cutoff_year: int = 2024
133
 
134
+ # t=0 web-search gate: a tiny model answering one yes/no question, run in
135
+ # parallel with the hedge (see webgate.py for why parallel, not first).
136
+ # Granite 4.1 8B won on both axes in evals/bench_web_gate.py: 100% on
137
+ # held-out queries at ~450-500ms TTFT. Smaller models were not faster —
138
+ # provider routing dominates at this size — and 1B scored 40%.
139
+ web_gate_enabled: bool = True
140
+ web_gate_model_id: str = "ibm-granite/granite-4.1-8b"
141
+ # Generous vs the ~500ms typical: the gate races the bids either way, so a
142
+ # slow tail costs nothing, while a tight timeout would silently drop it.
143
+ web_gate_timeout_s: float = 3.0
144
+
145
  # Image search (Tavily). The OpenRouter web plugin returns no images, so
146
  # image-worthy web queries get one extra Tavily call of our own. An empty
147
  # key disables the feature silently — nothing else changes.
 
257
 
258
  settings = Settings()
259
 
260
+ WEB_GATE_MODEL = _web_gate_model()
261
+
262
 
263
  # --- Tier 2: frontier escalation target -------------------------------------
264
  # (display name, $/Mtok in, $/Mtok out) per known frontier choice; unknown
backend/app/pipeline.py CHANGED
@@ -24,6 +24,7 @@ from .config import (BASELINE_MODEL, SPECULATIVE_HINT_MODELS, TIER1_MODELS,
24
  from .llm import LLMError, chat, extract_json
25
  from .schemas import Bid, RunResult, SourceImage, Usage, Verification
26
  from .store import get_store
 
27
  from .websearch import enabled as image_search_enabled
28
  from .websearch import search_images
29
 
@@ -36,6 +37,7 @@ class RouterState(TypedDict, total=False):
36
  winner: Optional[str]
37
  needs_web: bool
38
  web_via_bidder: bool
 
39
  wants_images: bool
40
  images: list[SourceImage]
41
  draft_answer: Optional[str]
@@ -470,12 +472,16 @@ async def auction(state: RouterState) -> RouterState:
470
  # answer was written from stale training data, so discard it and force
471
  # a real (web-enabled) draft.
472
  regex_web = _needs_web_heuristic(state["query"])
473
- needs_web = winner.needs_web or regex_web
 
 
 
 
474
  out["needs_web"] = needs_web
475
- # True when the upfront regex missed but a bidder flagged the query — the
476
- # UI announces "let me search the web" in that case (regex hits go straight
477
- # to the search silently, since we knew from the start).
478
- out["web_via_bidder"] = needs_web and not regex_web
479
  # Images ride along with a web search and never on their own: a picture
480
  # for a query we answered from training data would be unsourced, and
481
  # gating on needs_web caps image lookups at one per web-searched query.
@@ -853,6 +859,14 @@ async def run_query_stream(query: str, history: list[dict] | None = None,
853
  if not run_hedge:
854
  state["needs_web"] = True
855
 
 
 
 
 
 
 
 
 
856
  hedge_q: asyncio.Queue = asyncio.Queue()
857
 
858
  async def _hedge_stream():
@@ -876,7 +890,26 @@ async def run_query_stream(query: str, history: list[dict] | None = None,
876
  # Forward hedge tokens the moment they exist, while bids come in
877
  hedge_final: Optional[object] = None
878
  hedge_done = not run_hedge
 
879
  while not bid_task.done():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
880
  if not run_hedge:
881
  await asyncio.sleep(0.05)
882
  continue
@@ -888,6 +921,18 @@ async def run_query_stream(query: str, history: list[dict] | None = None,
888
  yield {"type": "token", "text": val}
889
  else:
890
  hedge_final, hedge_done = val, True
 
 
 
 
 
 
 
 
 
 
 
 
891
  state.update(bid_task.result())
892
  state.update(await auction(state))
893
  # Fired here so Tavily runs concurrently with the draft — by the time the
 
24
  from .llm import LLMError, chat, extract_json
25
  from .schemas import Bid, RunResult, SourceImage, Usage, Verification
26
  from .store import get_store
27
+ from . import webgate
28
  from .websearch import enabled as image_search_enabled
29
  from .websearch import search_images
30
 
 
37
  winner: Optional[str]
38
  needs_web: bool
39
  web_via_bidder: bool
40
+ gate_web: bool
41
  wants_images: bool
42
  images: list[SourceImage]
43
  draft_answer: Optional[str]
 
472
  # answer was written from stale training data, so discard it and force
473
  # a real (web-enabled) draft.
474
  regex_web = _needs_web_heuristic(state["query"])
475
+ # The gate model has already spoken by now (it resolves ~500ms in, well
476
+ # before the bids) and has far better recall than the regex on natural
477
+ # phrasing, so it joins the OR rather than replacing anything.
478
+ gate_web = bool(state.get("gate_web"))
479
+ needs_web = winner.needs_web or regex_web or gate_web
480
  out["needs_web"] = needs_web
481
+ # True when neither the upfront regex nor the gate caught it but a bidder
482
+ # did — only then does the UI still owe the user a "let me search" line.
483
+ # Regex hits search silently; the gate already announced its own.
484
+ out["web_via_bidder"] = needs_web and not regex_web and not gate_web
485
  # Images ride along with a web search and never on their own: a picture
486
  # for a query we answered from training data would be unsourced, and
487
  # gating on needs_web caps image lookups at one per web-searched query.
 
859
  if not run_hedge:
860
  state["needs_web"] = True
861
 
862
+ # The gate model races the hedge rather than preceding it: its TTFT is
863
+ # about the same as the hedge model's, so making the hedge wait would
864
+ # double time-to-first-token on every query that needs no search at all.
865
+ # Skipped when the regex already decided — that answer costs nothing and
866
+ # is never wrong in the "web needed" direction.
867
+ gate_task = (asyncio.ensure_future(webgate.decide(query))
868
+ if run_hedge and webgate.enabled() else None)
869
+
870
  hedge_q: asyncio.Queue = asyncio.Queue()
871
 
872
  async def _hedge_stream():
 
890
  # Forward hedge tokens the moment they exist, while bids come in
891
  hedge_final: Optional[object] = None
892
  hedge_done = not run_hedge
893
+ gate_says_web = False
894
  while not bid_task.done():
895
+ # Gate verdict usually lands ~500ms in, long before the bids. A "yes"
896
+ # kills the hedge here rather than at auction time, so the user sees a
897
+ # few hundred ms of doomed text instead of several seconds of it.
898
+ if gate_task is not None and gate_task.done():
899
+ verdict, gate_usage = gate_task.result()
900
+ gate_task = None
901
+ if gate_usage is not None:
902
+ state["usages"] = state["usages"] + [gate_usage]
903
+ if verdict is True:
904
+ gate_says_web = True
905
+ state["needs_web"] = True
906
+ if hedge_task is not None:
907
+ hedge_task.cancel()
908
+ hedge_task = None
909
+ run_hedge, hedge_done = False, True
910
+ yield {"type": "reset"}
911
+ yield {"type": "token",
912
+ "text": "🔎 Let me search the web for you…\n\n"}
913
  if not run_hedge:
914
  await asyncio.sleep(0.05)
915
  continue
 
921
  yield {"type": "token", "text": val}
922
  else:
923
  hedge_final, hedge_done = val, True
924
+ # Bids won the race; collect the gate's verdict if it still has one
925
+ if gate_task is not None:
926
+ try:
927
+ verdict, gate_usage = await asyncio.wait_for(gate_task, timeout=0.5)
928
+ if gate_usage is not None:
929
+ state["usages"] = state["usages"] + [gate_usage]
930
+ if verdict is True:
931
+ gate_says_web = True
932
+ state["needs_web"] = True
933
+ except Exception:
934
+ pass # no opinion; the regex and bidder flags still decide
935
+ state["gate_web"] = gate_says_web
936
  state.update(bid_task.result())
937
  state.update(await auction(state))
938
  # Fired here so Tavily runs concurrently with the draft — by the time the
backend/app/schemas.py CHANGED
@@ -50,7 +50,7 @@ class Verification(BaseModel):
50
  class Usage(BaseModel):
51
  model_key: str
52
  model_name: str
53
- stage: Literal["bid", "draft", "verify", "escalate"]
54
  tokens_in: int = 0
55
  tokens_out: int = 0
56
  cost_usd: float = 0.0
 
50
  class Usage(BaseModel):
51
  model_key: str
52
  model_name: str
53
+ stage: Literal["bid", "draft", "verify", "escalate", "gate"]
54
  tokens_in: int = 0
55
  tokens_out: int = 0
56
  cost_usd: float = 0.0
backend/app/webgate.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A tiny model that answers one question: does this query need a web search?
2
+
3
+ Runs at t=0 alongside the hedge and the bids, not before them. That ordering is
4
+ load-bearing: the gate's TTFT (~450-500ms measured) is roughly the same as the
5
+ hedge model's, so putting it on the critical path would double time-to-first-
6
+ token on the majority of queries, which need no web search at all. In parallel
7
+ it costs nothing and simply cancels the hedge far earlier than the bidders
8
+ would have (~500ms instead of 2-4s).
9
+
10
+ It exists because the regex it supplements has poor recall on natural
11
+ phrasing — 62.5% on held-out queries against this model's 100%. See
12
+ evals/bench_web_gate.py for the comparison and how the model was chosen.
13
+ """
14
+
15
+ import asyncio
16
+ import logging
17
+
18
+ from .config import WEB_GATE_MODEL, settings
19
+ from .llm import LLMError, chat
20
+ from .schemas import Usage
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ # Every token here is on a latency-critical path, so the prompt stays short.
25
+ # Identical to the one the benchmark scored, so measured accuracy carries over.
26
+ GATE_SYSTEM = (
27
+ "You decide if a user query needs a live web search to answer correctly.\n"
28
+ "Answer YES if it needs current, real-time, or post-training information: "
29
+ "news, prices, scores, weather, releases, results, who currently holds a "
30
+ "role, or a specific named item you may not know.\n"
31
+ "Answer NO for timeless questions: math, code, definitions, writing, "
32
+ "advice, explanations, general knowledge.\n"
33
+ "Reply with exactly one word: YES or NO."
34
+ )
35
+
36
+
37
+ def enabled() -> bool:
38
+ return bool(settings.web_gate_enabled and settings.openrouter_api_key)
39
+
40
+
41
+ async def decide(query: str) -> tuple[bool | None, Usage | None]:
42
+ """Return (needs_web, usage). None means "no opinion" — never a guess.
43
+
44
+ Callers must treat None as "fall back to the other signals" rather than
45
+ as False, so a gate outage can only lose recall, never invent it.
46
+ """
47
+ if not enabled() or not query.strip():
48
+ return None, None
49
+ try:
50
+ resp = await asyncio.wait_for(
51
+ chat(WEB_GATE_MODEL, GATE_SYSTEM, query,
52
+ max_tokens=8, prefer_paid=True),
53
+ timeout=settings.web_gate_timeout_s,
54
+ )
55
+ except (LLMError, asyncio.TimeoutError, Exception) as e:
56
+ log.warning("web gate failed: %s: %s", type(e).__name__, str(e)[:150])
57
+ return None, None
58
+
59
+ usage = Usage(
60
+ model_key=WEB_GATE_MODEL.key,
61
+ model_name=WEB_GATE_MODEL.display_name,
62
+ stage="gate",
63
+ tokens_in=resp.tokens_in,
64
+ tokens_out=resp.tokens_out,
65
+ cost_usd=WEB_GATE_MODEL.estimate_cost(resp.tokens_in, resp.tokens_out,
66
+ resp.served_model),
67
+ latency_ms=resp.latency_ms,
68
+ )
69
+ answer = resp.content.strip().upper()
70
+ if answer.startswith("YES"):
71
+ return True, usage
72
+ if answer.startswith("NO"):
73
+ return False, usage
74
+ log.warning("web gate returned unparseable %r", resp.content[:60])
75
+ return None, usage