[haik]: refactor internal naming convention from leading-underscore (foo/FOO) to p/P prefix across anthropic_proxy, anthropic_to_openai, ssrf_guard, web tools, web_mcp_server, and auth router; clean up import aliases and local variable names in proxy handler; remove unused dashboard_layout module (models + router); remove orphaned healthcheck endpoint from anthropic proxy

This commit is contained in:
haikdc
2026-06-13 19:53:07 -07:00
parent f0d3cabc47
commit 61687726da
10 changed files with 135 additions and 223 deletions
+57 -60
View File
@@ -21,7 +21,7 @@ async def anthropic_proxy_lifespan():
anthropic_proxy = SubApp("anthropic-proxy", anthropic_proxy_lifespan)
_CLAUDE_MODEL_PREFIXES = (
P_CLAUDE_MODEL_PREFIXES = (
"claude-",
"claude/",
"sonnet",
@@ -30,13 +30,13 @@ _CLAUDE_MODEL_PREFIXES = (
"cc/",
)
_GEMINI_MODEL_PREFIXES = ("gemini/", "gc/", "ag/")
P_GEMINI_MODEL_PREFIXES = ("gemini/", "gc/", "ag/")
# Own-key Gemini ("gemini-3-flash-api" etc.) skips the gemini/ prefix; match bare names so $schema scrub still fires.
_GEMINI_BARE_MODEL_PATTERNS = ("gemini-",)
P_GEMINI_BARE_MODEL_PATTERNS = ("gemini-",)
# Keys 9Router 0.3.60 misses that Gemini's function_declarations validator 400s on. Each was caught in prod.
_GEMINI_FORBIDDEN_SCHEMA_KEYS = {
P_GEMINI_FORBIDDEN_SCHEMA_KEYS = {
"$schema",
"$id",
"$ref",
@@ -59,27 +59,27 @@ _GEMINI_FORBIDDEN_SCHEMA_KEYS = {
}
def _scrub_gemini_schema(node):
def p_scrub_gemini_schema(node):
"""Recursive in-place strip of Gemini-rejected JSON Schema fields."""
if isinstance(node, dict):
for k in list(node.keys()):
if k in _GEMINI_FORBIDDEN_SCHEMA_KEYS:
if k in P_GEMINI_FORBIDDEN_SCHEMA_KEYS:
node.pop(k, None)
continue
node[k] = _scrub_gemini_schema(node[k])
node[k] = p_scrub_gemini_schema(node[k])
return node
if isinstance(node, list):
for i, v in enumerate(node):
node[i] = _scrub_gemini_schema(v)
node[i] = p_scrub_gemini_schema(v)
return node
return node
# GPT-5.x rejects max_tokens; needs max_completion_tokens. Anthropic-format wire still emits max_tokens; we rename on the way out.
_OPENAI_MAX_COMPLETION_TOKENS_MODELS = ("gpt-5",)
P_OPENAI_MAX_COMPLETION_TOKENS_MODELS = ("gpt-5",)
def _is_openai_max_completion_tokens_model(model: str) -> bool:
def p_is_openai_max_completion_tokens_model(model: str) -> bool:
"""Match every shape a GPT-5 name might arrive in (bare, api-suffixed, openai/-prefixed, cx/-routed)."""
m = (model or "").strip().lower()
if not m:
@@ -88,10 +88,10 @@ def _is_openai_max_completion_tokens_model(model: str) -> bool:
if m.startswith(prefix):
m = m[len(prefix):]
break
return any(m.startswith(p) for p in _OPENAI_MAX_COMPLETION_TOKENS_MODELS)
return any(m.startswith(p) for p in P_OPENAI_MAX_COMPLETION_TOKENS_MODELS)
def _rewrite_document_to_openai_file(parsed: dict) -> None:
def p_rewrite_document_to_openai_file(parsed: dict) -> None:
"""In-place: rewrite Anthropic base64 `image` blocks to OpenAI `image_url` (PDFs go via OpenRouter)."""
msgs = parsed.get("messages") if isinstance(parsed, dict) else None
if not isinstance(msgs, list):
@@ -131,7 +131,7 @@ def _rewrite_document_to_openai_file(parsed: dict) -> None:
}
def _scrub_request_for_openai_gpt5(body: bytes) -> bytes:
def p_scrub_request_for_openai_gpt5(body: bytes) -> bytes:
"""Rename max_tokens→max_completion_tokens for GPT-5 AND rewrite any
Anthropic document blocks to OpenAI type:file shape so PDFs flow
natively on GPT-5.x vision models. Bytes in/out; never raises."""
@@ -152,7 +152,7 @@ def _scrub_request_for_openai_gpt5(body: bytes) -> bytes:
mutated = True
try:
before = json.dumps(parsed.get("messages"), sort_keys=True) if "messages" in parsed else ""
_rewrite_document_to_openai_file(parsed)
p_rewrite_document_to_openai_file(parsed)
after = json.dumps(parsed.get("messages"), sort_keys=True) if "messages" in parsed else ""
if before != after:
mutated = True
@@ -161,7 +161,7 @@ def _scrub_request_for_openai_gpt5(body: bytes) -> bytes:
return json.dumps(parsed).encode("utf-8") if mutated else body
def _rewrite_document_to_image(parsed: dict) -> None:
def p_rewrite_document_to_image(parsed: dict) -> None:
"""In-place: rewrite Anthropic `document` (PDF) AND `image` content
blocks → OpenAI `image_url` shape with a `data:` URL. Critical fix
for 9router 0.3.60 which **only translates `image_url` blocks** to
@@ -207,15 +207,15 @@ def _rewrite_document_to_image(parsed: dict) -> None:
}
_OPENROUTER_MODEL_PREFIXES = ("openrouter/", "or:")
P_OPENROUTER_MODEL_PREFIXES = ("openrouter/", "or:")
def _is_openrouter_model(model: str) -> bool:
def p_is_openrouter_model(model: str) -> bool:
m = (model or "").strip().lower()
return any(m.startswith(p) for p in _OPENROUTER_MODEL_PREFIXES)
return any(m.startswith(p) for p in P_OPENROUTER_MODEL_PREFIXES)
def _inject_openrouter_file_parser(body: bytes) -> bytes:
def p_inject_openrouter_file_parser(body: bytes) -> bytes:
"""When the request has document blocks AND is bound for OpenRouter,
inject the file-parser plugin so OR's universal PDF support kicks in
on any model (free models get pdf-text engine; native PDF models can
@@ -255,7 +255,7 @@ def _inject_openrouter_file_parser(body: bytes) -> bytes:
return json.dumps(parsed).encode("utf-8")
def _scrub_request_for_gemini(body: bytes) -> bytes:
def p_scrub_request_for_gemini(body: bytes) -> bytes:
"""Strip Gemini-incompatible schema keys from request tools AND
rewrite Anthropic document blocks to image-shape so 9router's
inline_data translator picks them up. Bytes-in/out, never raises."""
@@ -271,19 +271,19 @@ def _scrub_request_for_gemini(body: bytes) -> bytes:
if not isinstance(t, dict):
continue
if isinstance(t.get("input_schema"), (dict, list)):
_scrub_gemini_schema(t["input_schema"])
p_scrub_gemini_schema(t["input_schema"])
if isinstance(t.get("parameters"), (dict, list)):
_scrub_gemini_schema(t["parameters"])
p_scrub_gemini_schema(t["parameters"])
try:
if isinstance(parsed, dict):
_rewrite_document_to_image(parsed)
p_rewrite_document_to_image(parsed)
except Exception:
pass
return json.dumps(parsed).encode("utf-8")
# Hop-by-hop headers or auth we replace with the upstream-specific value.
_HOP_HEADERS = {
P_HOP_HEADERS = {
"host",
"content-length",
"authorization",
@@ -299,22 +299,22 @@ _HOP_HEADERS = {
}
def _is_claude_model(model: str) -> bool:
def p_is_claude_model(model: str) -> bool:
m = (model or "").strip().lower()
return m.startswith(_CLAUDE_MODEL_PREFIXES)
return m.startswith(P_CLAUDE_MODEL_PREFIXES)
def _is_gemini_model(model: str) -> bool:
def p_is_gemini_model(model: str) -> bool:
m = (model or "").strip().lower()
if m.startswith(_GEMINI_MODEL_PREFIXES):
if m.startswith(P_GEMINI_MODEL_PREFIXES):
return True
# Bare-name match for own-key Gemini; excludes anthropic-routed gemini (those carry "/").
if "/" in m:
return False
return any(m.startswith(p) for p in _GEMINI_BARE_MODEL_PATTERNS)
return any(m.startswith(p) for p in P_GEMINI_BARE_MODEL_PATTERNS)
def _pick_upstream(model: str) -> tuple[str, dict[str, str]]:
def p_pick_upstream(model: str) -> tuple[str, dict[str, str]]:
"""Return (base_url_without_v1, auth_headers) for this model.
Routing for Claude-family models:
@@ -326,7 +326,7 @@ def _pick_upstream(model: str) -> tuple[str, dict[str, str]]:
from backend.apps.settings.settings import load_settings
s = load_settings()
if _is_claude_model(model):
if p_is_claude_model(model):
if getattr(s, "connection_mode", "own_key") == "openswarm-pro":
bearer = getattr(s, "openswarm_bearer_token", "") or ""
proxy = (getattr(s, "openswarm_proxy_url", "") or "https://api.openswarm.com").rstrip("/")
@@ -352,9 +352,6 @@ def _pick_upstream(model: str) -> tuple[str, dict[str, str]]:
methods=["GET", "HEAD", "OPTIONS"],
include_in_schema=False,
)
async def _healthcheck():
"""CLI healthchecks the proxy root; return 200 so it doesn't 404."""
return {"ok": True}
@anthropic_proxy.router.api_route(
@@ -382,46 +379,46 @@ async def proxy(rest: str, request: Request):
parsed_for_bypass = None
if isinstance(parsed_for_bypass, dict):
from backend.apps.agents.proxy.anthropic_to_openai import (
should_bypass_9router as _should_bypass_oai,
should_bypass_9router_for_openrouter as _should_bypass_or,
forward_to_openai as _forward_oai,
forward_to_openrouter as _forward_or,
should_bypass_9router,
should_bypass_9router_for_openrouter,
forward_to_openai,
forward_to_openrouter,
)
from backend.apps.settings.settings import load_settings as _load
_s = _load()
if _is_openai_max_completion_tokens_model(model):
_oak = (getattr(_s, "openai_api_key", "") or "").strip()
if _should_bypass_oai(parsed_for_bypass, _oak):
status, body_stream, hdrs = await _forward_oai(
parsed_for_bypass, _oak,
from backend.apps.settings.settings import load_settings
s = load_settings()
if p_is_openai_max_completion_tokens_model(model):
oak = (getattr(s, "openai_api_key", "") or "").strip()
if should_bypass_9router(parsed_for_bypass, oak):
status, body_stream, hdrs = await forward_to_openai(
parsed_for_bypass, oak,
)
return StreamingResponse(
body_stream, status_code=status, headers=hdrs,
media_type=hdrs.get("content-type", "text/event-stream"),
)
if _is_openrouter_model(model):
_ork = (getattr(_s, "openrouter_api_key", "") or "").strip()
if _should_bypass_or(parsed_for_bypass, _ork):
status, body_stream, hdrs = await _forward_or(
parsed_for_bypass, _ork,
if p_is_openrouter_model(model):
ork = (getattr(s, "openrouter_api_key", "") or "").strip()
if should_bypass_9router_for_openrouter(parsed_for_bypass, ork):
status, body_stream, hdrs = await forward_to_openrouter(
parsed_for_bypass, ork,
)
return StreamingResponse(
body_stream, status_code=status, headers=hdrs,
media_type=hdrs.get("content-type", "text/event-stream"),
)
if _is_gemini_model(model):
body = _scrub_request_for_gemini(body)
if _is_openai_max_completion_tokens_model(model):
body = _scrub_request_for_openai_gpt5(body)
if _is_openrouter_model(model):
body = _inject_openrouter_file_parser(body)
if p_is_gemini_model(model):
body = p_scrub_request_for_gemini(body)
if p_is_openai_max_completion_tokens_model(model):
body = p_scrub_request_for_openai_gpt5(body)
if p_is_openrouter_model(model):
body = p_inject_openrouter_file_parser(body)
base_url, auth_headers = _pick_upstream(model)
base_url, auth_headers = p_pick_upstream(model)
forward_headers: dict[str, str] = {}
for k, v in request.headers.items():
if k.lower() in _HOP_HEADERS:
if k.lower() in P_HOP_HEADERS:
continue
# CLI carries our install token as x-api-key; never forward (leak + shadows real upstream auth).
if k.lower() == "x-api-key":
@@ -459,7 +456,7 @@ async def proxy(rest: str, request: Request):
streamer(),
status_code=upstream.status_code,
headers={k: v for k, v in upstream.headers.items()
if k.lower() not in _HOP_HEADERS},
if k.lower() not in P_HOP_HEADERS},
media_type=upstream.headers.get("content-type", "text/event-stream"),
)
else:
@@ -471,7 +468,7 @@ async def proxy(rest: str, request: Request):
return JSONResponse(
content=r.json() if r.headers.get("content-type", "").startswith("application/json") else {"raw": r.text},
status_code=r.status_code,
headers={k: v for k, v in r.headers.items() if k.lower() not in _HOP_HEADERS},
headers={k: v for k, v in r.headers.items() if k.lower() not in P_HOP_HEADERS},
)
except httpx.TimeoutException:
return JSONResponse({"error": "upstream timeout"}, status_code=504)
@@ -26,8 +26,8 @@ import httpx
logger = logging.getLogger(__name__)
_OPENAI_UPSTREAM = "https://api.openai.com/v1"
_OPENROUTER_UPSTREAM = "https://openrouter.ai/api/v1"
P_OPENAI_UPSTREAM = "https://api.openai.com/v1"
P_OPENROUTER_UPSTREAM = "https://openrouter.ai/api/v1"
# Concurrency cap for bypass-route requests. Each in-flight request holds
# the base64'd PDF (raw_bytes * 1.33) in memory across httpx's request
@@ -37,18 +37,18 @@ _OPENROUTER_UPSTREAM = "https://openrouter.ai/api/v1"
# concurrent probes. Cap at 2 so a Mehmet-style multi-PDF attach in one
# session can't take the whole backend down. Requests above the cap
# queue rather than fail.
_BYPASS_CONCURRENCY = 2
_bypass_sema = asyncio.Semaphore(_BYPASS_CONCURRENCY)
P_BYPASS_CONCURRENCY = 2
p_bypass_sema = asyncio.Semaphore(P_BYPASS_CONCURRENCY)
# Hard per-request body size ceiling. Anthropic API caps at 32MB,
# OpenAI Chat Completions at 50MB, OpenRouter at whatever underlying
# model accepts. We refuse anything over 40MB raw (≈53MB base64) before
# we even build the request body, so a malicious or accidental huge
# attach never reaches the in-memory pipeline.
_BYPASS_MAX_RAW_BYTES = 40 * 1024 * 1024
P_BYPASS_MAX_RAW_BYTES = 40 * 1024 * 1024
def _has_document_block(parsed: dict) -> bool:
def p_has_document_block(parsed: dict) -> bool:
msgs = parsed.get("messages")
if not isinstance(msgs, list):
return False
@@ -75,7 +75,7 @@ def should_bypass_9router(parsed: dict, api_key: str | None) -> bool:
return False
if parsed.get("tools"):
return False
return _has_document_block(parsed)
return p_has_document_block(parsed)
def should_bypass_9router_for_openrouter(parsed: dict, api_key: str | None) -> bool:
@@ -90,10 +90,10 @@ def should_bypass_9router_for_openrouter(parsed: dict, api_key: str | None) -> b
return False
if parsed.get("tools"):
return False
return _has_document_block(parsed)
return p_has_document_block(parsed)
def _content_blocks_to_openai(content) -> list[dict]:
def p_content_blocks_to_openai(content) -> list[dict]:
"""Convert Anthropic content blocks → OpenAI Chat Completions parts."""
if isinstance(content, str):
return [{"type": "text", "text": content}]
@@ -134,7 +134,7 @@ def _content_blocks_to_openai(content) -> list[dict]:
return out
def translate_request(parsed: dict) -> dict:
def p_translate_request(parsed: dict) -> dict:
"""Anthropic Messages request → OpenAI Chat Completions request."""
model = parsed.get("model") or ""
if "/" in model:
@@ -160,7 +160,7 @@ def translate_request(parsed: dict) -> dict:
role = m.get("role")
if role not in ("user", "assistant"):
continue
msgs_out.append({"role": role, "content": _content_blocks_to_openai(m.get("content"))})
msgs_out.append({"role": role, "content": p_content_blocks_to_openai(m.get("content"))})
openai_body["messages"] = msgs_out
mt = parsed.get("max_tokens")
@@ -176,12 +176,12 @@ def translate_request(parsed: dict) -> dict:
return openai_body
def _sse_event(event: str, data: dict) -> bytes:
def p_sse_event(event: str, data: dict) -> bytes:
"""Encode an Anthropic-format SSE event."""
return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode("utf-8")
async def _translate_response_stream(
async def p_translate_response_stream(
upstream: httpx.Response, model: str,
) -> AsyncIterator[bytes]:
"""Convert OpenAI Chat Completions SSE → Anthropic Messages SSE.
@@ -226,7 +226,7 @@ async def _translate_response_stream(
if not started:
usage = (ev.get("usage") or {})
input_tokens = int(usage.get("prompt_tokens") or 0)
yield _sse_event("message_start", {
yield p_sse_event("message_start", {
"type": "message_start",
"message": {
"id": msg_id,
@@ -255,13 +255,13 @@ async def _translate_response_stream(
delta_text = delta.get("content")
if isinstance(delta_text, str) and delta_text:
if not block_opened:
yield _sse_event("content_block_start", {
yield p_sse_event("content_block_start", {
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": ""},
})
block_opened = True
yield _sse_event("content_block_delta", {
yield p_sse_event("content_block_delta", {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": delta_text},
@@ -277,15 +277,15 @@ async def _translate_response_stream(
finally:
if started:
if block_opened:
yield _sse_event("content_block_stop", {
yield p_sse_event("content_block_stop", {
"type": "content_block_stop", "index": 0,
})
yield _sse_event("message_delta", {
yield p_sse_event("message_delta", {
"type": "message_delta",
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
"usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
})
yield _sse_event("message_stop", {"type": "message_stop"})
yield p_sse_event("message_stop", {"type": "message_stop"})
async def forward_to_openai(
@@ -293,8 +293,8 @@ async def forward_to_openai(
) -> tuple[int, AsyncIterator[bytes], dict[str, str]]:
"""Translate + forward an Anthropic request to OpenAI Chat Completions.
Returns (status, body_stream, response_headers)."""
openai_body = translate_request(parsed)
return await _forward(openai_body, api_key, f"{_OPENAI_UPSTREAM}/chat/completions")
openai_body = p_translate_request(parsed)
return await p_forward(openai_body, api_key, f"{P_OPENAI_UPSTREAM}/chat/completions")
async def forward_to_openrouter(
@@ -302,7 +302,7 @@ async def forward_to_openrouter(
) -> tuple[int, AsyncIterator[bytes], dict[str, str]]:
"""Translate + forward to OpenRouter, injecting the file-parser plugin
so any OR model parses the attached PDFs server-side."""
openai_body = translate_request(parsed)
openai_body = p_translate_request(parsed)
model = (parsed.get("model") or "").lower()
bare = model
for prefix in ("openrouter/", "or:"):
@@ -311,10 +311,10 @@ async def forward_to_openrouter(
break
openai_body["model"] = bare
openai_body["plugins"] = [{"id": "file-parser", "pdf": {"engine": "pdf-text"}}]
return await _forward(openai_body, api_key, f"{_OPENROUTER_UPSTREAM}/chat/completions")
return await p_forward(openai_body, api_key, f"{P_OPENROUTER_UPSTREAM}/chat/completions")
def _estimate_body_bytes(body_json: dict) -> int:
def p_estimate_body_bytes(body_json: dict) -> int:
"""Sum the base64 payload bytes across content blocks. Used as a
cheap pre-flight check before httpx serializes the body."""
total = 0
@@ -336,12 +336,12 @@ def _estimate_body_bytes(body_json: dict) -> int:
return total
async def _forward(
async def p_forward(
body_json: dict, api_key: str, url: str,
) -> tuple[int, AsyncIterator[bytes], dict[str, str]]:
# Pre-flight size check. base64 expands ~4/3 so 40MB raw → 53MB b64.
raw_estimate = int(_estimate_body_bytes(body_json) * 0.75)
if raw_estimate > _BYPASS_MAX_RAW_BYTES:
raw_estimate = int(p_estimate_body_bytes(body_json) * 0.75)
if raw_estimate > P_BYPASS_MAX_RAW_BYTES:
async def reject():
payload = json.dumps({
@@ -350,7 +350,7 @@ async def _forward(
"type": "invalid_request_error",
"message": (
f"Attached files total ~{raw_estimate // (1024*1024)} MB, "
f"over the {_BYPASS_MAX_RAW_BYTES // (1024*1024)} MB per-request "
f"over the {P_BYPASS_MAX_RAW_BYTES // (1024*1024)} MB per-request "
"cap on this provider lane. Detach a file or split across "
"separate turns."
),
@@ -371,14 +371,14 @@ async def _forward(
# ~40MB request body + a streaming response buffer, and the OS
# OOM-kills the backend (observed on macOS during a 3-PDF probe
# burst). Semaphore serializes excess requests instead of failing.
await _bypass_sema.acquire()
await p_bypass_sema.acquire()
client = httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=30.0))
try:
req = client.build_request("POST", url, json=body_json, headers=headers)
upstream = await client.send(req, stream=True)
except Exception:
await client.aclose()
_bypass_sema.release()
p_bypass_sema.release()
raise
async def streamer():
@@ -387,7 +387,7 @@ async def _forward(
raw = await upstream.aread()
yield raw
return
async for chunk in _translate_response_stream(upstream, body_json["model"]):
async for chunk in p_translate_response_stream(upstream, body_json["model"]):
yield chunk
finally:
try:
@@ -396,7 +396,7 @@ async def _forward(
try:
await client.aclose()
finally:
_bypass_sema.release()
p_bypass_sema.release()
return upstream.status_code, streamer(), {
"content-type": "text/event-stream" if upstream.status_code < 400 else "application/json",
+9 -9
View File
@@ -27,7 +27,7 @@ class SSRFBlocked(Exception):
"""A fetch was refused because it targets a forbidden IP range."""
_BLOCKED_V4_NETS = [
P_BLOCKED_V4_NETS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
@@ -38,7 +38,7 @@ _BLOCKED_V4_NETS = [
ipaddress.ip_network("198.18.0.0/15"), # benchmarking
]
_BLOCKED_V6_NETS = [
P_BLOCKED_V6_NETS = [
ipaddress.ip_network("fe80::/10"), # link-local
ipaddress.ip_network("fc00::/7"), # ULA
ipaddress.ip_network("ff00::/8"), # multicast
@@ -46,7 +46,7 @@ _BLOCKED_V6_NETS = [
]
async def _resolve_host_async(host: str) -> list[str]:
async def p_resolve_host_async(host: str) -> list[str]:
"""Resolve host to all IPs (v4 + v6) without blocking the event loop."""
loop = asyncio.get_event_loop()
try:
@@ -56,7 +56,7 @@ async def _resolve_host_async(host: str) -> list[str]:
return list({info[4][0] for info in infos})
def _is_forbidden_ip(ip_str: str) -> bool:
def p_is_forbidden_ip(ip_str: str) -> bool:
"""True iff this IP is in a blocked range. Loopback is allowed (see module docstring)."""
try:
ip = ipaddress.ip_address(ip_str)
@@ -65,8 +65,8 @@ def _is_forbidden_ip(ip_str: str) -> bool:
if ip.is_loopback:
return False
if ip.version == 4:
return any(ip in net for net in _BLOCKED_V4_NETS)
return any(ip in net for net in _BLOCKED_V6_NETS)
return any(ip in net for net in P_BLOCKED_V4_NETS)
return any(ip in net for net in P_BLOCKED_V6_NETS)
async def assert_safe_url(url: str) -> str:
@@ -88,17 +88,17 @@ async def assert_safe_url(url: str) -> str:
try:
ipaddress.ip_address(host)
if _is_forbidden_ip(host):
if p_is_forbidden_ip(host):
raise SSRFBlocked(f"URL host {host} is in a blocked range.")
return url
except ValueError:
pass
resolved = await _resolve_host_async(host)
resolved = await p_resolve_host_async(host)
if not resolved:
raise SSRFBlocked(f"No DNS records for {host}.")
for ip in resolved:
if _is_forbidden_ip(ip):
if p_is_forbidden_ip(ip):
raise SSRFBlocked(f"Host {host} resolves to forbidden IP {ip}.")
return url
+22 -22
View File
@@ -10,9 +10,9 @@ import httpx
from backend.apps.agents.tools.base import BaseTool
from backend.apps.agents.tools.ssrf_guard import SSRFBlocked, safe_fetch
_HTTP_TIMEOUT = 30
_MAX_OUTPUT_BYTES = 250 * 1024 # ~250 KB covers ~95% of articles/wikis/docs.
_USER_AGENT = (
P_HTTP_TIMEOUT = 30
P_MAX_OUTPUT_BYTES = 250 * 1024 # ~250 KB covers ~95% of articles/wikis/docs.
P_USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
@@ -47,13 +47,13 @@ def anthropic_web_search_is_reliable(*, uses_direct_anthropic_api: bool,
return bool(uses_direct_anthropic_api or is_pro)
def _truncate(text: str, limit: int = _MAX_OUTPUT_BYTES) -> str:
def p_truncate(text: str, limit: int = P_MAX_OUTPUT_BYTES) -> str:
if len(text) > limit:
return text[:limit] + "\n... (output truncated)"
return text
def _strip_html(raw_html: str) -> str:
def p_strip_html(raw_html: str) -> str:
"""Naive but effective HTML to plain-text conversion."""
text = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", raw_html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<[^>]+>", " ", text)
@@ -64,13 +64,13 @@ def _strip_html(raw_html: str) -> str:
class WebSearchTool(BaseTool):
name = "WebSearch"
description = (
p_name = "WebSearch"
p_description = (
"Search the web using DuckDuckGo and return titles, URLs, and "
"snippets for the top results."
)
def get_schema(self) -> dict:
def p_get_schema(self) -> dict:
return {
"type": "object",
"properties": {
@@ -88,12 +88,12 @@ class WebSearchTool(BaseTool):
"additionalProperties": False,
}
async def execute(self, input_data: dict) -> list[dict]:
async def p_execute(self, input_data: dict) -> list[dict]:
query: str = input_data["query"]
num_results: int = input_data.get("num_results", 5)
try:
results = await self._search_ddg(query, num_results)
results = await self.search_ddg(query, num_results)
if not results:
return [{"type": "text", "text": f"No search results found for: {query}"}]
return [{"type": "text", "text": results}]
@@ -106,12 +106,12 @@ class WebSearchTool(BaseTool):
return [{"type": "text", "text": f"Web search error: {exc}"}]
@staticmethod
async def _search_ddg(query: str, num_results: int) -> str:
async def search_ddg(query: str, num_results: int) -> str:
"""Query DuckDuckGo HTML endpoint and parse results."""
async with httpx.AsyncClient(
timeout=_HTTP_TIMEOUT,
timeout=P_HTTP_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": _USER_AGENT},
headers={"User-Agent": P_USER_AGENT},
) as client:
resp = await client.post(
"https://html.duckduckgo.com/html/",
@@ -160,14 +160,14 @@ class WebSearchTool(BaseTool):
if "/y.js?" in raw_url or "ad_provider=" in raw_url or "ad_domain=" in raw_url:
continue
title = _strip_html(link_match.group(2)).strip()
title = p_strip_html(link_match.group(2)).strip()
snippet_match = re.search(
r'<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>',
block,
flags=re.DOTALL,
)
snippet = _strip_html(snippet_match.group(1)).strip() if snippet_match else ""
snippet = p_strip_html(snippet_match.group(1)).strip() if snippet_match else ""
# DDG wraps URLs in a redirect; extract the real one.
real_url_match = re.search(r"uddg=([^&]+)", raw_url)
@@ -186,13 +186,13 @@ class WebSearchTool(BaseTool):
class WebFetchTool(BaseTool):
name = "WebFetch"
description = (
p_name = "WebFetch"
p_description = (
"Fetch the contents of a URL and return the extracted text. "
"HTML is stripped to plain text. Output capped at ~250 KB."
)
def get_schema(self) -> dict:
def p_get_schema(self) -> dict:
return {
"type": "object",
"properties": {
@@ -217,8 +217,8 @@ class WebFetchTool(BaseTool):
resp = await safe_fetch(
url,
method="GET",
headers={"User-Agent": _USER_AGENT},
timeout=_HTTP_TIMEOUT,
headers={"User-Agent": P_USER_AGENT},
timeout=P_HTTP_TIMEOUT,
)
resp.raise_for_status()
except SSRFBlocked as exc:
@@ -245,11 +245,11 @@ class WebFetchTool(BaseTool):
except Exception:
text = None
if not text:
text = _strip_html(resp.text)
text = p_strip_html(resp.text)
else:
text = resp.text
text = _truncate(text)
text = p_truncate(text)
header = f"Contents of {url}:"
if prompt:
+3 -3
View File
@@ -76,7 +76,7 @@ def send_response(id_, result=None, error=None):
sys.stdout.flush()
def _post(url: str, body: dict, timeout: float = 60.0) -> dict:
def p_post(url: str, body: dict, timeout: float = 60.0) -> dict:
payload = json.dumps(body).encode()
headers = {"Content-Type": "application/json"}
if BACKEND_AUTH:
@@ -107,7 +107,7 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
body = {"query": query, "num_results": num}
if PRIMARY_HINT:
body["primary"] = PRIMARY_HINT
r = _post(SEARCH_URL, body, timeout=45.0)
r = p_post(SEARCH_URL, body, timeout=45.0)
if "error" in r:
return {"content": [{"type": "text", "text": f"Search failed: {r['error']}"}], "isError": True}
results = r.get("results", "")
@@ -127,7 +127,7 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
body["prompt"] = str(prompt)
if PRIMARY_HINT:
body["primary"] = PRIMARY_HINT
r = _post(FETCH_URL, body, timeout=45.0)
r = p_post(FETCH_URL, body, timeout=45.0)
if "error" in r:
return {"content": [{"type": "text", "text": f"Fetch failed: {r['error']}"}], "isError": True}
content = r.get("content", "")
+11 -11
View File
@@ -40,14 +40,14 @@ async def auth_lifespan():
auth = SubApp("auth", auth_lifespan)
def _proxy_url() -> str:
def p_proxy_url() -> str:
settings_obj = load_settings()
url = (getattr(settings_obj, "openswarm_proxy_url", None)
or OPENSWARM_DEFAULT_PROXY_URL)
return url.rstrip("/")
async def _sync_pro_routing(settings_obj) -> None:
async def p_sync_pro_routing(settings_obj) -> None:
"""Mirror connection state into 9Router's Claude lane; sign-in can flip a
paying user into pro mode and sign-out must tear the lane down so a
revoked bearer doesn't linger in the router."""
@@ -58,11 +58,11 @@ async def _sync_pro_routing(settings_obj) -> None:
logger.debug("pro routing sync skipped: %s", e)
def _sync_identity_to_service(settings_obj) -> None:
def p_sync_identity_to_service(settings_obj) -> None:
"""Push user_id + email + signin_method into the service-sync identify
pipeline so every event from this user has the right Person properties."""
try:
from backend.apps.service.client import identify as _identify
from backend.apps.service.client import identify
except Exception:
return
props = {
@@ -73,7 +73,7 @@ def _sync_identity_to_service(settings_obj) -> None:
if email:
props["email"] = email
try:
_identify(props)
identify(props)
except Exception as e:
logger.debug("identify sync failed: %s", e)
@@ -101,7 +101,7 @@ async def signin_activate(body: SigninActivateRequest):
if not body.token or len(body.token) < 16:
raise HTTPException(status_code=400, detail="Invalid token")
proxy = _proxy_url()
proxy = p_proxy_url()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(
@@ -157,8 +157,8 @@ async def signin_activate(body: SigninActivateRequest):
settings_obj.openswarm_proxy_url = proxy
await save_settings_async(settings_obj)
_sync_identity_to_service(settings_obj)
await _sync_pro_routing(settings_obj)
p_sync_identity_to_service(settings_obj)
await p_sync_pro_routing(settings_obj)
return {
"ok": True,
@@ -185,7 +185,7 @@ async def signout():
"""
settings_obj = load_settings()
bearer = getattr(settings_obj, "openswarm_bearer_token", None)
proxy = _proxy_url()
proxy = p_proxy_url()
if bearer:
try:
async with httpx.AsyncClient(timeout=5.0) as client:
@@ -245,6 +245,6 @@ async def signout():
settings_obj.openswarm_subscription_expires = None
settings_obj.openswarm_usage_cached = None
await save_settings_async(settings_obj)
_sync_identity_to_service(settings_obj)
await _sync_pro_routing(settings_obj)
p_sync_identity_to_service(settings_obj)
await p_sync_pro_routing(settings_obj)
return {"ok": True}
@@ -1,58 +0,0 @@
import json
import os
import logging
from contextlib import asynccontextmanager
from backend.config.Apps import SubApp
from backend.apps.dashboard_layout.models import DashboardLayout, DashboardLayoutUpdate
logger = logging.getLogger(__name__)
from backend.config.paths import DASHBOARD_LAYOUT_DIR as DATA_DIR
LAYOUT_FILE = os.path.join(DATA_DIR, "layout.json")
@asynccontextmanager
async def dashboard_layout_lifespan():
os.makedirs(DATA_DIR, exist_ok=True)
yield
dashboard_layout = SubApp("dashboard_layout", dashboard_layout_lifespan)
def _default_layout() -> DashboardLayout:
return DashboardLayout(cards={})
def _load() -> DashboardLayout:
if not os.path.exists(LAYOUT_FILE):
return _default_layout()
try:
with open(LAYOUT_FILE) as f:
data = json.load(f)
if "columns" in data and "cards" not in data:
logger.info("Detected old column-based layout format, resetting to empty canvas")
return _default_layout()
return DashboardLayout(**data)
except Exception:
logger.exception("Failed to load dashboard layout, returning default")
return _default_layout()
def _save(layout: DashboardLayout):
with open(LAYOUT_FILE, "w") as f:
json.dump(layout.model_dump(), f, indent=2)
@dashboard_layout.router.get("")
async def get_layout():
layout = _load()
return layout.model_dump()
@dashboard_layout.router.put("")
async def update_layout(body: DashboardLayoutUpdate):
layout = DashboardLayout(cards=body.cards, view_cards=body.view_cards)
_save(layout)
return layout.model_dump()
-27
View File
@@ -1,27 +0,0 @@
from pydantic import BaseModel, Field
class CardPosition(BaseModel):
session_id: str
x: float = 0
y: float = 0
width: float = 420
height: float = 280
class ViewCardPosition(BaseModel):
output_id: str
x: float = 0
y: float = 0
width: float = 480
height: float = 360
class DashboardLayout(BaseModel):
cards: dict[str, CardPosition] = Field(default_factory=dict)
view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict)
class DashboardLayoutUpdate(BaseModel):
cards: dict[str, CardPosition]
view_cards: dict[str, ViewCardPosition] = Field(default_factory=dict)
+1 -1
View File
@@ -450,7 +450,7 @@ async def search(body: SearchBody) -> dict:
# 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)
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.