Files
ModelRouter/internal/lua/adapters/anthropic.lua
JianFeeeee 93eeba3762 feat(types): 统一格式落地为内核实际使用的类型 —— 私有字段泄漏归零
Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为
UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。

改掉的具体缺陷:

1. 私有字段泄漏归零(v1 的核心问题)
   v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body,
   迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。
   现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与
   适配器都认这个词:
   - gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成
     thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能)
   - deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled
   - anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens}
     (enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法)
   - 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段"

2. Tools/ToolChoice 改 json.RawMessage
   原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。
   内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何
   内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。

3. 删除 MarshalJSON 后门
   v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是
   UnifiedRequest 上一个正正经经声明的字段。

渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译,
语义变更不被机械改名淹没。

测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传);
vm_test.go 两处迁移到中立语义。golden 字节级未漂移。
2026-10-11 09:35:32 +08:00

460 lines
19 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
}
-- Anthropic requires tool_use.id / tool_result.tool_use_id to match
-- ^[a-zA-Z0-9_-]{1,64}$ and rejects the WHOLE request otherwise with
-- REQUEST_BODY_INVALID / "Invalid tool use format". OpenAI imposes no such
-- rule, so an OpenAI-compatible upstream can hand a client an id like
-- "bash:0" (observed from moonshotai/kimi-k3); once that lands in the
-- client's history, every later replay kills every Claude slot at once and
-- an AUTO chain falls through all tiers.
--
-- safe_tool_id is pure and deterministic, so a tool_use block and its
-- matching tool_result are rewritten identically within one request. A
-- rewritten id keeps an 8-hex digest of the ORIGINAL id: without it two
-- distinct ids could collapse into one ("a:b" and "a_b"), which Anthropic
-- rejects as a duplicate/unpaired tool_use. Already-legal ids pass through
-- untouched so upstreams that round-trip their own ids are unaffected.
local TOOL_ID_MAX = 64
local function safe_tool_id(id)
if type(id) ~= "string" or id == "" then return "" end
local clean = string.gsub(id, "[^A-Za-z0-9_-]", "_")
if clean == id and #clean <= TOOL_ID_MAX then
return clean
end
local digest = string.sub(sha256_hex(id), 1, 8)
local keep = TOOL_ID_MAX - #digest - 1
if #clean > keep then clean = string.sub(clean, 1, keep) end
return clean .. "_" .. digest
end
-- map_usage converts one Anthropic usage object to the gateway's TokenUsage
-- shape. The two APIs disagree on what the prompt count MEANS:
--
-- Anthropic: input_tokens EXCLUDES cached blocks. cache_read_input_tokens and
-- cache_creation_input_tokens are separate, additive, billed input.
-- OpenAI: prompt_tokens INCLUDES its cached_tokens subset.
--
-- Mapping input_tokens straight onto prompt therefore did two wrong things at
-- once: it undercounted the billed prompt by the entire cache portion, and it
-- could report cached_tokens > prompt_tokens — a cache hit rate above 100%.
-- cache_creation_input_tokens was not read at all, so a cache-write turn lost
-- those billed tokens outright. Summing all three restores OpenAI semantics,
-- which is what clients (and the gateway's own audit trail) assume.
--
-- Returns nil when the object carries no counts, so callers can keep treating
-- "no usage in this chunk" as "say nothing" rather than reporting zeros.
local function map_usage(u)
if type(u) ~= "table" then return nil end
local fresh = u.input_tokens or 0
local read = u.cache_read_input_tokens or 0
local create = u.cache_creation_input_tokens or 0
local p = fresh + read + create
local c = u.output_tokens or 0
if p <= 0 and c <= 0 then return nil end
local uses = { prompt = p, completion = c, total = p + c }
-- Emit the split whenever Anthropic reports either cache field, even at 0:
-- a reported zero-hit ("cache missed") must stay distinguishable from "this
-- upstream never reports cache info at all".
if u.cache_read_input_tokens ~= nil or u.cache_creation_input_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = read }
uses.prompt_cache_hit_tokens = read
uses.prompt_cache_miss_tokens = fresh + create
end
return uses
end
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 = safe_tool_id(tc.id),
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 = safe_tool_id(m.tool_call_id),
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 ─────────────────────────────────────────────
-- Anthropic 的原生字段就是 {type, budget_tokens},所以中立语义几乎直译。
--
-- 中立格式(v2):{ enabled = true/false, budget_tokens = N }
-- enabled=false → 不传该字段(Anthropic 默认关闭思考,传 type=disabled
-- 是非法的,这也是过去只能「不传」的原因)
-- enabled=true → { type="enabled", budget_tokens=N 或默认 }
--
-- 仍接受 extra_body.thinking 的原生写法:已有部署的配置不因此失效。
local th = req.thinking
if type(th) == "table" and th.enabled ~= false then
local budget = th.budget_tokens
if budget == nil or budget <= 0 then budget = 4096 end -- Anthropic 必填
anthropic_req.thinking = { type = "enabled", budget_tokens = budget }
elseif type(req.extra_body) == "table" and type(req.extra_body.thinking) == "table" then
anthropic_req.thinking = req.extra_body.thinking
end
req.thinking = nil
-- ── 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 },
}
local mapped = map_usage(resp.usage)
if mapped ~= nil then
unified.token_usage = mapped
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 = safe_tool_id(block.id),
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 then
-- Some Anthropic-compatible upstreams report a placeholder here and
-- only send the true prompt count in message_delta (justwoker:
-- 160 at message_start vs 6931 at message_delta). The gateway's
-- mergeUsage lets a later non-zero value win, so both are emitted.
local uses = map_usage(chunk.message.usage)
if uses ~= nil then
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
-- message_delta carries the FINAL usage, so it must map the cache
-- fields too; the old code dropped them here, which silently lost the
-- whole cache split on every streamed response.
local uses = map_usage(chunk.usage)
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 = safe_tool_id(cb.id),
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