local adapter = {} adapter.name = "anthropic" adapter.version = "3.0.0" adapter.endpoint = "/v1/messages" adapter.headers = { ["anthropic-version"] = "2023-06-01", } -- ============================================================ -- helpers -- ============================================================ local ROLE_WHITELIST = { system = true, user = true, assistant = true, tool = true, } local function collect_blocks(content) if type(content) == "string" then if content == "" then return {} end return { { type = "text", text = content } } end if type(content) ~= "table" then return {} end local blocks = {} for _, p in ipairs(content) do if type(p) == "string" then table.insert(blocks, { type = "text", text = p }) elseif type(p) == "table" then if p.type == "text" then table.insert(blocks, { type = "text", text = p.text or "" }) elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then local url = p.image_url.url local mt, b64 = string.match(url, "^data:([^,]+);base64,(.+)$") if b64 then table.insert(blocks, { type = "image", source = { type = "base64", media_type = mt or "image/png", data = b64 } }) else table.insert(blocks, { type = "image", source = { type = "url", url = url } }) end else -- Unknown content type: preserve for forward compatibility table.insert(blocks, p) end end end return blocks end local function text_of(content) if type(content) == "string" then return content end local t = "" for _, p in ipairs(content or {}) do if type(p) == "string" then t = t .. p elseif type(p) == "table" and p.type == "text" and p.text then t = t .. p.text end end return t end --- Append blocks to the last user message (merge) or create a new one. local function append_user(msgs, blocks) if #msgs > 0 and msgs[#msgs].role == "user" then for _, b in ipairs(blocks) do table.insert(msgs[#msgs].content, b) end else table.insert(msgs, { role = "user", content = blocks }) end end -- ============================================================ -- transform_request (OpenAI → Anthropic) -- ============================================================ function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) if not ok or type(req) ~= "table" then return raw_body end local msgs = {} local system = "" local pending_tool = {} -- accumulated tool_result blocks for _, m in ipairs(req.messages or {}) do local role = m.role or "user" if role == "system" then system = system .. text_of(m.content) .. "\n" elseif role == "assistant" then -- Build content array: text/image blocks from content + tool_use from tool_calls local blocks = collect_blocks(m.content) if type(m.tool_calls) == "table" then for _, tc in ipairs(m.tool_calls) do if type(tc) == "table" then local fn = tc["function"] or {} local args = fn.arguments local input = {} if type(args) == "string" and args ~= "" then local ok2, parsed = pcall(json.decode, args) if ok2 and type(parsed) == "table" then input = parsed end elseif type(args) == "table" then input = args end table.insert(blocks, { type = "tool_use", id = tc.id or "", name = fn.name or "", input = input, }) end end end table.insert(msgs, { role = "assistant", content = blocks }) elseif role == "tool" then -- Accumulate consecutive tool results; will be flushed as one -- user message with tool_result content blocks. table.insert(pending_tool, { type = "tool_result", tool_use_id = m.tool_call_id or "", content = text_of(m.content), }) else -- user / any other role: flush pending tool results first if #pending_tool > 0 then append_user(msgs, pending_tool) pending_tool = {} end append_user(msgs, collect_blocks(m.content)) end end -- Flush any trailing tool results if #pending_tool > 0 then append_user(msgs, pending_tool) end -- ── Build Anthropic request ────────────────────────────── local anthropic_req = { model = req.model or "claude-sonnet-4-20250514", max_tokens = req.max_tokens or 4096, messages = msgs, stream = req.stream or false, } -- ── tools ──────────────────────────────────────────────── local has_tools = false if req.tools and type(req.tools) == "table" then local tools = {} for _, t in ipairs(req.tools) do if type(t) == "table" and t.type == "function" and type(t["function"]) == "table" then local fn = t["function"] table.insert(tools, { name = fn.name or "", description = fn.description or "", input_schema = fn.parameters or { type = "object", properties = {} }, }) end end if #tools > 0 then anthropic_req.tools = tools has_tools = true end end -- ── tool_choice ────────────────────────────────────────── -- OpenAI → Anthropic mapping: -- "auto" → {type:"auto"} -- "none" → remove tools entirely (Anthropic has no "none") -- "required" → {type:"any"} -- {type:"function", function:{name:"X"}} → {type:"tool", name:"X"} if has_tools and req.tool_choice then local tc = req.tool_choice if type(tc) == "string" then if tc == "auto" then anthropic_req.tool_choice = { type = "auto" } elseif tc == "none" then anthropic_req.tools = nil anthropic_req.tool_choice = nil elseif tc == "required" then anthropic_req.tool_choice = { type = "any" } end elseif type(tc) == "table" and tc.type == "function" then local fn = tc["function"] or {} anthropic_req.tool_choice = { type = "tool", name = fn.name or "" } end end -- ── thinking (opt-in via extra_body) ────────────────────── -- Default: OFF. Client sends extra_body.thinking to enable. -- Example: {"extra_body": {"thinking": {"type": "enabled", "budget_tokens": 4096}}} if type(req.extra_body) == "table" and type(req.extra_body.thinking) == "table" then anthropic_req.thinking = req.extra_body.thinking end -- ── system ─────────────────────────────────────────────── if system ~= "" then -- Strip trailing newline from accumulation anthropic_req.system = string.match(system, "^(.-)\n*$") end return json.encode(anthropic_req) end -- ============================================================ -- transform_response (Anthropic → OpenAI, non-streaming) -- ============================================================ function adapter.transform_response(raw_body) local ok, resp = pcall(json.decode, raw_body) if not ok or resp == nil then return raw_body end local unified = { content = "", finish_reason = "", token_usage = { prompt = 0, completion = 0, total = 0 }, } if resp.usage then unified.token_usage.prompt = resp.usage.input_tokens or 0 unified.token_usage.completion = resp.usage.output_tokens or 0 unified.token_usage.total = (resp.usage.input_tokens or 0) + (resp.usage.output_tokens or 0) -- Emit details whenever Anthropic reports the field, even at 0, so a -- reported cache miss stays distinguishable from "not reported". if resp.usage.cache_read_input_tokens ~= nil then unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.cache_read_input_tokens } end end if resp.content and #resp.content > 0 then local tcs = {} for _, block in ipairs(resp.content) do if block.type == "text" then unified.content = unified.content .. (block.text or "") elseif block.type == "thinking" and block.thinking then unified.reasoning_content = (unified.reasoning_content or "") .. block.thinking elseif block.type == "tool_use" then table.insert(tcs, { id = block.id or "", type = "function", name = block.name or "", arguments = block.input or {}, }) end end if #tcs > 0 then unified.tool_calls = tcs end end unified.finish_reason = resp.stop_reason or "" if unified.finish_reason == "tool_use" then unified.finish_reason = "tool_calls" elseif unified.finish_reason == "max_tokens" then unified.finish_reason = "length" elseif unified.finish_reason == "end_turn" or unified.finish_reason == "stop_sequence" then unified.finish_reason = "stop" end return json.encode(unified) end -- ============================================================ -- transform_stream_chunk (Anthropic SSE → OpenAI SSE delta) -- ============================================================ function adapter.transform_stream_chunk(raw_chunk) local ok, chunk = pcall(json.decode, raw_chunk) if not ok then return "" end -- ── message_start: initial usage ───────────────────────── if chunk.type == "message_start" then if chunk.message and type(chunk.message.usage) == "table" then local u = chunk.message.usage local p = u.input_tokens or 0 local c = u.output_tokens or 0 if p > 0 or c > 0 then local uses = { prompt = p, completion = c, total = p + c } if u.cache_read_input_tokens ~= nil then uses.prompt_tokens_details = { cached_tokens = u.cache_read_input_tokens } end return json.encode({ usage = uses, done = false }) end end return "" end -- ── message_delta: stop_reason + final usage ───────────── if chunk.type == "message_delta" then local finish = nil if chunk.delta and chunk.delta.stop_reason ~= nil then local sr = chunk.delta.stop_reason if sr == "max_tokens" then finish = "length" elseif sr == "tool_use" then finish = "tool_calls" else finish = "stop" end end local uses = nil if type(chunk.usage) == "table" then local u = chunk.usage local p = u.input_tokens or 0 local c = u.output_tokens or 0 if p > 0 or c > 0 then uses = { prompt = p, completion = c, total = p + c } end end if uses ~= nil then return json.encode({ content = "", done = (finish ~= nil), finish_reason = finish, usage = uses }) end return json.encode({ content = "", done = (finish ~= nil), finish_reason = finish }) end -- ── content_block_start: begin text / thinking / tool_use ─ if chunk.type == "content_block_start" and chunk.content_block then local cb = chunk.content_block if cb.type == "tool_use" then -- Pass Anthropic content_block index through; Go-side rewrites -- to sequential OpenAI tool_call ordinal for multi-tool streams. return json.encode({ content = "", done = false, tool_calls = { { index = chunk.index or 0, id = cb.id or "", type = "function", ["function"] = { name = cb.name or "", arguments = "" }, } }, }) end return "" -- text / thinking block start: no OpenAI equivalent end -- ── content_block_delta: incremental content ────────────── if chunk.type == "content_block_delta" and chunk.delta then local d = chunk.delta if d.type == "input_json_delta" then -- Tool call argument fragment; Go-side rewrites index. return json.encode({ content = "", done = false, tool_calls = { { index = chunk.index or 0, id = "", type = "function", ["function"] = { name = "", arguments = d.partial_json or "" }, } }, }) end if d.type == "thinking_delta" and d.thinking then return json.encode({ content = "", done = false, reasoning_content = d.thinking }) end if d.type == "text_delta" and d.text then return json.encode({ content = d.text, done = false }) end end -- ── content_block_stop / message_stop ───────────────────── if chunk.type == "message_stop" then -- The message_delta event already emitted the true finish_reason. -- Do NOT emit done=true here: an empty finish_reason would -- overwrite the real one (tool_calls) in the Go gateway's -- lastFinish tracker, causing the final SSE chunk to say -- finish_reason=stop instead of tool_calls. return "" end return "" end -- ============================================================ -- transform_error (Anthropic error → human-readable string) -- ============================================================ function adapter.transform_error(status, body) local ok, resp = pcall(json.decode, body) if not ok or type(resp) ~= "table" then return nil end -- Anthropic envelope: {type:"error", error:{type, message}} if resp.type == "error" and type(resp.error) == "table" and type(resp.error.message) == "string" then return resp.error.message end -- Flat envelope: {error: {message: "..."}} if type(resp.error) == "table" and type(resp.error.message) == "string" then return resp.error.message end return nil end return adapter