mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-22 04:32:22 +02:00
482 lines
17 KiB
Python
482 lines
17 KiB
Python
"""Anthropic-format HTTP proxy splitting requests by model field; primary to 9Router, aux Claude to Pro proxy."""
|
|
|
|
import json
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
import httpx
|
|
from fastapi import Request
|
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
|
|
from backend.config.Apps import SubApp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def anthropic_proxy_lifespan():
|
|
yield
|
|
|
|
|
|
anthropic_proxy = SubApp("anthropic-proxy", anthropic_proxy_lifespan)
|
|
|
|
|
|
_CLAUDE_MODEL_PREFIXES = (
|
|
"claude-",
|
|
"claude/",
|
|
"sonnet",
|
|
"opus",
|
|
"haiku",
|
|
"cc/",
|
|
)
|
|
|
|
_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-",)
|
|
|
|
# Keys 9Router 0.3.60 misses that Gemini's function_declarations validator 400s on. Each was caught in prod.
|
|
_GEMINI_FORBIDDEN_SCHEMA_KEYS = {
|
|
"$schema",
|
|
"$id",
|
|
"$ref",
|
|
"$defs",
|
|
"definitions",
|
|
"additionalProperties",
|
|
"propertyNames",
|
|
"patternProperties",
|
|
"exclusiveMinimum",
|
|
"exclusiveMaximum",
|
|
"const",
|
|
"prefill",
|
|
"enumTitles",
|
|
"title",
|
|
"examples",
|
|
"default",
|
|
"readOnly",
|
|
"writeOnly",
|
|
"deprecated",
|
|
}
|
|
|
|
|
|
def _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:
|
|
node.pop(k, None)
|
|
continue
|
|
node[k] = _scrub_gemini_schema(node[k])
|
|
return node
|
|
if isinstance(node, list):
|
|
for i, v in enumerate(node):
|
|
node[i] = _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",)
|
|
|
|
|
|
def _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:
|
|
return False
|
|
for prefix in ("openai/", "cx/", "openrouter/", "or:openai/", "cp/", "cp-"):
|
|
if m.startswith(prefix):
|
|
m = m[len(prefix):]
|
|
break
|
|
return any(m.startswith(p) for p in _OPENAI_MAX_COMPLETION_TOKENS_MODELS)
|
|
|
|
|
|
def _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):
|
|
return
|
|
counter = 0
|
|
for m in msgs:
|
|
content = m.get("content") if isinstance(m, dict) else None
|
|
if not isinstance(content, list):
|
|
continue
|
|
for block in content:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
btype = block.get("type")
|
|
src = block.get("source") or {}
|
|
if not isinstance(src, dict) or src.get("type") != "base64":
|
|
continue
|
|
data = src.get("data")
|
|
if not isinstance(data, str) or not data:
|
|
continue
|
|
media_type = src.get("media_type") or ""
|
|
|
|
# 9router 0.3.60 chunk 318 stringifies ANY non-`text`/`image_url`
|
|
# block. Image blocks → image_url with data: URL.
|
|
# PDFs on OpenAI direct are REFUSED upstream (agent_manager
|
|
# _resolve_attachments has openai NOT in supports_pdf) because
|
|
# OpenAI Chat Completions rejects non-image mime types inside
|
|
# image_url with "Invalid MIME type. Only image types are
|
|
# supported." (verified empirically May 2026). The shipping
|
|
# path for OpenAI PDFs is openrouter/openai/gpt-5 which uses
|
|
# OR's file-parser plugin.
|
|
if btype != "image":
|
|
continue
|
|
mt = media_type or "image/png"
|
|
block.clear()
|
|
block["type"] = "image_url"
|
|
block["image_url"] = {
|
|
"url": f"data:{mt};base64,{data}",
|
|
}
|
|
|
|
|
|
def _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."""
|
|
if not body:
|
|
return body
|
|
try:
|
|
parsed = json.loads(body)
|
|
except Exception:
|
|
return body
|
|
if not isinstance(parsed, dict):
|
|
return body
|
|
mutated = False
|
|
if "max_tokens" in parsed and "max_completion_tokens" not in parsed:
|
|
parsed["max_completion_tokens"] = parsed.pop("max_tokens")
|
|
mutated = True
|
|
elif "max_tokens" in parsed and "max_completion_tokens" in parsed:
|
|
parsed.pop("max_tokens", None)
|
|
mutated = True
|
|
try:
|
|
before = json.dumps(parsed.get("messages"), sort_keys=True) if "messages" in parsed else ""
|
|
_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
|
|
except Exception:
|
|
pass
|
|
return json.dumps(parsed).encode("utf-8") if mutated else body
|
|
|
|
|
|
def _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
|
|
Gemini's `inlineData` (verified in router/.next/server/chunks/318.js:
|
|
`b.image_url?.url?.startsWith('data:')` → builds `{inlineData:{mime_type,data}}`).
|
|
|
|
Anthropic-shape `image`/`document` blocks fall through 9router's
|
|
content filter and either get stringified or dropped, which is why
|
|
PDFs were silently missing from Gemini requests until this rewrite.
|
|
|
|
For PDFs we set mime_type=application/pdf in the data URL; Gemini's
|
|
inlineData accepts it natively.
|
|
|
|
Strictly defensive: rewrite only when source.type='base64' and data
|
|
is present. Unknown shapes pass through untouched."""
|
|
msgs = parsed.get("messages") if isinstance(parsed, dict) else None
|
|
if not isinstance(msgs, list):
|
|
return
|
|
for m in msgs:
|
|
content = m.get("content") if isinstance(m, dict) else None
|
|
if not isinstance(content, list):
|
|
continue
|
|
for block in content:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
btype = block.get("type")
|
|
if btype not in ("document", "image"):
|
|
continue
|
|
src = block.get("source") or {}
|
|
if not isinstance(src, dict) or src.get("type") != "base64":
|
|
continue
|
|
data = src.get("data")
|
|
if not isinstance(data, str) or not data:
|
|
continue
|
|
if btype == "document":
|
|
media_type = src.get("media_type") or "application/pdf"
|
|
else:
|
|
media_type = src.get("media_type") or "image/png"
|
|
block.clear()
|
|
block["type"] = "image_url"
|
|
block["image_url"] = {
|
|
"url": f"data:{media_type};base64,{data}",
|
|
}
|
|
|
|
|
|
_OPENROUTER_MODEL_PREFIXES = ("openrouter/", "or:")
|
|
|
|
|
|
def _is_openrouter_model(model: str) -> bool:
|
|
m = (model or "").strip().lower()
|
|
return any(m.startswith(p) for p in _OPENROUTER_MODEL_PREFIXES)
|
|
|
|
|
|
def _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
|
|
still see the document directly). The plugins field sits at the top
|
|
level alongside `messages`; we don't touch the message content blocks,
|
|
OR's normaliser handles Anthropic→target translation.
|
|
Bytes-in/out, never raises."""
|
|
if not body:
|
|
return body
|
|
try:
|
|
parsed = json.loads(body)
|
|
except Exception:
|
|
return body
|
|
if not isinstance(parsed, dict):
|
|
return body
|
|
msgs = parsed.get("messages")
|
|
if not isinstance(msgs, list):
|
|
return body
|
|
has_doc = False
|
|
for m in msgs:
|
|
content = m.get("content") if isinstance(m, dict) else None
|
|
if not isinstance(content, list):
|
|
continue
|
|
for block in content:
|
|
if isinstance(block, dict) and block.get("type") == "document":
|
|
has_doc = True
|
|
break
|
|
if has_doc:
|
|
break
|
|
if not has_doc:
|
|
return body
|
|
existing = parsed.get("plugins")
|
|
plugins = existing if isinstance(existing, list) else []
|
|
if not any(isinstance(p, dict) and p.get("id") == "file-parser" for p in plugins):
|
|
plugins.append({"id": "file-parser", "pdf": {"engine": "pdf-text"}})
|
|
parsed["plugins"] = plugins
|
|
return json.dumps(parsed).encode("utf-8")
|
|
|
|
|
|
def _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."""
|
|
if not body:
|
|
return body
|
|
try:
|
|
parsed = json.loads(body)
|
|
except Exception:
|
|
return body
|
|
tools = parsed.get("tools") if isinstance(parsed, dict) else None
|
|
if isinstance(tools, list):
|
|
for t in tools:
|
|
if not isinstance(t, dict):
|
|
continue
|
|
if isinstance(t.get("input_schema"), (dict, list)):
|
|
_scrub_gemini_schema(t["input_schema"])
|
|
if isinstance(t.get("parameters"), (dict, list)):
|
|
_scrub_gemini_schema(t["parameters"])
|
|
try:
|
|
if isinstance(parsed, dict):
|
|
_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 = {
|
|
"host",
|
|
"content-length",
|
|
"authorization",
|
|
"x-api-key",
|
|
"connection",
|
|
"keep-alive",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"te",
|
|
"trailers",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
}
|
|
|
|
|
|
def _is_claude_model(model: str) -> bool:
|
|
m = (model or "").strip().lower()
|
|
return m.startswith(_CLAUDE_MODEL_PREFIXES)
|
|
|
|
|
|
def _is_gemini_model(model: str) -> bool:
|
|
m = (model or "").strip().lower()
|
|
if m.startswith(_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)
|
|
|
|
|
|
def _pick_upstream(model: str) -> tuple[str, dict[str, str]]:
|
|
"""Return (base_url_without_v1, auth_headers) for this model.
|
|
|
|
Routing for Claude-family models:
|
|
1. openswarm-pro mode → cloud proxy with bearer
|
|
2. Direct Anthropic API key set → api.anthropic.com (preferred when
|
|
user has their own key, avoids the 8h OAuth expiry pain)
|
|
3. Fallback → 9router (cc/ OAuth subscription, may 401 if expired)
|
|
Everything non-Claude goes to 9router for translation."""
|
|
from backend.apps.settings.settings import load_settings
|
|
s = load_settings()
|
|
|
|
if _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("/")
|
|
if bearer and proxy:
|
|
return (proxy, {"Authorization": f"Bearer {bearer}"})
|
|
ak = getattr(s, "anthropic_api_key", "") or ""
|
|
if ak.strip():
|
|
return ("https://api.anthropic.com", {
|
|
"x-api-key": ak.strip(),
|
|
"anthropic-version": "2023-06-01",
|
|
})
|
|
|
|
return ("http://127.0.0.1:20128", {"x-api-key": "9router"})
|
|
|
|
|
|
@anthropic_proxy.router.api_route(
|
|
"",
|
|
methods=["GET", "HEAD", "OPTIONS"],
|
|
include_in_schema=False,
|
|
)
|
|
@anthropic_proxy.router.api_route(
|
|
"/",
|
|
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(
|
|
"/v1/{rest:path}",
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
|
)
|
|
async def proxy(rest: str, request: Request):
|
|
body = await request.body()
|
|
model = ""
|
|
if body:
|
|
try:
|
|
parsed = json.loads(body)
|
|
model = str(parsed.get("model") or "")
|
|
except Exception:
|
|
pass
|
|
|
|
# 9router-bypass paths for PDF-bearing requests on providers where
|
|
# 9router 0.3.60 strips or mangles the relevant content/plugin
|
|
# fields. We translate + POST directly to the provider's API and
|
|
# convert the streaming response back to Anthropic SSE so the
|
|
# bundled Claude CLI subprocess consumes it unchanged.
|
|
try:
|
|
parsed_for_bypass = json.loads(body) if body else None
|
|
except Exception:
|
|
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,
|
|
)
|
|
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,
|
|
)
|
|
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,
|
|
)
|
|
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)
|
|
|
|
base_url, auth_headers = _pick_upstream(model)
|
|
|
|
forward_headers: dict[str, str] = {}
|
|
for k, v in request.headers.items():
|
|
if k.lower() in _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":
|
|
continue
|
|
forward_headers[k] = v
|
|
forward_headers.update(auth_headers)
|
|
|
|
url = f"{base_url}/v1/{rest}"
|
|
wants_stream = False
|
|
if body:
|
|
try:
|
|
wants_stream = bool(json.loads(body).get("stream"))
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
if wants_stream:
|
|
client = httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=30.0))
|
|
req = client.build_request(
|
|
request.method, url, content=body, headers=forward_headers,
|
|
params=dict(request.query_params),
|
|
)
|
|
upstream = await client.send(req, stream=True)
|
|
|
|
async def streamer():
|
|
try:
|
|
async for chunk in upstream.aiter_raw():
|
|
if chunk:
|
|
yield chunk
|
|
finally:
|
|
await upstream.aclose()
|
|
await client.aclose()
|
|
|
|
return StreamingResponse(
|
|
streamer(),
|
|
status_code=upstream.status_code,
|
|
headers={k: v for k, v in upstream.headers.items()
|
|
if k.lower() not in _HOP_HEADERS},
|
|
media_type=upstream.headers.get("content-type", "text/event-stream"),
|
|
)
|
|
else:
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=30.0)) as client:
|
|
r = await client.request(
|
|
request.method, url, content=body, headers=forward_headers,
|
|
params=dict(request.query_params),
|
|
)
|
|
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},
|
|
)
|
|
except httpx.TimeoutException:
|
|
return JSONResponse({"error": "upstream timeout"}, status_code=504)
|
|
except Exception as e:
|
|
logger.warning(f"anthropic-proxy error: {e}")
|
|
return JSONResponse({"error": str(e)[:300]}, status_code=502)
|