Files
openswarm/backend/apps/web/web.py
T

389 lines
14 KiB
Python

"""Web search + fetch sub-app.
Thin HTTP wrappers around `WebSearchTool` and `WebFetchTool` from
`backend.apps.agents.tools.web`. Exists so the in-process MCP server
(`backend.apps.agents.web_mcp_server`) can proxy tool calls to the
backend instead of re-implementing DuckDuckGo scraping + trafilatura
extraction in the MCP process.
Mounted at `/api/web`.
"""
from contextlib import asynccontextmanager
from typing import Any
from fastapi import HTTPException
from pydantic import BaseModel, Field
from typeguard import typechecked
import debug
from backend.config.Apps import SubApp
@asynccontextmanager
async def web_lifespan():
debug("START")
yield
debug("END")
web = SubApp("web", web_lifespan)
# ---------------------------------------------------------------------------
# Request models
# ---------------------------------------------------------------------------
class SearchBody(BaseModel):
query: str = Field(..., description="The search query.")
num_results: int = Field(5, ge=1, le=10, description="Max results to return.")
# Hint from the MCP server about which primary provider the session
# is using. Lets us route to that provider's native search tool
# (Gemini googleSearch, OpenAI web_search_preview) when available —
# costs come out of the user's existing primary budget.
primary: str | None = Field(None, description="Primary provider hint: 'gemini' | 'openai' | 'anthropic' | None")
class FetchBody(BaseModel):
url: str = Field(..., description="The URL to fetch.")
prompt: str | None = Field(None, description="Optional context hint.")
primary: str | None = Field(None, description="Primary provider hint.")
# ---------------------------------------------------------------------------
# Helper — extract plain text from a tool's structured output list
# ---------------------------------------------------------------------------
def _join_text(parts: list[dict[str, Any]]) -> str:
out = []
for p in parts:
if isinstance(p, dict) and p.get("type") == "text":
out.append(str(p.get("text", "")))
return "\n".join(out)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
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
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.
"""
import httpx
tools = [{"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 = []
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}
def _format_grounded_as_search_results(grounded: dict, query: str) -> str:
"""Format Gemini grounding output to match WebSearchTool's text shape."""
lines = []
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)
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)
def _resolve_gemini_api_key() -> str | None:
"""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
def _resolve_openai_api_key() -> str | None:
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
async def _openai_websearch(api_key: str, query: str) -> dict:
"""Call OpenAI Responses API with the web_search_preview tool.
Returns {"text": grounded_answer, "chunks": [(title, uri), ...]}.
"""
import httpx
body = {
"model": OPENAI_SEARCH_MODEL,
"input": f"Search the web for: {query}\n\nReturn a concise summary. Cite sources.",
"tools": [{"type": "web_search_preview"}],
}
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=body,
)
r.raise_for_status()
data = r.json()
text_parts = []
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", "")
title = ann.get("title", uri)
if uri:
chunks.append((title, uri))
return {"text": "".join(text_parts), "chunks": chunks, "queries": [query]}
async def _openai_urlfetch(api_key: str, url: str, prompt: str | None) -> 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}"
import httpx
body = {
"model": OPENAI_SEARCH_MODEL,
"input": prompt_text,
"tools": [{"type": "web_search_preview"}],
}
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=body,
)
r.raise_for_status()
data = r.json()
text_parts = []
chunks = []
for item in (data.get("output") or []):
for content in (item.get("content") or []):
if isinstance(content, dict) and content.get("type") == "output_text":
text_parts.append(content.get("text", ""))
for ann in (content.get("annotations") or []) if isinstance(content, dict) else []:
if isinstance(ann, dict) and ann.get("type") == "url_citation":
uri = ann.get("url", "")
title = ann.get("title", uri)
if uri:
chunks.append((title, uri))
return {"text": "".join(text_parts), "chunks": chunks}
@web.router.post("/search")
@typechecked
async def search(body: SearchBody) -> dict:
"""Web search, primary-aware. Prefers the native search tool of the
provider the user is already paying for:
Gemini primary + Gemini key → googleSearch grounding
OpenAI primary + OpenAI key → web_search_preview
Anthropic path available → handled at agent_manager layer
(MCP isn't registered; built-in
WebSearch routes through 9Router
→ Anthropic's server-side tool)
otherwise → DDG fallback (CAPTCHA-prone)
If the primary's own native path fails, we cascade to whichever
other provider's credentials are available, then DDG last."""
gemini_key = _resolve_gemini_api_key()
openai_key = _resolve_openai_api_key()
primary = (body.primary or "").lower()
errors: list[str] = []
async def try_gemini():
if not gemini_key:
return None
prompt = (
f"Search the web for: {body.query}\n\n"
f"Return a concise summary of what you found. Cite sources."
)
grounded = await _gemini_grounded_call(gemini_key, prompt, use_url_context=False)
return {
"query": body.query,
"results": _format_grounded_as_search_results(grounded, body.query),
"backend": "gemini_native",
}
async def try_openai():
if not openai_key:
return None
grounded = await _openai_websearch(openai_key, body.query)
return {
"query": body.query,
"results": _format_grounded_as_search_results(grounded, body.query),
"backend": "openai_native",
}
# Ordered cascade: primary's native path first, then the other
# native paths, then DDG.
if primary == "openai":
cascade = [("openai", try_openai), ("gemini", try_gemini)]
elif primary in ("gemini", "google"):
cascade = [("gemini", try_gemini), ("openai", try_openai)]
else:
cascade = [("gemini", try_gemini), ("openai", try_openai)]
for name, fn in cascade:
try:
res = await fn()
if res is not None:
return res
except Exception as e:
errors.append(f"{name}: {str(e)[:150]}")
# DDG fallback.
from backend.apps.agents.tools.web import WebSearchTool
try:
tool = WebSearchTool()
parts = await tool.execute(
{"query": body.query, "num_results": body.num_results},
None,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Search failed: {e}")
text = _join_text(parts)
hint = ""
if text.startswith("No search results found") and not (gemini_key or openai_key):
hint = (
"\n\n(DuckDuckGo returned no results — likely rate-limiting this IP. "
"Add a Gemini key (https://aistudio.google.com/apikey) or OpenAI key "
"in Settings for reliable native search.)"
)
return {
"query": body.query,
"results": text + hint,
"backend": "ddg",
**({"cascade_errors": errors} if errors else {}),
}
@web.router.post("/fetch")
@typechecked
async def fetch(body: FetchBody) -> dict:
"""Fetch a URL, primary-aware. Mirrors /search cascade logic."""
gemini_key = _resolve_gemini_api_key()
openai_key = _resolve_openai_api_key()
primary = (body.primary or "").lower()
async def try_gemini():
if not gemini_key:
return None
prompt_bits = [f"Fetch and summarize this URL: {body.url}"]
if body.prompt:
prompt_bits.append(f"Focus on: {body.prompt}")
grounded = await _gemini_grounded_call(
gemini_key, "\n".join(prompt_bits), use_url_context=True,
)
return {
"url": body.url,
"content": _format_grounded_as_fetch(grounded, body.url),
"backend": "gemini_native",
}
async def try_openai():
if not openai_key:
return None
grounded = await _openai_urlfetch(openai_key, body.url, body.prompt)
return {
"url": body.url,
"content": _format_grounded_as_fetch(grounded, body.url),
"backend": "openai_native",
}
if primary == "openai":
cascade = [try_openai, try_gemini]
else:
cascade = [try_gemini, try_openai]
for fn in cascade:
try:
res = await fn()
if res is not None:
return res
except Exception:
continue
# Local httpx + trafilatura fallback.
from backend.apps.agents.tools.web import WebFetchTool
try:
tool = WebFetchTool()
parts = await tool.execute(
{"url": body.url, "prompt": body.prompt or ""},
None,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Fetch failed: {e}")
return {"url": body.url, "content": _join_text(parts), "backend": "local"}