Files

266 lines
9.5 KiB
Python

"""Provider-grounded search/fetch backends for the /api/web cascade.
These are the PAID tiers: the user's own Gemini / OpenAI key, or the same
providers reached through a 9Router subscription. They are slow (tens of
seconds) but render and reason over pages, so they sit behind the free
keyless tiers and only run when those come up empty."""
import time
from typing import Dict, List, Optional, Set, Tuple
import httpx
from typeguard import typechecked
GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"
GEMINI_GROUNDING_MODEL = "gemini-2.5-flash" # cheapest + fastest for grounded calls
OPENAI_API_BASE = "https://api.openai.com/v1"
OPENAI_SEARCH_MODEL = "gpt-5-mini" # cheapest model that supports web_search_preview
NINE_ROUTER_MESSAGES_URL = "http://localhost:20128/v1/messages"
@typechecked
async def gemini_grounded_call(api_key: str, prompt: str, *, use_url_context: bool) -> Dict:
"""Call Gemini with googleSearch (+ optionally urlContext) grounding.
Returns {"text": grounded_answer, "chunks": [(title, uri), ...],
"queries": [...]} or raises httpx.HTTPError on failure.
"""
tools: List[Dict] = [{"googleSearch": {}}]
if use_url_context:
tools.append({"urlContext": {}})
body = {
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
"tools": tools,
"generationConfig": {"thinkingConfig": {"thinkingBudget": 0}},
}
url = f"{GEMINI_API_BASE}/models/{GEMINI_GROUNDING_MODEL}:generateContent"
async with httpx.AsyncClient(timeout=45.0) as client:
r = await client.post(
url,
headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
json=body,
)
r.raise_for_status()
data = r.json()
cand = (data.get("candidates") or [{}])[0]
text = "".join(
p.get("text", "") for p in (cand.get("content", {}).get("parts") or [])
if isinstance(p, dict)
)
gm = cand.get("groundingMetadata") or {}
chunks: List[Tuple[str, str]] = []
for gc in (gm.get("groundingChunks") or []):
web = (gc or {}).get("web") or {}
uri = web.get("uri") or web.get("url") or ""
title = web.get("title") or uri
if uri:
chunks.append((title, uri))
queries = gm.get("webSearchQueries") or []
return {"text": text, "chunks": chunks, "queries": queries}
@typechecked
def format_grounded_as_search_results(grounded: Dict, query: str) -> str:
"""Format Gemini grounding output to match WebSearchTool's text shape."""
lines: List[str] = []
chunks = grounded.get("chunks") or []
for i, (title, uri) in enumerate(chunks[:10], start=1):
lines.append(f"[{i}] {title}\n {uri}")
text = grounded.get("text") or ""
if text:
lines.append("\n" + text)
if not lines:
return f"No search results found for: {query}"
return "\n\n".join(lines)
@typechecked
def format_grounded_as_fetch(grounded: Dict, url: str) -> str:
"""Format Gemini urlContext output to match WebFetchTool's text shape."""
parts = [f"Contents of {url}:", ""]
text = grounded.get("text") or ""
if text:
parts.append(text)
chunks = grounded.get("chunks") or []
if chunks:
parts.append("\nCited sources:")
for i, (title, uri) in enumerate(chunks[:5], start=1):
parts.append(f" [{i}] {title}; {uri}")
return "\n".join(parts)
@typechecked
def resolve_gemini_api_key() -> Optional[str]:
"""Pull the AI Studio API key from settings, or None."""
try:
from backend.apps.settings.settings import load_settings
s = load_settings()
return getattr(s, "google_api_key", None) or None
except Exception:
return None
@typechecked
def resolve_openai_api_key() -> Optional[str]:
try:
from backend.apps.settings.settings import load_settings
s = load_settings()
return getattr(s, "openai_api_key", None) or None
except Exception:
return None
# Cache of which 9Router subscriptions are connected. Refreshed rather than hit on every search call; 9Router's /api/providers is fast but not free and we already query it from many places.
p_nine_router_connected: Set[str] = set()
p_nine_router_cache_at: float = 0.0
@typechecked
async def refresh_9r_connected() -> Set[str]:
"""The currently-active 9Router subscription providers, cached 20s."""
global p_nine_router_connected, p_nine_router_cache_at
now = time.time()
if now - p_nine_router_cache_at < 20.0:
return p_nine_router_connected
try:
from backend.apps.nine_router import is_running as p_9r_running, get_providers as p_9r_providers
if not p_9r_running():
p_nine_router_connected = set()
else:
conns = await p_9r_providers()
p_nine_router_connected = {
c.get("provider")
for c in conns
if isinstance(c, dict) and c.get("isActive") and c.get("provider")
}
p_nine_router_cache_at = now
except Exception:
# Cache stays; best-effort.
pass
return p_nine_router_connected
@typechecked
async def p_nine_router_text(body: Dict) -> str:
"""POST an Anthropic-shape body to 9Router and concatenate its text blocks."""
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.post(
NINE_ROUTER_MESSAGES_URL,
json=body,
headers={"x-api-key": "9router", "anthropic-version": "2023-06-01"},
)
if r.status_code != 200:
return ""
data = r.json()
return "".join(
block.get("text", "")
for block in (data.get("content") or [])
if isinstance(block, dict) and block.get("type") == "text"
)
@typechecked
async def gemini_grounded_via_9router(prompt: str, use_url_context: bool) -> Dict:
"""Grounded Gemini through the user's OAuth subscription instead of an AI Studio key.
Shapes its return like `gemini_grounded_call` so the formatters work
unchanged. 9Router doesn't surface citations as a structured field
uniformly across providers, so we hand back text-only."""
connected = await refresh_9r_connected()
if "gemini-cli" in connected:
model = "gc/gemini-2.5-flash"
elif "antigravity" in connected:
model = "ag/gemini-3-flash"
else:
return {}
sys_prompt = (
"You fetch URLs and return concise summaries with citations."
if use_url_context
else "You search the web and return concise grounded answers with "
"source citations. Always cite the URLs you used."
)
text = await p_nine_router_text({
"model": model,
"max_tokens": 1024,
"system": sys_prompt,
"messages": [{"role": "user", "content": prompt}],
})
return {"text": text, "chunks": []}
@typechecked
async def openai_websearch_via_9router(query: str) -> Dict:
"""Same idea for OpenAI, through the user's Codex 9Router connection."""
connected = await refresh_9r_connected()
if "codex" not in connected:
return {}
text = await p_nine_router_text({
"model": "cx/gpt-5.4-mini",
"max_tokens": 1024,
"system": (
"You search the web and return concise grounded answers "
"with source citations. Always cite the URLs you used."
),
"messages": [{"role": "user", "content": f"Search the web for: {query}"}],
})
return {"text": text, "chunks": []}
@typechecked
def p_parse_openai_response(data: Dict) -> Dict:
"""Pull output_text + url_citation annotations out of a Responses API body."""
text_parts: List[str] = []
chunks: List[Tuple[str, str]] = []
for item in (data.get("output") or []):
if not isinstance(item, dict):
continue
for content in (item.get("content") or []):
if not isinstance(content, dict):
continue
if content.get("type") == "output_text":
text_parts.append(content.get("text", ""))
for ann in (content.get("annotations") or []):
if isinstance(ann, dict) and ann.get("type") == "url_citation":
uri = ann.get("url", "")
if uri:
chunks.append((ann.get("title", uri), uri))
return {"text": "".join(text_parts), "chunks": chunks}
@typechecked
async def p_openai_responses(api_key: str, prompt: str) -> Dict:
async with httpx.AsyncClient(timeout=45.0) as client:
r = await client.post(
f"{OPENAI_API_BASE}/responses",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={
"model": OPENAI_SEARCH_MODEL,
"input": prompt,
"tools": [{"type": "web_search_preview"}],
},
)
r.raise_for_status()
return p_parse_openai_response(r.json())
@typechecked
async def openai_websearch(api_key: str, query: str) -> Dict:
"""Call OpenAI Responses API with the web_search_preview tool."""
grounded = await p_openai_responses(
api_key, f"Search the web for: {query}\n\nReturn a concise summary. Cite sources.",
)
grounded["queries"] = [query]
return grounded
@typechecked
async def openai_urlfetch(api_key: str, url: str, prompt: Optional[str]) -> Dict:
"""Use OpenAI's web_search_preview to fetch/summarize a specific URL."""
prompt_text = f"Fetch and summarize the content at: {url}"
if prompt:
prompt_text += f"\n\nFocus on: {prompt}"
return await p_openai_responses(api_key, prompt_text)