mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
plan.md 的 R2 目标里有三行钩子契约,出向两条(serialize_response/serialize_chunk)
Phase 2 已落地,第三条 parse_request(protocol_body) -> unified 一直空着:
/v1/messages 的入向解析硬编码在内核 Go 里(anthropicRequest.toUnified),
Anthropic 改协议就得改内核。本次补齐这条对偶。
## 机制(与出向序列化对称)
- 适配器声明 adapter.ingress = "/v1/messages" 认领入站路径,并实现
deserialize_request(raw_body) -> unified_json。
- 内核 handleMessages 经新增的 parseViaIngress 判定:
- 有适配器认领该路径且实现了钩子 → 让出,解析由适配器完成;
- 无适配器认领 → 内核内置解析运行(R5 兜底,未配置部署仍可服务)。
- 认领了但钩子执行失败 → 硬错误 400,不静默换用另一种协议解析
(客户端说的正是该适配器声称支持的协议,悄悄换解析器只会错误调度)。
- 这是内核里唯一的新判定点,与 serializerFor 对称:出向选序列化器,入向选解析器。
## 内核改动
- internal/lua/vm.go:staticInfo 增 ingress 字段;inspectScript 读取;
新增 VM.AdapterForIngress(path)(确定性选择:同名多认领取字典序最小)。
- internal/gateway/messages.go:handleMessages 改为先读 body → parseViaIngress
(让出)→ 未认领时回落内置 anthropicRequest.toUnified。
内置解析与 anthropicRequest 类型保留——它们是兜底,不是死代码。
## 适配器改动
- internal/lua/adapters/anthropic.lua:声明 adapter.ingress = "/v1/messages",
新增 deserialize_request,把内核原 Go 解析逻辑逐字段移植(system 两种拼写、
content 原样透传、缺省 thinking={enabled:false}、缺 messages 报错)。
内置 anthropic 默认随包播种,因此真实部署里内核总是让出给它,
Go 侧 anthropicRequest.toUnified 退化为纯兜底。
## 测试
- gateway:TestMessagesIngressYieldsToBundledAnthropic(让出后仍 200)、
TestAdapterForIngressClaim(声明可被找到、未声明路径为空、钩子存在)、
TestMessagesKernelFallbackWhenNoAdapterClaims(移除认领适配器后内核兜底仍 200)。
- lua:TestAnthropicDeserializeRequestShape 逐字段核对 Lua 与 Go 产出一致
(system 拼接、content、max_tokens/stream/temperature/top_p/top_k/stop_sequences、
缺省 thinking),TestAnthropicDeserializeRejectsMissingMessages。
- 全量回归:gateway/lua/core/config/types/provider/scheduler/e2e 全绿。
- 文档:docs/lua-adapters.md 补「入向让出钩子」章节 + plan.md 台账更新。
661 lines
26 KiB
Lua
661 lines
26 KiB
Lua
local adapter = {}
|
||
adapter.name = "anthropic"
|
||
adapter.version = "3.0.0"
|
||
adapter.endpoint = "/v1/messages"
|
||
adapter.headers = {
|
||
["anthropic-version"] = "2023-06-01",
|
||
}
|
||
-- INGRESS CLAIM (inbound dual of serialize_*): this adapter owns parsing of the
|
||
-- client-facing POST /v1/messages request body. The kernel keeps a built-in
|
||
-- Anthropic parser as its fallback (R5) — that is what an unconfigured
|
||
-- deployment uses — but once this adapter is loaded the kernel YIELDS, and the
|
||
-- deserialize_request hook below is the single place Anthropic's REQUEST shape
|
||
-- is understood. Iterating on that shape therefore means editing this file,
|
||
-- never the kernel.
|
||
adapter.ingress = "/v1/messages"
|
||
|
||
-- ============================================================
|
||
-- 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
|
||
|
||
-- ============================================================
|
||
-- deserialize_request (Anthropic → unified) [INGRESS]
|
||
-- ============================================================
|
||
-- The inbound dual of serialize_response. The kernel hands over the raw client
|
||
-- body and receives the unified request; from there scheduling is identical to
|
||
-- /v1/chat/completions. Ported field-for-field from the kernel's former
|
||
-- built-in anthropicRequest.toUnified — which remains as the kernel fallback
|
||
-- for an unconfigured deployment.
|
||
function adapter.deserialize_request(raw_body)
|
||
local ok, req = pcall(json.decode, raw_body)
|
||
if not ok or type(req) ~= "table" then
|
||
error("invalid json")
|
||
end
|
||
|
||
-- system accepts Anthropic's two spellings: a plain string, or an array of
|
||
-- { type = "text", text = ... } blocks. Only text blocks contribute.
|
||
local system = ""
|
||
local sys = req.system
|
||
if type(sys) == "string" then
|
||
system = sys
|
||
elseif type(sys) == "table" then
|
||
for _, b in ipairs(sys) do
|
||
if type(b) == "table" and b.type == "text" and type(b.text) == "string" then
|
||
system = system .. b.text
|
||
end
|
||
end
|
||
end
|
||
|
||
local msgs = {}
|
||
local in_msgs = req.messages
|
||
if type(in_msgs) ~= "table" or #in_msgs == 0 then
|
||
error("messages is required")
|
||
end
|
||
for i, m in ipairs(in_msgs) do
|
||
if type(m) ~= "table" or type(m.role) ~= "string" or m.role == "" then
|
||
error("messages[" .. (i - 1) .. "].role is required")
|
||
end
|
||
-- Content is carried VERBATIM: a string stays a string, a block array
|
||
-- stays an array. The unified format keeps multimodal/tool blocks
|
||
-- untouched because only an adapter knows which block types the upstream
|
||
-- accepts. An absent/null content becomes the empty string.
|
||
local content = m.content
|
||
if content == nil or content == "" then
|
||
content = ""
|
||
end
|
||
table.insert(msgs, { role = m.role, content = content })
|
||
end
|
||
|
||
-- Anthropic's default is thinking OFF; emit that explicitly so the unified
|
||
-- request carries the client's intent rather than leaving it absent.
|
||
local thinking = req.thinking
|
||
if thinking == nil then
|
||
thinking = { enabled = false }
|
||
end
|
||
|
||
local out = {
|
||
model = req.model or "",
|
||
messages = msgs,
|
||
system = system,
|
||
max_tokens = req.max_tokens or 0,
|
||
stream = req.stream == true,
|
||
temperature = req.temperature,
|
||
top_p = req.top_p,
|
||
top_k = req.top_k or 0,
|
||
stop_sequences = req.stop_sequences,
|
||
tools = req.tools,
|
||
tool_choice = req.tool_choice,
|
||
thinking = thinking,
|
||
extra_body = {},
|
||
}
|
||
return json.encode(out)
|
||
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
|