ayoub5550/ClaudeCode
0
1#!/usr/bin/env python32"""3Anthropic → OpenAI API Translator Proxy4Accepts Anthropic /v1/messages format, translates to OpenAI /v1/chat/completions,5routes through Cloudflare proxy to NVIDIA API, translates response back.6 7Handles: text, tool_use, tool_result, streaming SSE8"""9 10import os, json, logging, uuid, time, traceback11from aiohttp import web, ClientSession, ClientTimeout12 13log = logging.getLogger("anthropic-proxy")14logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")15 16NVIDIA_API_KEY = os.environ.get("NVIDIA_API_KEY", "")17MODEL = os.environ.get("MODEL", "z-ai/glm-5.1")18PROXY_URL = os.environ.get("PROXY_URL", "")19PROXY_SECRET = os.environ.get("PROXY_SECRET", "")20TARGET_URL = f"{PROXY_URL}/v1/chat/completions" if PROXY_URL else "https://integrate.api.nvidia.com/v1/chat/completions"21 22REQUEST_COUNT = 023 24 25def anthropic_tools_to_openai(tools: list) -> list:26 """Convert Anthropic tool definitions to OpenAI function tools."""27 result = []28 for t in tools:29 result.append({30 "type": "function",31 "function": {32 "name": t.get("name", ""),33 "description": t.get("description", ""),34 "parameters": t.get("input_schema", t.get("parameters", {}))35 }36 })37 return result38 39 40def anthropic_messages_to_openai(messages: list) -> list:41 """Convert Anthropic message array to OpenAI message array."""42 result = []43 for msg in messages:44 role = msg.get("role", "user")45 content = msg.get("content", "")46 47 if isinstance(content, str):48 result.append({"role": role, "content": content})49 continue50 51 if not isinstance(content, list):52 result.append({"role": role, "content": str(content)})53 continue54 55 # Content is a list of blocks56 text_parts = []57 tool_calls = []58 tool_results = []59 60 for block in content:61 btype = block.get("type", "")62 63 if btype == "text":64 text_parts.append(block.get("text", ""))65 66 elif btype == "tool_use":67 tool_calls.append({68 "id": block.get("id", f"call_{uuid.uuid4().hex[:8]}"),69 "type": "function",70 "function": {71 "name": block.get("name", ""),72 "arguments": json.dumps(block.get("input", {}), ensure_ascii=False)73 }74 })75 76 elif btype == "tool_result":77 # Flatten tool result content78 tc_content = block.get("content", "")79 if isinstance(tc_content, list):80 tc_text = "\n".join(81 b.get("text", "") for b in tc_content if b.get("type") == "text"82 ) or json.dumps(tc_content, ensure_ascii=False)83 else:84 tc_text = str(tc_content)85 86 tool_results.append({87 "role": "tool",88 "tool_call_id": block.get("tool_use_id", ""),89 "content": tc_text90 })91 92 # Build messages from blocks93 if role == "assistant":94 m = {"role": "assistant", "content": "\n".join(text_parts) if text_parts else ""}95 if tool_calls:96 m["tool_calls"] = tool_calls97 if not m["content"]:98 m["content"] = None99 result.append(m)100 elif tool_results:101 # Tool results become separate tool messages102 if text_parts:103 result.append({"role": "user", "content": "\n".join(text_parts)})104 result.extend(tool_results)105 else:106 result.append({"role": role, "content": "\n".join(text_parts) or ""})107 108 return result109 110 111def anthropic_to_openai_request(body: dict) -> dict:112 """Convert full Anthropic /v1/messages request to OpenAI /v1/chat/completions."""113 messages = []114 115 # System message116 system = body.get("system", "")117 if isinstance(system, str) and system:118 messages.append({"role": "system", "content": system})119 elif isinstance(system, list):120 text = "\n".join(b.get("text", "") for b in system if b.get("type") == "text")121 if text:122 messages.append({"role": "system", "content": text})123 124 # Convert messages125 messages.extend(anthropic_messages_to_openai(body.get("messages", [])))126 127 oai = {128 "model": MODEL,129 "messages": messages,130 "max_tokens": body.get("max_tokens", 4096),131 "stream": body.get("stream", False),132 }133 134 if body.get("temperature") is not None:135 oai["temperature"] = body["temperature"]136 if body.get("top_p") is not None:137 oai["top_p"] = body["top_p"]138 139 # Tools140 tools = body.get("tools", [])141 if tools:142 oai["tools"] = anthropic_tools_to_openai(tools)143 oai["tool_choice"] = "auto"144 145 return oai146 147 148def openai_to_anthropic_response(oai: dict, model_name: str) -> dict:149 """Convert OpenAI chat completion to Anthropic messages response."""150 choices = oai.get("choices", [])151 if not choices:152 return {153 "id": f"msg_{uuid.uuid4().hex[:24]}",154 "type": "message", "role": "assistant",155 "content": [{"type": "text", "text": ""}],156 "model": model_name,157 "stop_reason": "end_turn",158 "stop_sequence": None,159 "usage": {"input_tokens": 0, "output_tokens": 0}160 }161 162 message = choices[0].get("message", {})163 content = []164 165 text = message.get("content", "")166 if text:167 content.append({"type": "text", "text": text})168 169 for tc in message.get("tool_calls", []):170 fn = tc.get("function", {})171 try:172 inp = json.loads(fn.get("arguments", "{}"))173 except json.JSONDecodeError:174 inp = {"raw": fn.get("arguments", "")}175 content.append({176 "type": "tool_use",177 "id": tc.get("id", f"toolu_{uuid.uuid4().hex[:12]}"),178 "name": fn.get("name", ""),179 "input": inp180 })181 182 if not content:183 content.append({"type": "text", "text": ""})184 185 finish = choices[0].get("finish_reason", "stop")186 stop_map = {"stop": "end_turn", "tool_calls": "tool_use", "length": "max_tokens"}187 usage = oai.get("usage", {})188 189 return {190 "id": f"msg_{uuid.uuid4().hex[:24]}",191 "type": "message",192 "role": "assistant",193 "content": content,194 "model": model_name,195 "stop_reason": stop_map.get(finish, "end_turn"),196 "stop_sequence": None,197 "usage": {198 "input_tokens": usage.get("prompt_tokens", 0),199 "output_tokens": usage.get("completion_tokens", 0),200 }201 }202 203 204async def sse_write(resp: web.StreamResponse, event: str, data: dict):205 """Write one SSE event."""206 payload = f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"207 await resp.write(payload.encode("utf-8"))208 209 210async def handle_messages(request: web.Request) -> web.StreamResponse:211 """Handle POST /v1/messages — Anthropic API format."""212 global REQUEST_COUNT213 REQUEST_COUNT += 1214 req_id = REQUEST_COUNT215 216 try:217 body = await request.json()218 except Exception as e:219 log.error(f"[req#{req_id}] Invalid JSON: {e}")220 return web.json_response({"type": "error", "error": {"type": "invalid_request_error", "message": str(e)}}, status=400)221 222 model_name = body.get("model", "claude-sonnet-4-20250514")223 is_stream = body.get("stream", False)224 n_tools = len(body.get("tools", []))225 n_msgs = len(body.get("messages", []))226 227 log.info(f"[req#{req_id}] {model_name} → {MODEL} | msgs={n_msgs} tools={n_tools} stream={is_stream}")228 229 # Translate230 try:231 oai_body = anthropic_to_openai_request(body)232 except Exception as e:233 log.error(f"[req#{req_id}] Translation error: {e}\n{traceback.format_exc()}")234 return web.json_response(235 {"type": "error", "error": {"type": "api_error", "message": f"Translation error: {e}"}},236 status=500237 )238 239 log.info(f"[req#{req_id}] OpenAI request: {len(oai_body['messages'])} messages, {len(oai_body.get('tools', []))} tools")240 241 headers = {242 "Authorization": f"Bearer {NVIDIA_API_KEY}",243 "Content-Type": "application/json",244 }245 if PROXY_URL and PROXY_SECRET:246 headers["x-target-host"] = "integrate.api.nvidia.com"247 headers["x-proxy-key"] = PROXY_SECRET248 249 timeout = ClientTimeout(total=300, connect=30)250 251 async with ClientSession(timeout=timeout) as session:252 try:253 async with session.post(TARGET_URL, json=oai_body, headers=headers) as resp:254 if resp.status != 200:255 error_text = await resp.text()256 log.error(f"[req#{req_id}] NVIDIA API {resp.status}: {error_text[:500]}")257 return web.json_response(258 {"type": "error", "error": {"type": "api_error", "message": f"Backend error {resp.status}: {error_text[:300]}"}},259 status=resp.status260 )261 262 # ─── Non-streaming ───263 if not is_stream:264 oai_resp = await resp.json()265 anthropic_resp = openai_to_anthropic_response(oai_resp, model_name)266 log.info(f"[req#{req_id}] Response: {len(anthropic_resp['content'])} blocks, stop={anthropic_resp['stop_reason']}")267 return web.json_response(anthropic_resp)268 269 # ─── Streaming ───270 response = web.StreamResponse(271 status=200, reason="OK",272 headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"}273 )274 await response.prepare(request)275 276 msg_id = f"msg_{uuid.uuid4().hex[:24]}"277 278 # message_start279 await sse_write(response, "message_start", {280 "type": "message_start",281 "message": {282 "id": msg_id, "type": "message", "role": "assistant",283 "content": [], "model": model_name,284 "usage": {"input_tokens": 0, "output_tokens": 0}285 }286 })287 288 block_open = False289 block_index = 0290 full_text = ""291 tool_buffers = {} # tc_id -> {name, args}292 current_tool_id = None293 294 async for raw_line in resp.content:295 line = raw_line.decode("utf-8", errors="replace").strip()296 if not line.startswith("data: "):297 continue298 data_str = line[6:].strip()299 if data_str == "[DONE]":300 break301 302 try:303 chunk = json.loads(data_str)304 except json.JSONDecodeError:305 continue306 307 choices = chunk.get("choices", [])308 if not choices:309 continue310 311 delta = choices[0].get("delta", {})312 finish = choices[0].get("finish_reason")313 314 # ── Text delta ──315 text = delta.get("content")316 if text:317 if not block_open or current_tool_id is not None:318 # Close any tool block first319 if block_open:320 await sse_write(response, "content_block_stop",321 {"type": "content_block_stop", "index": block_index})322 block_index += 1323 # Open text block324 await sse_write(response, "content_block_start", {325 "type": "content_block_start", "index": block_index,326 "content_block": {"type": "text", "text": ""}327 })328 block_open = True329 current_tool_id = None330 331 await sse_write(response, "content_block_delta", {332 "type": "content_block_delta", "index": block_index,333 "delta": {"type": "text_delta", "text": text}334 })335 full_text += text336 337 # ── Tool call delta ──338 tool_calls = delta.get("tool_calls", [])339 for tc in tool_calls:340 tc_id = tc.get("id")341 fn = tc.get("function", {})342 fn_name = fn.get("name")343 fn_args = fn.get("arguments", "")344 345 if tc_id and tc_id not in tool_buffers:346 # New tool call — close previous block347 if block_open:348 await sse_write(response, "content_block_stop",349 {"type": "content_block_stop", "index": block_index})350 block_index += 1351 352 tool_buffers[tc_id] = {"name": fn_name or "", "args": fn_args}353 current_tool_id = tc_id354 355 await sse_write(response, "content_block_start", {356 "type": "content_block_start", "index": block_index,357 "content_block": {358 "type": "tool_use",359 "id": tc_id,360 "name": fn_name or ""361 }362 })363 block_open = True364 365 if fn_args:366 await sse_write(response, "content_block_delta", {367 "type": "content_block_delta", "index": block_index,368 "delta": {"type": "input_json_delta", "partial_json": fn_args}369 })370 elif fn_args:371 # Continuing an existing tool call372 if current_tool_id and current_tool_id in tool_buffers:373 tool_buffers[current_tool_id]["args"] += fn_args374 await sse_write(response, "content_block_delta", {375 "type": "content_block_delta", "index": block_index,376 "delta": {"type": "input_json_delta", "partial_json": fn_args}377 })378 379 if finish:380 break381 382 # Close last block383 if block_open:384 await sse_write(response, "content_block_stop",385 {"type": "content_block_stop", "index": block_index})386 387 # Determine stop reason388 stop_reason = "tool_use" if tool_buffers else "end_turn"389 390 await sse_write(response, "message_delta", {391 "type": "message_delta",392 "delta": {"stop_reason": stop_reason, "stop_sequence": None},393 "usage": {"output_tokens": max(1, len(full_text.split()))}394 })395 await sse_write(response, "message_stop", {"type": "message_stop"})396 397 log.info(f"[req#{req_id}] Stream done: {len(full_text)} chars text, {len(tool_buffers)} tool calls, stop={stop_reason}")398 return response399 400 except Exception as e:401 log.error(f"[req#{req_id}] Error: {e}\n{traceback.format_exc()}")402 return web.json_response(403 {"type": "error", "error": {"type": "api_error", "message": str(e)}},404 status=500405 )406 407 408async def handle_health(request: web.Request):409 return web.json_response({410 "status": "ok",411 "proxy": "anthropic-to-openai",412 "target_model": MODEL,413 "target_url": TARGET_URL,414 "requests_served": REQUEST_COUNT,415 })416 417 418def create_app():419 app = web.Application()420 app.router.add_post("/v1/messages", handle_messages)421 app.router.add_post("/messages", handle_messages)422 app.router.add_get("/v1/messages", handle_health)423 app.router.add_get("/health", handle_health)424 app.router.add_get("/", handle_health)425 return app426 427 428if __name__ == "__main__":429 app = create_app()430 log.info(f"Anthropic→OpenAI proxy starting on :4000 → {MODEL} via {TARGET_URL}")431 web.run_app(app, host="0.0.0.0", port=4000)432 