mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
266 lines
9.5 KiB
Python
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)
|