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)

  

posted @ 2026-06-16 11:14  人间春风意  阅读(34)  评论(0)    收藏  举报