mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
525 lines
20 KiB
Python
525 lines
20 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)
|
|
|
|
|
|
P_CLAUDE_MODEL_PREFIXES = (
|
|
"claude-",
|
|
"claude/",
|
|
"sonnet",
|
|
"opus",
|
|
"haiku",
|
|
"cc/",
|
|
)
|
|
|
|
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.
|
|
P_GEMINI_BARE_MODEL_PATTERNS = ("gemini-",)
|
|
|
|
# Gemini's function_declarations validator accepts only a small OpenAPI subset.
|
|
# A denylist was whack-a-mole: every new JSON Schema construct that slipped
|
|
# through (union `type`, anyOf, $comment, format, ...) was a fresh prod 400 with
|
|
# zero tokens in. We invert it: keep ONLY the keys Gemini is known to accept, and
|
|
# fold the two "optional" encodings Anthropic emits (a union `type` list, and an
|
|
# anyOf whose other branch is `{"type":"null"}`) into the `nullable` flag Gemini
|
|
# actually understands. Everything dropped is advisory; the model still reads it
|
|
# from `description`. The win is structural: an unknown future key can't 400 us.
|
|
P_GEMINI_ALLOWED_SCHEMA_KEYS = {
|
|
"type", "description", "nullable", "enum", "items", "properties",
|
|
"required", "minimum", "maximum", "minItems", "maxItems",
|
|
}
|
|
|
|
P_GEMINI_NULL_TYPES = {"null", None}
|
|
|
|
|
|
def normalize_schema_for_gemini(node):
|
|
"""Allowlist-rewrite a JSON Schema node into the subset Gemini accepts.
|
|
Returns a NEW node (callers must assign the result); folds union/anyOf
|
|
nullability into `nullable`. Never raises on odd input."""
|
|
if isinstance(node, list):
|
|
return [normalize_schema_for_gemini(v) for v in node]
|
|
if not isinstance(node, dict):
|
|
return node
|
|
|
|
nullable = bool(node.get("nullable"))
|
|
|
|
# Gemini can't represent unions; collapse anyOf/oneOf/allOf to one branch.
|
|
# A bare {"type": "null"} member just means the field is nullable.
|
|
for combiner in ("anyOf", "oneOf", "allOf"):
|
|
branches = node.get(combiner)
|
|
if isinstance(branches, list) and branches:
|
|
picked = None
|
|
for b in branches:
|
|
if isinstance(b, dict) and b.get("type") in P_GEMINI_NULL_TYPES and len(b) == 1:
|
|
nullable = True
|
|
elif picked is None:
|
|
picked = b
|
|
base = normalize_schema_for_gemini(picked) if isinstance(picked, dict) else {}
|
|
if nullable and isinstance(base, dict):
|
|
base["nullable"] = True
|
|
return base
|
|
|
|
out = {}
|
|
t = node.get("type")
|
|
if isinstance(t, list): # ["string", "null"] -> "string" + nullable
|
|
non_null = [x for x in t if x not in P_GEMINI_NULL_TYPES]
|
|
if len(non_null) != len(t):
|
|
nullable = True
|
|
t = non_null[0] if non_null else None
|
|
if t is not None:
|
|
out["type"] = t
|
|
|
|
for k, v in node.items():
|
|
if k in ("type", "nullable") or k not in P_GEMINI_ALLOWED_SCHEMA_KEYS:
|
|
continue
|
|
if k == "properties" and isinstance(v, dict):
|
|
out[k] = {pk: normalize_schema_for_gemini(pv) for pk, pv in v.items()}
|
|
elif k == "items":
|
|
out[k] = normalize_schema_for_gemini(v)
|
|
else:
|
|
out[k] = v
|
|
|
|
if nullable:
|
|
out["nullable"] = True
|
|
return out
|
|
|
|
|
|
# GPT-5.x rejects max_tokens; needs max_completion_tokens. Anthropic-format wire still emits max_tokens; we rename on the way out.
|
|
P_OPENAI_MAX_COMPLETION_TOKENS_MODELS = ("gpt-5",)
|
|
|
|
|
|
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:
|
|
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 P_OPENAI_MAX_COMPLETION_TOKENS_MODELS)
|
|
|
|
|
|
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):
|
|
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
|
|
# GPT-5 reasoning models reject sampling knobs (temperature must be 1, top_p
|
|
# and penalties unsupported); the wire carries them for the user's picked model.
|
|
if "temperature" in parsed and parsed["temperature"] != 1:
|
|
parsed.pop("temperature", None)
|
|
mutated = True
|
|
for p_k in ("top_p", "top_k", "frequency_penalty", "presence_penalty",
|
|
"logprobs", "top_logprobs", "logit_bias"):
|
|
if parsed.pop(p_k, None) is not None:
|
|
mutated = True
|
|
try:
|
|
before = json.dumps(parsed.get("messages"), sort_keys=True) if "messages" in parsed else ""
|
|
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
|
|
except Exception:
|
|
pass
|
|
return json.dumps(parsed).encode("utf-8") if mutated else body
|
|
|
|
|
|
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
|
|
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}",
|
|
}
|
|
|
|
|
|
P_OPENROUTER_MODEL_PREFIXES = ("openrouter/", "or:")
|
|
|
|
|
|
def p_is_openrouter_model(model: str) -> bool:
|
|
m = (model or "").strip().lower()
|
|
return any(m.startswith(p) for p in P_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)):
|
|
t["input_schema"] = normalize_schema_for_gemini(t["input_schema"])
|
|
if isinstance(t.get("parameters"), (dict, list)):
|
|
t["parameters"] = normalize_schema_for_gemini(t["parameters"])
|
|
try:
|
|
if isinstance(parsed, dict):
|
|
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.
|
|
P_HOP_HEADERS = {
|
|
"host",
|
|
"content-length",
|
|
"authorization",
|
|
"x-api-key",
|
|
"connection",
|
|
"keep-alive",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"te",
|
|
"trailers",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
}
|
|
|
|
|
|
def p_is_claude_model(model: str) -> bool:
|
|
m = (model or "").strip().lower()
|
|
return m.startswith(P_CLAUDE_MODEL_PREFIXES)
|
|
|
|
|
|
def p_is_gemini_model(model: str) -> bool:
|
|
m = (model or "").strip().lower()
|
|
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 P_GEMINI_BARE_MODEL_PATTERNS)
|
|
|
|
|
|
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:
|
|
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 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("/")
|
|
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 p_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 p_should_bypass_oai,
|
|
should_bypass_9router_for_openrouter as p_should_bypass_or,
|
|
forward_to_openai as p_forward_oai,
|
|
forward_to_openrouter as p_forward_or,
|
|
)
|
|
from backend.apps.settings.settings import load_settings as p_load
|
|
p_s = p_load()
|
|
if p_is_openai_max_completion_tokens_model(model):
|
|
p_oak = (getattr(p_s, "openai_api_key", "") or "").strip()
|
|
if p_should_bypass_oai(parsed_for_bypass, p_oak):
|
|
status, body_stream, hdrs = await p_forward_oai(
|
|
parsed_for_bypass, p_oak,
|
|
)
|
|
return StreamingResponse(
|
|
body_stream, status_code=status, headers=hdrs,
|
|
media_type=hdrs.get("content-type", "text/event-stream"),
|
|
)
|
|
if p_is_openrouter_model(model):
|
|
p_ork = (getattr(p_s, "openrouter_api_key", "") or "").strip()
|
|
if p_should_bypass_or(parsed_for_bypass, p_ork):
|
|
status, body_stream, hdrs = await p_forward_or(
|
|
parsed_for_bypass, p_ork,
|
|
)
|
|
return StreamingResponse(
|
|
body_stream, status_code=status, headers=hdrs,
|
|
media_type=hdrs.get("content-type", "text/event-stream"),
|
|
)
|
|
|
|
if p_is_gemini_model(model):
|
|
body = scrub_request_for_gemini(body)
|
|
if p_is_openai_max_completion_tokens_model(model):
|
|
body = scrub_request_for_openai_gpt5(body)
|
|
if p_is_openrouter_model(model):
|
|
body = inject_openrouter_file_parser(body)
|
|
|
|
base_url, auth_headers = p_pick_upstream(model)
|
|
|
|
forward_headers: dict[str, str] = {}
|
|
for k, v in request.headers.items():
|
|
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":
|
|
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
|
|
|
|
# Gemini (especially the AI Studio key) intermittently 503s and 9Router holds
|
|
# the retry, which hangs the whole turn for the full read window. Bound Gemini
|
|
# so a stalled first response fails fast (~2 min) instead of stalling ~10 min;
|
|
# other providers keep the generous window for long reasoning turns.
|
|
p_read_timeout = 120.0 if p_is_gemini_model(model) else 600.0
|
|
|
|
try:
|
|
if wants_stream:
|
|
client = httpx.AsyncClient(timeout=httpx.Timeout(p_read_timeout, 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 P_HOP_HEADERS},
|
|
media_type=upstream.headers.get("content-type", "text/event-stream"),
|
|
)
|
|
else:
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(p_read_timeout, 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 P_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)
|