mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 03:35:40 +02:00
602 lines
25 KiB
Python
602 lines
25 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`.
|
|
"""
|
|
|
|
import asyncio
|
|
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 p_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
|
|
|
|
# Per-attempt timeouts for the search/fetch cascade. The fast-first ORDERING is what fixes the ~75s stall (DDG answers in ~1s so the slow grounded backends are rarely reached); these bounds are hang safety-nets, set just ABOVE each path's own httpx timeout so a normally-slow call still completes and only a truly hung provider (no response at all) gets cut. Grounded native search legitimately takes 32-42s (httpx ceiling 45s), so its leash sits at 48s, NOT below 45, or we'd clip the slow tail of a valid paid call.
|
|
P_DDG_ATTEMPT_TIMEOUT = 6.0 # DDG answers <1s; >6s is a network hang, fall through
|
|
P_GROUNDED_ATTEMPT_TIMEOUT = 48.0 # just above the providers' own 45s httpx timeout
|
|
# Local httpx + trafilatura fetch of a real page; the fast path for /fetch (normal pages return in <2s). Set just above WebFetchTool's own 30s httpx ceiling so a valid-but-slow page still completes locally instead of being clipped down to a grounded summary; only a truly hung server gets cut.
|
|
P_LOCAL_FETCH_TIMEOUT = 32.0
|
|
|
|
|
|
async def p_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 p_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 p_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 p_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 p_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
|
|
|
|
|
|
# Cache of which 9Router subscriptions are connected. Refreshed via `_refresh_9r_connected()` 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
|
|
|
|
|
|
async def p_refresh_9r_connected() -> set[str]:
|
|
"""Return the set of currently-active 9Router subscription providers
|
|
(e.g. {"claude", "codex", "antigravity", "gemini-cli"}). Cached for
|
|
20s to keep search/fetch endpoints snappy."""
|
|
global P_NINE_ROUTER_CONNECTED, P_NINE_ROUTER_CACHE_AT
|
|
import time as p_t
|
|
now = p_t.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
|
|
|
|
|
|
async def p_gemini_grounded_via_9router(prompt: str, use_url_context: bool) -> dict:
|
|
"""Call 9Router's /v1/messages endpoint with a Gemini model so the
|
|
user's OAuth subscription (Gemini CLI or Antigravity) covers the
|
|
search call instead of needing a separate AI Studio API key.
|
|
|
|
Routes through Anthropic-shape against 9Router's translator. We
|
|
request a tool result naturally; the translator surfaces grounded
|
|
URIs as text + cited sources in the response body. Format-shape
|
|
matches the existing `_gemini_grounded_call` so downstream
|
|
`_format_grounded_as_search_results` works unchanged."""
|
|
import httpx
|
|
# Prefer Gemini CLI (broader model coverage). Fall back to Antigravity if CLI isn't connected.
|
|
connected = await p_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 search the web and return concise grounded answers with "
|
|
"source citations. Always cite the URLs you used."
|
|
if not use_url_context
|
|
else "You fetch URLs and return concise summaries with citations."
|
|
)
|
|
body = {
|
|
"model": model,
|
|
"max_tokens": 1024,
|
|
"system": sys_prompt,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
}
|
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
r = await client.post(
|
|
"http://localhost:20128/v1/messages",
|
|
json=body,
|
|
headers={"x-api-key": "9router", "anthropic-version": "2023-06-01"},
|
|
)
|
|
if r.status_code != 200:
|
|
return {}
|
|
data = r.json()
|
|
# Synthesize a grounded shape so the existing formatter works: _format_grounded_as_search_results expects {"text": str, "chunks": [(title, uri), ...]}. 9Router doesn't surface citations as a structured field uniformly across providers, so we hand back text-only and let the formatter do its thing.
|
|
text = ""
|
|
for block in (data.get("content") or []):
|
|
if isinstance(block, dict) and block.get("type") == "text":
|
|
text += block.get("text", "")
|
|
return {"text": text, "chunks": []}
|
|
|
|
|
|
async def p_openai_websearch_via_9router(query: str) -> dict:
|
|
"""Same idea, but for OpenAI's web_search_preview through Codex's
|
|
9Router connection. Goes through 9Router's openai-compat endpoint
|
|
(the responses API) so the user's Codex subscription covers it."""
|
|
import httpx
|
|
connected = await p_refresh_9r_connected()
|
|
if "codex" not in connected:
|
|
return {}
|
|
body = {
|
|
"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}"}],
|
|
}
|
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
r = await client.post(
|
|
"http://localhost:20128/v1/messages",
|
|
json=body,
|
|
headers={"x-api-key": "9router", "anthropic-version": "2023-06-01"},
|
|
)
|
|
if r.status_code != 200:
|
|
return {}
|
|
data = r.json()
|
|
text = ""
|
|
for block in (data.get("content") or []):
|
|
if isinstance(block, dict) and block.get("type") == "text":
|
|
text += block.get("text", "")
|
|
return {"text": text, "chunks": []}
|
|
|
|
|
|
async def p_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 p_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 = p_resolve_gemini_api_key()
|
|
openai_key = p_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 p_gemini_grounded_call(gemini_key, prompt, use_url_context=False)
|
|
return {
|
|
"query": body.query,
|
|
"results": p_format_grounded_as_search_results(grounded, body.query),
|
|
"backend": "gemini_native",
|
|
}
|
|
|
|
async def try_openai():
|
|
if not openai_key:
|
|
return None
|
|
grounded = await p_openai_websearch(openai_key, body.query)
|
|
return {
|
|
"query": body.query,
|
|
"results": p_format_grounded_as_search_results(grounded, body.query),
|
|
"backend": "openai_native",
|
|
}
|
|
|
|
async def try_gemini_subscription():
|
|
prompt = (
|
|
f"Search the web for: {body.query}\n\n"
|
|
f"Return a concise summary of what you found. Cite sources."
|
|
)
|
|
grounded = await p_gemini_grounded_via_9router(prompt, use_url_context=False)
|
|
if not grounded.get("text"):
|
|
return None
|
|
return {
|
|
"query": body.query,
|
|
"results": p_format_grounded_as_search_results(grounded, body.query),
|
|
"backend": "gemini_subscription",
|
|
}
|
|
|
|
async def try_openai_subscription():
|
|
grounded = await p_openai_websearch_via_9router(body.query)
|
|
if not grounded.get("text"):
|
|
return None
|
|
return {
|
|
"query": body.query,
|
|
"results": p_format_grounded_as_search_results(grounded, body.query),
|
|
"backend": "openai_subscription",
|
|
}
|
|
|
|
async def try_ddg():
|
|
# Fast path: direct HTML search, sub-second when DDG isn't throttling us. Returns None on a real no-hits OR a 202 throttle so the chain falls through to the slower-but-grounded backends.
|
|
from backend.apps.agents.tools.web import WebSearchTool, DDGRateLimited
|
|
try:
|
|
text = await WebSearchTool.search_ddg(body.query, body.num_results)
|
|
except DDGRateLimited:
|
|
# Surface the throttle as a recorded error (not a silent None) so the caller can see WHY we fell through to a slower backend.
|
|
raise RuntimeError("DuckDuckGo rate-limited (HTTP 202)") from None
|
|
if not text:
|
|
return None
|
|
return {"query": body.query, "results": text, "backend": "ddg"}
|
|
|
|
# Fast-first cascade: DDG leads (~1s = human speed); the 30-42s LLM-grounded backends are the reliable fallback when DDG is throttled or empty. The primary hint only reorders the grounded tier (native key before the same-provider subscription). Every attempt is wait_for-bounded so a slow or hung provider fails over fast instead of stalling the whole request.
|
|
grounded = [
|
|
("gemini_native", try_gemini),
|
|
("gemini_subscription", try_gemini_subscription),
|
|
("openai_native", try_openai),
|
|
("openai_subscription", try_openai_subscription),
|
|
]
|
|
if primary == "openai":
|
|
grounded = grounded[2:] + grounded[:2]
|
|
|
|
cascade = [("ddg", try_ddg, P_DDG_ATTEMPT_TIMEOUT)] + [
|
|
(name, fn, P_GROUNDED_ATTEMPT_TIMEOUT) for name, fn in grounded
|
|
]
|
|
|
|
for name, fn, timeout in cascade:
|
|
try:
|
|
res = await asyncio.wait_for(fn(), timeout=timeout)
|
|
if res is not None:
|
|
if errors:
|
|
res["cascade_errors"] = errors
|
|
return res
|
|
except asyncio.TimeoutError:
|
|
errors.append(f"{name}: timed out after {timeout:.0f}s")
|
|
except Exception as e:
|
|
errors.append(f"{name}: {str(e)[:150]}")
|
|
|
|
# Everything failed. Be honest about why instead of an empty "no results".
|
|
connected = await p_refresh_9r_connected()
|
|
has_subscription = bool(connected & {"codex", "antigravity", "gemini-cli"})
|
|
if not (gemini_key or openai_key or has_subscription):
|
|
tail = (
|
|
"No search backend is configured and DuckDuckGo is rate-limiting this "
|
|
"network. Connect Codex / Antigravity / Gemini CLI in Settings, or add "
|
|
"an OpenAI / Gemini API key, for reliable search."
|
|
)
|
|
else:
|
|
tail = (
|
|
"DuckDuckGo is rate-limiting this network and every configured provider "
|
|
"errored (see details below). Wait a moment and retry."
|
|
)
|
|
return {
|
|
"query": body.query,
|
|
"results": f"No results for: {body.query}\n\n{tail}",
|
|
"backend": "none",
|
|
"cascade_errors": errors,
|
|
}
|
|
|
|
|
|
@web.router.post("/fetch")
|
|
@typechecked
|
|
async def fetch(body: FetchBody) -> dict:
|
|
"""Fetch a URL, primary-aware. Mirrors /search cascade logic."""
|
|
# Belt-and-suspenders: even though we delegate to remote Gemini/OpenAI fetchers (which can't reach private IPs), validating the URL here means a private/metadata URL gets a 4xx instead of being silently forwarded.
|
|
from backend.apps.agents.tools.ssrf_guard import SSRFBlocked, assert_safe_url
|
|
try:
|
|
await assert_safe_url(body.url)
|
|
except SSRFBlocked as exc:
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=400, detail=f"Refused: {exc}")
|
|
gemini_key = p_resolve_gemini_api_key()
|
|
openai_key = p_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 p_gemini_grounded_call(
|
|
gemini_key, "\n".join(prompt_bits), use_url_context=True,
|
|
)
|
|
return {
|
|
"url": body.url,
|
|
"content": p_format_grounded_as_fetch(grounded, body.url),
|
|
"backend": "gemini_native",
|
|
}
|
|
|
|
async def try_openai():
|
|
if not openai_key:
|
|
return None
|
|
grounded = await p_openai_urlfetch(openai_key, body.url, body.prompt)
|
|
return {
|
|
"url": body.url,
|
|
"content": p_format_grounded_as_fetch(grounded, body.url),
|
|
"backend": "openai_native",
|
|
}
|
|
|
|
async def try_gemini_subscription():
|
|
prompt_bits = [f"Fetch and summarize this URL: {body.url}"]
|
|
if body.prompt:
|
|
prompt_bits.append(f"Focus on: {body.prompt}")
|
|
grounded = await p_gemini_grounded_via_9router(
|
|
"\n".join(prompt_bits), use_url_context=True,
|
|
)
|
|
if not grounded.get("text"):
|
|
return None
|
|
return {
|
|
"url": body.url,
|
|
"content": p_format_grounded_as_fetch(grounded, body.url),
|
|
"backend": "gemini_subscription",
|
|
}
|
|
|
|
async def try_openai_subscription():
|
|
# Codex's web_search is general; URL fetch via search query works adequately for our use.
|
|
prompt = f"Fetch this URL and summarize: {body.url}"
|
|
if body.prompt:
|
|
prompt += f"\nFocus on: {body.prompt}"
|
|
grounded = await p_openai_websearch_via_9router(prompt)
|
|
if not grounded.get("text"):
|
|
return None
|
|
return {
|
|
"url": body.url,
|
|
"content": p_format_grounded_as_fetch(grounded, body.url),
|
|
"backend": "openai_subscription",
|
|
}
|
|
|
|
# Remembered so a thin/errored local read is still returned as the last resort if every grounded fetcher also fails (never worse than before).
|
|
local_text: str | None = None
|
|
|
|
async def try_local():
|
|
# Fast path: direct httpx + trafilatura, sub-second to a few seconds and returns the page's ACTUAL text (the grounded fetchers summarize, which is slower and loses detail). Thin/errored reads (JS walls, paywalls, HTTP errors) fall through to the grounded fetchers that can render them.
|
|
nonlocal local_text
|
|
from backend.apps.agents.tools.web import WebFetchTool
|
|
parts = await WebFetchTool().execute(
|
|
{"url": body.url, "prompt": body.prompt or ""}, None,
|
|
)
|
|
text = p_join_text(parts)
|
|
local_text = text
|
|
if text.startswith(("HTTP error", "Error fetching", "Refused to fetch")):
|
|
return None
|
|
body_text = text.split("\n\n", 1)[-1] if "\n\n" in text else text
|
|
if len(body_text.strip()) < 200:
|
|
return None
|
|
return {"url": body.url, "content": text, "backend": "local"}
|
|
|
|
grounded = [
|
|
("gemini_native", try_gemini),
|
|
("gemini_subscription", try_gemini_subscription),
|
|
("openai_native", try_openai),
|
|
("openai_subscription", try_openai_subscription),
|
|
]
|
|
if primary == "openai":
|
|
grounded = grounded[2:] + grounded[:2]
|
|
|
|
cascade = [("local", try_local, P_LOCAL_FETCH_TIMEOUT)] + [
|
|
(name, fn, P_GROUNDED_ATTEMPT_TIMEOUT) for name, fn in grounded
|
|
]
|
|
|
|
errors: list[str] = []
|
|
for name, fn, timeout in cascade:
|
|
try:
|
|
res = await asyncio.wait_for(fn(), timeout=timeout)
|
|
if res is not None:
|
|
if errors:
|
|
res["cascade_errors"] = errors
|
|
return res
|
|
except asyncio.TimeoutError:
|
|
errors.append(f"{name}: timed out after {timeout:.0f}s")
|
|
except Exception as e:
|
|
errors.append(f"{name}: {str(e)[:150]}")
|
|
|
|
# Grounded all failed; hand back whatever the local read got (even an error string is useful signal) rather than nothing.
|
|
if local_text is not None:
|
|
return {"url": body.url, "content": local_text, "backend": "local",
|
|
**({"cascade_errors": errors} if errors else {})}
|
|
raise HTTPException(status_code=502, detail=f"Fetch failed for {body.url}: " + "; ".join(errors)[:400])
|