Files
openswarm/backend/apps/agents/proxy/anthropic_to_openai.py
T

385 lines
16 KiB
Python

"""Anthropic Messages API ↔ OpenAI Chat Completions translator.
Used to bypass 9router 0.3.60 for OpenAI requests that include document
blocks (PDFs). 9router's content-block filter strips any block that
isn't `text` or `image_url`, so PDFs sent as OpenAI's native `type:file`
shape never reach the model. This translator POSTs directly to
api.openai.com using the user's OpenAI API key and converts the
streaming response back to Anthropic Messages SSE format so the
bundled Claude CLI subprocess can consume it unchanged.
Scope: PDFs + images + text. Tool-use translation is NOT covered (the
PDF-attach flow does not need tools in the same turn). If a request has
both tools and documents, fall back to the 9router path which handles
tool-use but strips PDFs (refused upstream in agent_manager).
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import uuid
from typing import AsyncIterator
import httpx
logger = logging.getLogger(__name__)
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 pipeline + our SSE translator's chunk buffer + the response body. A 30MB PDF is ~40MB base64; four in-flight = ~160MB of httpx buffers plus Python overhead, which OOM-killed the dev backend on macOS during 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)
# 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.
P_BYPASS_MAX_RAW_BYTES = 40 * 1024 * 1024
def p_has_document_block(parsed: dict) -> bool:
msgs = parsed.get("messages")
if not isinstance(msgs, list):
return 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":
return True
return False
def should_bypass_9router(parsed: dict, api_key: str | None) -> bool:
"""True iff request is a GPT-5.x Chat Completions with at least one
document block AND user has an OpenAI API key. Anything else falls
through the normal 9router path."""
if not api_key:
return False
model = (parsed.get("model") or "").lower()
if not any(model.startswith(p) for p in ("gpt-5", "openai/gpt-5", "cp-openai/gpt-5")):
return False
if "codex" in model:
return False
if parsed.get("tools"):
return False
return p_has_document_block(parsed)
def should_bypass_9router_for_openrouter(parsed: dict, api_key: str | None) -> bool:
"""True iff request is bound for OpenRouter AND has document blocks
AND user has an OpenRouter API key. 9router 0.3.60 doesn't know
about OR's `plugins` field and silently strips it; we bypass to
inject the file-parser plugin and POST directly to openrouter.ai."""
if not api_key:
return False
model = (parsed.get("model") or "").lower()
if not (model.startswith("openrouter/") or model.startswith("or:")):
return False
if parsed.get("tools"):
return False
return p_has_document_block(parsed)
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}]
if not isinstance(content, list):
return [{"type": "text", "text": str(content)}]
out: list[dict] = []
file_counter = 0
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type")
if btype == "text":
txt = block.get("text") or ""
if txt:
out.append({"type": "text", "text": txt})
elif btype == "image":
src = block.get("source") or {}
if src.get("type") == "base64" and src.get("data"):
mt = src.get("media_type") or "image/png"
out.append({
"type": "image_url",
"image_url": {"url": f"data:{mt};base64,{src['data']}"},
})
elif btype == "document":
src = block.get("source") or {}
if src.get("type") == "base64" and src.get("data"):
file_counter += 1
mt = src.get("media_type") or "application/pdf"
out.append({
"type": "file",
"file": {
"filename": f"attachment_{file_counter}.pdf",
"file_data": f"data:{mt};base64,{src['data']}",
},
})
if not out:
out.append({"type": "text", "text": ""})
return out
def translate_request(parsed: dict) -> dict:
"""Anthropic Messages request → OpenAI Chat Completions request."""
model = parsed.get("model") or ""
if "/" in model:
model = model.split("/", 1)[1]
openai_body: dict = {"model": model, "stream": True}
sys = parsed.get("system")
msgs_out: list[dict] = []
if sys:
if isinstance(sys, str):
msgs_out.append({"role": "system", "content": sys})
elif isinstance(sys, list):
sys_text = "\n".join(
b.get("text", "") for b in sys
if isinstance(b, dict) and b.get("type") == "text"
)
if sys_text:
msgs_out.append({"role": "system", "content": sys_text})
for m in (parsed.get("messages") or []):
if not isinstance(m, dict):
continue
role = m.get("role")
if role not in ("user", "assistant"):
continue
msgs_out.append({"role": role, "content": p_content_blocks_to_openai(m.get("content"))})
openai_body["messages"] = msgs_out
mt = parsed.get("max_tokens")
if isinstance(mt, int) and mt > 0:
openai_body["max_completion_tokens"] = mt
if isinstance(parsed.get("temperature"), (int, float)):
openai_body["temperature"] = parsed["temperature"]
# OpenAI omits usage from streamed chunks unless explicitly asked. Without this, our Anthropic message_delta would always report 0 tokens, breaking cost tracking + the context meter for bypass-route turns. OpenRouter respects the same flag.
openai_body["stream_options"] = {"include_usage": True}
return openai_body
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 p_translate_response_stream(
upstream: httpx.Response, model: str,
) -> AsyncIterator[bytes]:
"""Convert OpenAI Chat Completions SSE → Anthropic Messages SSE.
Emits message_start, content_block_start (text block at index 0),
content_block_delta per chunk, then content_block_stop +
message_delta + message_stop on completion.
"""
msg_id = f"msg_{uuid.uuid4().hex[:24]}"
started = False
block_opened = False
output_tokens = 0
input_tokens = 0
stop_reason = "end_turn"
buffer = b""
try:
async for chunk in upstream.aiter_bytes():
if not chunk:
continue
buffer += chunk
while b"\n\n" in buffer:
raw_event, buffer = buffer.split(b"\n\n", 1)
line = raw_event.decode("utf-8", errors="replace").strip()
if not line:
continue
for ln in line.split("\n"):
# SSE comments (`:` prefix) are keep-alives, e.g. OpenRouter emits `: OPENROUTER PROCESSING` while its file-parser plugin works. Drop them.
if ln.startswith(":"):
continue
if not ln.startswith("data:"):
continue
payload = ln[5:].strip()
if payload == "[DONE]":
continue
try:
ev = json.loads(payload)
except Exception:
continue
if not started:
usage = (ev.get("usage") or {})
input_tokens = int(usage.get("prompt_tokens") or 0)
yield p_sse_event("message_start", {
"type": "message_start",
"message": {
"id": msg_id,
"type": "message",
"role": "assistant",
"content": [],
"model": model,
"stop_reason": None,
"stop_sequence": None,
"usage": {
"input_tokens": input_tokens,
"output_tokens": 0,
},
},
})
started = True
choices = ev.get("choices") or []
if not choices:
usage = ev.get("usage") or {}
if usage:
output_tokens = int(usage.get("completion_tokens") or output_tokens)
input_tokens = int(usage.get("prompt_tokens") or input_tokens)
continue
choice = choices[0]
delta = choice.get("delta") or {}
delta_text = delta.get("content")
if isinstance(delta_text, str) and delta_text:
if not block_opened:
yield p_sse_event("content_block_start", {
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": ""},
})
block_opened = True
yield p_sse_event("content_block_delta", {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": delta_text},
})
finish = choice.get("finish_reason")
if finish:
if finish == "length":
stop_reason = "max_tokens"
elif finish == "tool_calls":
stop_reason = "tool_use"
else:
stop_reason = "end_turn"
finally:
if started:
if block_opened:
yield p_sse_event("content_block_stop", {
"type": "content_block_stop", "index": 0,
})
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 p_sse_event("message_stop", {"type": "message_stop"})
async def forward_to_openai(
parsed: dict, api_key: str,
) -> 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 p_forward(openai_body, api_key, f"{P_OPENAI_UPSTREAM}/chat/completions")
async def forward_to_openrouter(
parsed: dict, api_key: str,
) -> 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)
model = (parsed.get("model") or "").lower()
bare = model
for prefix in ("openrouter/", "or:"):
if bare.startswith(prefix):
bare = bare[len(prefix):]
break
openai_body["model"] = bare
openai_body["plugins"] = [{"id": "file-parser", "pdf": {"engine": "pdf-text"}}]
return await p_forward(openai_body, api_key, f"{P_OPENROUTER_UPSTREAM}/chat/completions")
def 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
for m in body_json.get("messages") or []:
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
if block.get("type") == "image_url":
url = (block.get("image_url") or {}).get("url") or ""
if "," in url:
total += len(url.split(",", 1)[1])
elif block.get("type") == "file":
fd = (block.get("file") or {}).get("file_data") or ""
if "," in fd:
total += len(fd.split(",", 1)[1])
return total
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 > P_BYPASS_MAX_RAW_BYTES:
async def reject():
payload = json.dumps({
"type": "error",
"error": {
"type": "invalid_request_error",
"message": (
f"Attached files total ~{raw_estimate // (1024*1024)} MB, "
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."
),
},
}).encode("utf-8")
yield payload
return 413, reject(), {"content-type": "application/json"}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
# Acquire the bypass-concurrency semaphore before opening a streaming connection. Without this, N simultaneous PDF attaches each hold a ~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()
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()
raise
async def streamer():
try:
if upstream.status_code >= 400:
raw = await upstream.aread()
yield raw
return
async for chunk in p_translate_response_stream(upstream, body_json["model"]):
yield chunk
finally:
try:
await upstream.aclose()
finally:
try:
await client.aclose()
finally:
bypass_sema.release()
return upstream.status_code, streamer(), {
"content-type": "text/event-stream" if upstream.status_code < 400 else "application/json",
}