Anthropic Messages API -> OpenAI Chat Completions API 转换代理
使用npm安装claudecode
npm install -g @anthropic-ai/claude-code
配置全局环境变量,或者临时环境变量
$env:ANTHROPIC_BASE_URL = "http://127.0.0.1:8787"
$env:ANTHROPIC_AUTH_TOKEN = "dummy"
使用如下命令,启动claude即可
claude
注意:需要提前运行如下脚本,启动本地转换代理(内网需要加上 $env:VERIFY_TLS="0";)
python anthropic_to_openai_proxy_cus.py
#!/usr/bin/env python3
"""
Anthropic Messages API -> OpenAI Chat Completions API 转换代理。
用途:让 Claude Code 通过本地代理,把请求转成 OpenAI 格式发往自定义网关,
并附带自定义请求头
依赖:
pip install fastapi uvicorn httpx
启动:
python anthropic_to_openai_proxy.py
# 默认监听 http://127.0.0.1:8787
让 Claude Code 指向本代理(PowerShell):
$env:ANTHROPIC_BASE_URL = "http://127.0.0.1:8787"
$env:ANTHROPIC_AUTH_TOKEN = "dummy" # 本代理不校验,占位即可
claude
"""
import json
import os
import time
import uuid
from typing import Any, Dict, List, Optional
import httpx
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse
# 设定 UPSTREAM_BASE_URL 的值,例如https://xxxxx/v1
# 如有需要,可设定自定义请求头,如X-Username
# 模型映射:把 Claude Code 发来的模型名映射为网关支持的模型名,需要列出对应的模型
# ---------------------------------------------------------------------------
# 配置(可用环境变量覆盖)
# ---------------------------------------------------------------------------
UPSTREAM_BASE = os.environ.get(
"UPSTREAM_BASE_URL", ""
)
UPSTREAM_CHAT_URL = UPSTREAM_BASE.rstrip("/") + "/chat/completions"
UPSTREAM_API_KEY = os.environ.get("UPSTREAM_API_KEY", "") # 若网关需要 Bearer,可填
CUSTOM_HEADERS = {
"X-Username": os.environ.get("X_USERNAME", "xxxxxx"),
}
# 模型名映射:把 Claude Code 发来的模型名映射为网关支持的模型名。
# Claude Code 主请求走 sonnet,后台小任务走 haiku。
MODEL_MAP = {
"claude-3-5-sonnet-20241022": "qwen3.7-plus",
"claude-3-7-sonnet-20250219": "qwen3.7-plus",
"claude-sonnet-4-20250514": "qwen3.7-max",
"claude-3-5-haiku-20241022": "qwen3.5-plus",
}
DEFAULT_MODEL = os.environ.get("DEFAULT_MODEL", "qwen3.7-plus")
# 若网关使用自签名证书无法验证,设为 "0" 关闭 TLS 校验(仅内网自担风险)
VERIFY_TLS = os.environ.get("VERIFY_TLS", "1") != "0"
app = FastAPI()
# ---------------------------------------------------------------------------
# 请求转换:Anthropic -> OpenAI
# ---------------------------------------------------------------------------
def _content_to_text(content: Any) -> str:
"""把 Anthropic content(字符串或 block 数组)拍平成文本。"""
if isinstance(content, str):
return content
parts: List[str] = []
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
return "".join(parts)
def anthropic_to_openai(body: Dict[str, Any]) -> Dict[str, Any]:
messages: List[Dict[str, Any]] = []
# system:Anthropic 顶层字段 -> OpenAI system 消息
system = body.get("system")
if system:
messages.append({"role": "system", "content": _content_to_text(system)})
for msg in body.get("messages", []):
role = msg.get("role", "user")
content = msg.get("content")
if isinstance(content, str):
messages.append({"role": role, "content": content})
continue
# content 是 block 数组
text_parts: List[str] = []
tool_calls: List[Dict[str, Any]] = []
tool_results: List[Dict[str, Any]] = []
for block in content if isinstance(content, list) else []:
btype = block.get("type")
if btype == "text":
text_parts.append(block.get("text", ""))
elif btype == "tool_use":
tool_calls.append(
{
"id": block.get("id", f"call_{uuid.uuid4().hex[:8]}"),
"type": "function",
"function": {
"name": block.get("name", ""),
"arguments": json.dumps(
block.get("input", {}), ensure_ascii=False
),
},
}
)
elif btype == "tool_result":
tool_results.append(
{
"role": "tool",
"tool_call_id": block.get("tool_use_id", ""),
"content": _content_to_text(block.get("content", "")),
}
)
if role == "assistant":
assistant_msg: Dict[str, Any] = {"role": "assistant"}
assistant_msg["content"] = "".join(text_parts) or None
if tool_calls:
assistant_msg["tool_calls"] = tool_calls
messages.append(assistant_msg)
else:
if text_parts:
messages.append({"role": "user", "content": "".join(text_parts)})
# tool_result 在 OpenAI 里是独立的 tool 角色消息
messages.extend(tool_results)
openai_body: Dict[str, Any] = {
"model": MODEL_MAP.get(body.get("model", ""), DEFAULT_MODEL),
"messages": messages,
"stream": bool(body.get("stream", False)),
}
if "max_tokens" in body:
openai_body["max_tokens"] = body["max_tokens"]
if "temperature" in body:
openai_body["temperature"] = body["temperature"]
if "top_p" in body:
openai_body["top_p"] = body["top_p"]
if body.get("stop_sequences"):
openai_body["stop"] = body["stop_sequences"]
# 工具定义转换
tools = body.get("tools")
if tools:
openai_body["tools"] = [
{
"type": "function",
"function": {
"name": t.get("name"),
"description": t.get("description", ""),
"parameters": t.get("input_schema", {}),
},
}
for t in tools
]
return openai_body
# ---------------------------------------------------------------------------
# 响应转换:OpenAI -> Anthropic(非流式)
# ---------------------------------------------------------------------------
def openai_to_anthropic(resp: Dict[str, Any], model: str) -> Dict[str, Any]:
choice = (resp.get("choices") or [{}])[0]
message = choice.get("message", {})
content_blocks: List[Dict[str, Any]] = []
if message.get("content"):
content_blocks.append({"type": "text", "text": message["content"]})
for tc in message.get("tool_calls", []) or []:
fn = tc.get("function", {})
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
content_blocks.append(
{
"type": "tool_use",
"id": tc.get("id", f"toolu_{uuid.uuid4().hex[:8]}"),
"name": fn.get("name", ""),
"input": args,
}
)
finish = choice.get("finish_reason")
stop_reason = {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
}.get(finish, "end_turn")
usage = resp.get("usage", {})
return {
"id": resp.get("id", f"msg_{uuid.uuid4().hex}"),
"type": "message",
"role": "assistant",
"model": model,
"content": content_blocks or [{"type": "text", "text": ""}],
"stop_reason": stop_reason,
"stop_sequence": None,
"usage": {
"input_tokens": usage.get("prompt_tokens", 0),
"output_tokens": usage.get("completion_tokens", 0),
},
}
# ---------------------------------------------------------------------------
# 响应转换:OpenAI SSE -> Anthropic SSE(流式)
# ---------------------------------------------------------------------------
def _sse(event: str, data: Dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def stream_openai_to_anthropic(upstream_resp, model: str):
msg_id = f"msg_{uuid.uuid4().hex}"
yield _sse(
"message_start",
{
"type": "message_start",
"message": {
"id": msg_id,
"type": "message",
"role": "assistant",
"model": model,
"content": [],
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": 0, "output_tokens": 0},
},
},
)
text_block_open = False
# tool_call 索引 -> Anthropic content block 索引
tool_blocks: Dict[int, Dict[str, Any]] = {}
next_index = 0
finish_reason = "stop"
async for line in upstream_resp.aiter_lines():
if not line or not line.startswith("data:"):
continue
data_str = line[len("data:"):].strip()
if data_str == "[DONE]":
break
try:
chunk = json.loads(data_str)
except json.JSONDecodeError:
continue
choice = (chunk.get("choices") or [{}])[0]
delta = choice.get("delta", {})
if choice.get("finish_reason"):
finish_reason = choice["finish_reason"]
# 文本增量
if delta.get("content"):
if not text_block_open:
yield _sse(
"content_block_start",
{
"type": "content_block_start",
"index": next_index,
"content_block": {"type": "text", "text": ""},
},
)
text_block_open = True
text_index = next_index
next_index += 1
yield _sse(
"content_block_delta",
{
"type": "content_block_delta",
"index": text_index,
"delta": {"type": "text_delta", "text": delta["content"]},
},
)
# 工具调用增量
for tc in delta.get("tool_calls", []) or []:
tc_idx = tc.get("index", 0)
fn = tc.get("function", {})
if tc_idx not in tool_blocks:
block_index = next_index
next_index += 1
tool_blocks[tc_idx] = {"block_index": block_index}
yield _sse(
"content_block_start",
{
"type": "content_block_start",
"index": block_index,
"content_block": {
"type": "tool_use",
"id": tc.get("id", f"toolu_{uuid.uuid4().hex[:8]}"),
"name": fn.get("name", ""),
"input": {},
},
},
)
if fn.get("arguments"):
yield _sse(
"content_block_delta",
{
"type": "content_block_delta",
"index": tool_blocks[tc_idx]["block_index"],
"delta": {
"type": "input_json_delta",
"partial_json": fn["arguments"],
},
},
)
# 关闭所有已开的 block
if text_block_open:
yield _sse(
"content_block_stop",
{"type": "content_block_stop", "index": text_index},
)
for info in tool_blocks.values():
yield _sse(
"content_block_stop",
{"type": "content_block_stop", "index": info["block_index"]},
)
stop_reason = {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
}.get(finish_reason, "end_turn")
yield _sse(
"message_delta",
{
"type": "message_delta",
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
"usage": {"output_tokens": 0},
},
)
yield _sse("message_stop", {"type": "message_stop"})
# ---------------------------------------------------------------------------
# 路由
# ---------------------------------------------------------------------------
def _upstream_headers() -> Dict[str, str]:
headers = {"Content-Type": "application/json"}
headers.update(CUSTOM_HEADERS)
if UPSTREAM_API_KEY:
headers["Authorization"] = f"Bearer {UPSTREAM_API_KEY}"
return headers
@app.post("/v1/messages")
async def messages(request: Request):
body = await request.json()
is_stream = bool(body.get("stream", False))
openai_body = anthropic_to_openai(body)
model = body.get("model", DEFAULT_MODEL)
if is_stream:
client = httpx.AsyncClient(timeout=None, verify=VERIFY_TLS)
req = client.build_request(
"POST",
UPSTREAM_CHAT_URL,
headers=_upstream_headers(),
json=openai_body,
)
upstream_resp = await client.send(req, stream=True)
async def gen():
try:
async for sse in stream_openai_to_anthropic(upstream_resp, model):
yield sse
finally:
await upstream_resp.aclose()
await client.aclose()
return StreamingResponse(gen(), media_type="text/event-stream")
async with httpx.AsyncClient(timeout=None, verify=VERIFY_TLS) as client:
r = await client.post(
UPSTREAM_CHAT_URL, headers=_upstream_headers(), json=openai_body
)
if r.status_code >= 400:
return JSONResponse(
status_code=r.status_code,
content={
"type": "error",
"error": {"type": "upstream_error", "message": r.text},
},
)
return JSONResponse(content=openai_to_anthropic(r.json(), model))
@app.get("/health")
async def health():
return {"status": "ok", "upstream": UPSTREAM_CHAT_URL}
if __name__ == "__main__":
host = os.environ.get("PROXY_HOST", "127.0.0.1")
port = int(os.environ.get("PROXY_PORT", "8787"))
print(f"[proxy] listening on http://{host}:{port}")
print(f"[proxy] upstream -> {UPSTREAM_CHAT_URL}")
print(f"[proxy] headers -> {CUSTOM_HEADERS}")
uvicorn.run(app, host=host, port=port)
作者:人间春风意
扫描左侧的二维码可以赞赏

本作品采用署名-非商业性使用-禁止演绎 4.0 国际 进行许可。

浙公网安备 33010602011771号