用适配器把 Bedrock Converse API 改到 TaoToken:OpenAI 兼容与 Tool Calling 实战
1. 为什么 Bedrock Converse API 需要一层 OpenAI 适配器如果你手上有 CLI 编码工具、Agent 框架或者自研的对话前端它们大概率只认 OpenAI 的/v1/chat/completions格式。而 Bedrock 的 Converse API 是另一套结构消息内容用 content block 数组表达工具调用走toolUse/toolResult角色还必须严格交替。两套协议对不上工具调用链路就会断。我试过直接用 LiteLLM 做桥接结果在 Tool Calling 上踩了坑。qwencode 发出的消息序列里assistant 带tool_calls后面跟两条连续的tool角色消息。LiteLLM 把它们都转成userBedrock 直接抛ValidationException: Messages must alternate between user and assistant roles。更麻烦的是某些版本把tool角色降级成普通文本模型根本不知道这是工具执行结果循环就卡死了。所以这篇要解决的核心问题是写一个轻量适配器把 Bedrock Converse API 转成 OpenAI 兼容格式并且完整支持 Tool Calling。适合两类人一是想把 Kimi、DeepSeek、Qwen 这些 Bedrock 上的模型接进现有 OpenAI 生态工具的开发者二是需要统一本地或服务端调用入口、不想被 LiteLLM 那套 20 层堆栈拖累的人。适配器要处理三件事消息格式转换、连续同角色合并、工具调用双向映射。下面从接入入口开始一步步给出可复制的配置和验证命令。2. TaoToken 作为统一入口的前置准备在写适配器之前先把调用入口统一掉。TaoToken 提供 OpenAI 兼容的 API 端点Base URL 是https://taotoken.net/api你可以把它当成一个标准的 OpenAI 服务来用。这样适配器只需要面向一套协议不用为每个上游单独写分支。先拿 API Key。打开https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite创建一个 Key格式类似sk-开头的一串字符。这个 Key 后面会同时用在适配器的鉴权校验和上游请求里。模型 ID 需要确认清楚。Bedrock 上的模型 ID 通常带厂商前缀和版本后缀比如moonshotai.kimi-k2.5、deepseek.v3.2、qwen.qwen3-coder-next、amazon.nova-pro-v1:0、mistral.mistral-large-3-675b-instruct。这些模型在 Tool Calling 支持度上不完全一样实测下来 Kimi、DeepSeek、Qwen3 Coder、Nova Pro、Mistral Large 都能完整走通工具调用zai.glm-4.7比较特殊能发起工具调用但接收toolResult时支持不完整选型时要留意。如果你更想先验证模型对话效果可以直接在https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite里试。长期跑编码 Agent 的话https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite更适合因为工具调用是高频操作配额和稳定性比单次对话重要得多。接入文档在https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite里面有完整的端点说明和参数列表。控制台在https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite可以看调用量和错误分布。前置准备就三样Base URL、API Key、Model ID。这三件套在后面的配置片段里会反复出现先记牢。3. 适配器配置片段与消息转换实现这一节给出可直接复制的配置和核心转换代码。适配器用 FastAPI 写依赖boto3和uvicorn。先装依赖pip install fastapi uvicorn boto3配置文件用 JSON 表达路径放在项目根目录的config.json{ server: { host: 0.0.0.0, port: 8765 }, upstream: { base_url: https://taotoken.net/api, api_key: sk-你的Key, default_model: moonshotai.kimi-k2.5 }, bedrock: { region: us-east-1, profile: global }, adapter: { api_key: sk-local-adapter-key, merge_same_role: true, max_tokens: 2048, temperature: 0.7 } }如果你用 TOML 风格管理等价写法是[server] host 0.0.0.0 port 8765 [upstream] base_url https://taotoken.net/api api_key sk-你的Key default_model moonshotai.kimi-k2.5 [bedrock] region us-east-1 profile global [adapter] api_key sk-local-adapter-key merge_same_role true max_tokens 2048 temperature 0.7核心转换函数分三块。第一块是 OpenAI 到 Bedrock 的消息转换import json def convert_to_bedrock(messages): OpenAI messages - Bedrock messages system system bedrock_messages [] for msg in messages: role msg[role] content msg.get(content, ) if role system: system content if isinstance(content, str) else continue if role tool: tool_call_id msg.get(tool_call_id, ) if isinstance(content, list): content \n.join( item.get(text, str(item)) if isinstance(item, dict) else str(item) for item in content ) bedrock_messages.append({ role: user, content: [{toolResult: { toolUseId: tool_call_id, content: [{text: str(content)}] }}] }) continue if role assistant: has_content content and ( (isinstance(content, str) and content.strip()) or (isinstance(content, list) and len(content) 0) ) if has_content: if isinstance(content, list): content \n.join( item.get(text, str(item)) if isinstance(item, dict) else str(item) for item in content ) bedrock_messages.append({ role: assistant, content: [{text: str(content)}] }) for tc in msg.get(tool_calls, []): bedrock_messages.append({ role: assistant, content: [{toolUse: { toolUseId: tc[id], name: tc[function][name], input: json.loads(tc[function][arguments]) if tc[function][arguments] else {} }}] }) continue # user if isinstance(content, list): content \n.join( item.get(text, str(item)) if isinstance(item, dict) else str(item) for item in content ) if content: bedrock_messages.append({ role: user, content: [{text: str(content)}] }) return system, bedrock_messages第二块是连续同角色合并这是 Bedrock 的硬性要求def merge_messages(messages): 合并连续同角色消息Bedrock 要求 user/assistant 严格交替 if not messages: return messages result [] for msg in messages: if result and result[-1][role] msg[role]: result[-1][content].extend(msg[content]) else: result.append({role: msg[role], content: list(msg[content])}) return result第三块是 Bedrock 响应转回 OpenAI 格式import time import uuid def convert_from_bedrock(response, model): content tool_calls [] if output in response and message in response[output]: for block in response[output][message].get(content, []): if text in block: content block[text] elif toolUse in block: tool_use block[toolUse] tool_calls.append({ id: tool_use[toolUseId], type: function, function: { name: tool_use[name], arguments: json.dumps(tool_use.get(input, {})) } }) finish_reason tool_calls if response.get(stopReason) tool_use else stop choice { index: 0, message: {role: assistant, content: content if content else None}, finish_reason: finish_reason } if tool_calls: choice[message][tool_calls] tool_calls return { id: fchatcmpl-{uuid.uuid4().hex[:8]}, object: chat.completion, created: int(time.time()), model: model, choices: [choice], usage: { prompt_tokens: response.get(usage, {}).get(inputTokens, 0), completion_tokens: response.get(usage, {}).get(outputTokens, 0), total_tokens: response.get(usage, {}).get(totalTokens, 0) } }Tools 配置转换单独拎出来OpenAI 的tools数组要映射成 Bedrock 的toolConfigdef convert_tools(tools): if not tools: return None return { tools: [{ toolSpec: { name: tool[function][name], description: tool[function].get(description, ), inputSchema: {json: tool[function].get(parameters, {})} } } for tool in tools if tool.get(type) function] }注意merge_messages必须在convert_to_bedrock之后调用。顺序反了连续 tool 消息会先被合并成一条toolUseId就丢了。4. 用 curl 验证 OpenAI 兼容端点与工具调用链路适配器跑起来后先验证基础对话再验证工具调用。启动服务python adapter.py服务监听0.0.0.0:8765。先测普通对话curl -s http://127.0.0.1:8765/v1/chat/completions \ -H Authorization: Bearer sk-local-adapter-key \ -H Content-Type: application/json \ -d { model: moonshotai.kimi-k2.5, messages: [ {role: user, content: 用一句话说明什么是适配器} ], max_tokens: 128 }预期返回结构里有choices[0].message.contentfinish_reason是stop。如果返回 401检查Authorization头是否和config.json里的adapter.api_key一致。再测工具调用。构造一个带tools的请求curl -s http://127.0.0.1:8765/v1/chat/completions \ -H Authorization: Bearer sk-local-adapter-key \ -H Content-Type: application/json \ -d { model: deepseek.v3.2, messages: [ {role: user, content: 帮我创建文件 notes.txt内容写 hello} ], tools: [{ type: function, function: { name: write_file, description: 写入文件, parameters: { type: object, properties: { path: {type: string}, content: {type: string} }, required: [path, content] } } }], max_tokens: 256 }成功时finish_reason应该是tool_callsmessage.tool_calls[0].function.name是write_filearguments里是 JSON 字符串。拿到这个结果后把工具执行结果回传验证完整循环curl -s http://127.0.0.1:8765/v1/chat/completions \ -H Authorization: Bearer sk-local-adapter-key \ -H Content-Type: application/json \ -d { model: deepseek.v3.2, messages: [ {role: user, content: 帮我创建文件 notes.txt内容写 hello}, {role: assistant, content: null, tool_calls: [{ id: call_abc123, type: function, function: {name: write_file, arguments: {\path\:\notes.txt\,\content\:\hello\}} }]}, {role: tool, tool_call_id: call_abc123, content: 文件写入成功} ], tools: [{ type: function, function: { name: write_file, description: 写入文件, parameters: { type: object, properties: { path: {type: string}, content: {type: string} }, required: [path, content] } } }], max_tokens: 256 }这一步是验证tool角色转toolResult的关键。如果适配器正确模型会基于工具结果继续生成自然语言回复finish_reason回到stop。如果这里报ValidationException说明连续同角色合并没生效或者toolResult结构拼错了。流式验证用stream: true观察 SSE 事件里delta.tool_calls是否分片到达。工具调用的参数是逐块拼接的客户端要按index累积arguments字符串。5. 常见报错排查对照表这一节按真实报错来对。适配器跑不通基本集中在下面几类。401 Missing API key / Invalid API key适配器自身的鉴权失败。检查请求头Authorization: Bearer sk-local-adapter-key是否和config.json里adapter.api_key完全一致。注意 Bearer 后面有一个空格Key 不要带引号。ValidationException: Messages must alternate between user and assistant roles这是最典型的。原因就是连续同角色消息没合并。OpenAI 的tool角色转成 Bedrock 的user后如果前面已经有一条user就会连续。解决方法是确保merge_messages在转换之后被调用并且合并时用extend而不是覆盖content。ValidationException: The toolResult toolUseId does not match any toolUsetool_call_id对不上。检查 assistant 消息里tool_calls[].id和 tool 消息里tool_call_id是否一致。有些客户端会重新生成 ID适配器要原样透传不能改写。local proxy failed / connection refused适配器没启动或者端口被占。用lsof -i :8765查一下。如果是从容器里访问宿主机127.0.0.1要换成宿主机的实际地址。reading choices: unexpected end of JSON input上游返回了非 JSON 内容通常是上游报错被直接透传。打开适配器日志看Calling Bedrock with modelId后面的实际请求。常见原因是模型 ID 写错比如把moonshotai.kimi-k2.5写成kimi-k2.5Bedrock 找不到模型。OAuth / auth.json 相关报错如果你用 Codex 或 Claude Code 这类工具它们的auth.json或 OAuth 流程可能覆盖了 Base URL。以 Codex 为例~/.codex/auth.json里要确认OPENAI_BASE_URL指向适配器地址OPENAI_API_KEY填适配器的 Key。三件套必须同时对齐Base URL、Key、Model ID。少一个都会在工具调用阶段暴露问题。模型返回空输入导致输出中断这是 Bedrock 在长链路里偶发的问题模型可能返回空 content block。适配器里可以在convert_from_bedrock加一层判断如果content和tool_calls都为空补一个默认文本避免客户端解析崩溃。zai.glm-4.7 工具结果不生效这个模型只能发起工具调用接收toolResult时支持不完整。如果业务强依赖工具循环换qwen.qwen3-coder-next或deepseek.v3.2。排查时优先看适配器日志里的三段Converted: N messages - M bedrock messages、Bedrock messages:、Response:。转换前后的消息数量对不上问题一定在合并逻辑Bedrock 请求体正常但响应异常问题在上游模型或参数。6. 把适配器接进你的工具链适配器跑通后接入现有工具只需要改 Base URL。以 Cline 或 CC Switch 这类支持自定义端点的工具为例配置里填三件套Base URL 用http://127.0.0.1:8765/v1API Key 用适配器的 KeyModel ID 用 Bedrock 的完整模型 ID。Cline 的 MCP 配置里如果涉及工具调用同样走这套端点不需要额外改协议。如果你不想自己维护适配器进程也可以直接用 TaoToken 的 OpenAI 兼容端点把 Base URL 设成https://taotoken.net/apiKey 用https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite里创建的模型 ID 按文档填。这样省掉本地适配器这一层工具调用链路直接由上游处理。接入细节看https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite。长期跑编码 Agent 的话建议把适配器和上游配额分开管理。适配器负责协议转换上游负责模型调度。Coding Plan 在https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite适合工具调用频繁的场景。想先验证模型对话效果去https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite试。最后留一个实操建议适配器的日志级别先开到 INFO把转换前后的消息都打出来。工具调用出问题时对比 OpenAI 请求里的tool_calls和 Bedrock 请求里的toolUse字段名和嵌套层级一眼就能看出差异。等链路稳定了再降到 WARNING避免日志刷屏。