mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 05:07:40 +02:00
[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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user