Spaces:
Sleeping
pipeline: Granite web-search gate, racing the hedge
Browse filesA 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 +24 -0
- backend/app/pipeline.py +50 -5
- backend/app/schemas.py +1 -1
- backend/app/webgate.py +75 -0
|
@@ -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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
out["needs_web"] = needs_web
|
| 475 |
-
# True when the upfront regex
|
| 476 |
-
#
|
| 477 |
-
#
|
| 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
|
|
@@ -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
|
|
@@ -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
|