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