Files
ModelRouter/internal/lua/adapters/anthropic.lua
JianFeeeee e4111337cd feat(gateway): /v1/messages 端到端 —— 内核不含任何 Anthropic 出站字段名
Phase 2 收尾。证明「对外协议由适配器决定、内核只调度」不是纸面主张:
Anthropic 客户端打 /v1/messages,拿到的是 Anthropic 形状。

- internal/gateway/messages.go:入向把 Anthropic 请求解析进统一格式
  (content blocks 原样保留——只有适配器知道自己的上游接受哪种块类型;
  system 支持字符串与 [{type:text,text}] 两种写法)。
  调度/配额/AUTO 链/冷却/审计/插件 hook 全部复用 /v1/chat/completions
  的同一条路径(serveUnified),差别只有两端线格式。
- internal/lua/adapters/anthropic.lua:新增 serialize_response /
  serialize_open_preamble / serialize_chunk / serialize_close,
  把统一格式译回 Anthropic Messages 形状(含 content 分块、
  stop_reason 反向映射、input/output_tokens 改名)。
- 流式是 message_start → content_block_delta → message_delta →
  message_stop;DoneMarker 为空所以内核**不会**擅自加 [DONE]。

内核出向零 Anthropic 字段名:解析是表驱动的,序列化属于适配器。

测试:
- messages_test.go 5 个端到端用例(真实 core+gateway + 只懂 Anthropic 的
  假上游):形状/stop_reason/块顺序/usage 命名、流式事件序列、入向校验、
  鉴权、以及「未声明钩子时回落 OpenAI 兜底」。
- 修正两个 serializer 测试的前提断言(anthropic 现在确实声明了钩子,
  这正是 Phase 2 的意图),并发测试改为按适配器各自校验形状。

golden 字节级未漂移;go test -tags luajit ./... 全绿。
2026-10-11 09:51:27 +08:00

580 lines
23 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
-- ============================================================
-- 对外序列化(统一格式 → Anthropic 形状)
-- ============================================================
--
-- 内核只持统一格式;对外发什么形状由适配器决定(Phase 2)。
-- 这几个钩子让以 anthropic 适配器为服务端的源,直接对 Anthropic 协议客户端
-- 提供 /v1/messages 语义,而内核里不再有任何 Anthropic 字段名。
--
-- 与入向的 transform_* 互为逆运算:那里的映射(end_turn→stop)在这里反着
-- 做回去(stop→end_turn),且默认 stop_sequence 是最保守的选择——它语义上
-- 最接近"正常结束"。
-- 统一 finish_reason → Anthropic stop_reason
local function to_stop_reason(finish)
if finish == "tool_calls" then return "tool_use" end
if finish == "length" then return "max_tokens" end
if finish == "content_filter" then return "refusal" end
-- stop / 空 / 未知 → end_turn(Anthropic 的"正常说完")
return "end_turn"
end
-- usage 数字化:内部 TokenUsage 的字段名与 Anthropic 的 input_tokens/output_tokens
-- 不同名,这里做最后一次翻译。
local function to_anthropic_usage(u)
if type(u) ~= "table" then
return { input_tokens = 0, output_tokens = 0 }
end
return {
input_tokens = u.prompt or 0,
output_tokens = u.completion or 0,
}
end
-- 非流式响应:统一结果 → Anthropic Messages 响应对象
function adapter.serialize_response(payload)
local p = json.decode(payload)
local blocks = {}
-- thinking 放最前,与 Anthropic 自己的块顺序一致
if p.reasoning ~= nil and p.reasoning ~= "" then
blocks[#blocks + 1] = { type = "thinking", thinking = p.reasoning }
end
blocks[#blocks + 1] = { type = "text", text = p.content or "" }
if type(p.tool_calls) == "table" and #p.tool_calls > 0 then
for _, tc in ipairs(p.tool_calls) do
blocks[#blocks + 1] = {
type = "tool_use",
id = tc.id or "",
name = tc.name or "",
input = tc.arguments or {},
}
end
end
return json.encode({
id = p.id or "",
type = "message",
role = "assistant",
model = p.model or "",
content = blocks,
stop_reason = to_stop_reason(p.finish_reason),
stop_sequence = nil,
usage = to_anthropic_usage(p.token_usage),
})
end
-- 角色前导:Anthropic 的流以 message_start 开头(不是 role delta)
function adapter.serialize_open_preamble(payload)
local p = json.decode(payload)
return json.encode({
type = "message_start",
message = {
id = p.id or "",
type = "message",
role = "assistant",
model = p.model or "",
content = {},
stop_reason = nil,
stop_sequence = nil,
usage = { input_tokens = 0, output_tokens = 0 },
},
})
end
-- 流式增量:统一 chunk → Anthropic content_block_delta
--
-- 真实 Anthropic 流需要 content_block_start(开块)→ delta → stop(关块)
-- 的三段式。这里用与多数 Anthropic 客户端兼容的简化形式:首个文本增量
-- 带 index=0 的 delta,后续同 index;关闭由 serialize_close 负责。
function adapter.serialize_chunk(payload)
local p = json.decode(payload)
if p.done then
-- 终止由 serialize_close 统一发,这里不重复
return ""
end
if p.reasoning ~= nil and p.reasoning ~= "" then
return json.encode({
type = "content_block_delta",
index = 0,
delta = { type = "thinking_delta", thinking = p.reasoning },
})
end
return json.encode({
type = "content_block_delta",
index = 0,
delta = { type = "text_delta", text = p.content or "" },
})
end
-- 收尾:message_delta(stop_reason + usage)→ message_stop
function adapter.serialize_close(payload)
local p = json.decode(payload)
return json.encode({
type = "message_delta",
delta = {
stop_reason = to_stop_reason(p.finish_reason),
stop_sequence = nil,
},
usage = { output_tokens = 0 },
}) .. "\n" .. json.encode({ type = "message_stop" })
end
return adapter