Files
ModelRouter/internal/lua/adapters/anthropic.lua
JianFeeeee 4174310421 fix(channels): 渠道鉴权头三修 + 源名契约 + 夹具形状判据
P1-1 anthropic 基座补 build_headers(x-api-key + anthropic-version)
  根因:基座只有静态 anthropic-version、没有 build_headers ⇒ 内核兜底发
  Authorization: Bearer,而 Anthropic 协议要 x-api-key。
  ★ 证据冲突(计划 P1-1 原文一半有误):zcode 的
  /zcode-plan/anthropic/v1/messages 虽然**说 Anthropic 协议**,鉴权却是
  Bearer <zcode_jwt>(参考 zcode-transport.ts:121-125;全仓 grep x-api-key
  = 0 次命中)。故 zcode 必须**显式覆盖** build_headers 回到 Bearer,
  否则基座的正向修复会把它从"对"改成"错"。
  内核改动:internal/provider/provider.go —— 兜底 Bearer 让位给 x-api-key,
  避免同一请求带两套鉴权(部分 Anthropic 兼容上游对歧义鉴权直接 401)。

P1-2 channels-cline 的 build_headers 补 workos: 前缀
  根因:参考 cline.ts 发 `Bearer workos:<jwt>`,而池里存的是第二步 register
  换回的裸 token ⇒ 之前无 build_headers,内核兜底发裸 Bearer。
  幂等:已带前缀(含大小写变体)不重复拼。

P1-3 channels-gemini 只确认不改
  参考 gemini.ts:353-366 同样是 OAuth Bearer + 身份五头、刻意不发
  x-goog-api-key ⇒ 我们现状本来就对。加判据把这个结论钉住,防下轮照
  P1-1 的模子把它改坏。

P2 源名契约:仅 [A-Za-z0-9_-],禁 : 与 /
  根因:: 与 / 是限定名分隔符,出现在源名里会让 `源名:模型名` 有两种都
  自圆其说的切法,同一请求串在不同部署下可能路由到不同源,且**不报错**。
  校验加在写入口 core.normalizeSource(硬拒,API 400);启动时只告警,
  避免存量带 : 的源名让升级 brick 掉整个网关。
  解析入口 splitPinned 不做校验(解析不该拒绝已有数据)。

P3 夹具形状判据(本轮最重要的一条)
  根因:trae 的 Result 信封缺陷在扁平夹具下所有判据全绿,真实登录却报
  「缺少 Token」—— 夹具形状错 ⇒ 判据必然虚假通过。
  通用形状层:shapeFamilies 登记表,每族给 real/naive/fact/eval/assert,
  断言 fact(real) 且 !fact(naive) 且 assert(eval(real))。
  刻意**不比较 eval 输出形状**:trae 余额实现合法保留顶层兼容路径,
  拿输出比对会把合法兼容判成错误;能区分真假的是 fact(额度在哪一层)。
  另有仓库扫描:对特征明确的退化形状静态反查,需退化夹具时写 SHAPE-OK 豁免。

判据:11 个文件 / 30 用例。全部变异已验证会报红(9 处)。
2026-10-18 00:38:49 +08:00

700 lines
28 KiB
Lua
Raw Permalink 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",
}
-- ============================================================
-- build_headers (鉴权头)
-- ============================================================
-- Anthropic 协议要 **x-api-key**,不是 `Authorization: Bearer`。
-- 此前本基座只有一个静态 `anthropic-version`、**没有** build_headers ⇒ 内核
-- 走兜底 `Authorization: Bearer <api_key>` ⇒ 上游一律 401/403:
-- `{"type":"error","error":{"type":"authentication_error",
-- "message":"invalid x-api-key"}}`
-- 继承本基座的渠道源(channels-zcode 等)一有账号就会立刻暴露 —— 与 trae
-- 修复前完全同型(静态头 + 内核 Bearer,缺协议要的那一个头)。
--
-- 三条纪律:
-- ① 值取自内核注入的 `meta.api_key`(源的凭据),**绝不**写日志、不额外
-- 放进返回值以外的任何地方;本函数只把它放进请求头。
-- ② **不自己发 Authorization** —— 源级 headers 若已显式配置 Authorization,
-- 重复发两种鉴权方案会让部分 Anthropic 兼容上游直接拒(歧义)。
-- ③ `anthropic-version` 与静态头同值,保证「装了 hook」与「没装 hook」两种
-- 路径产出同一份协议头,不会因为加了 hook 而少一个版本头。
function adapter.build_headers(meta)
local h = {
["anthropic-version"] = "2023-06-01",
}
local key = ""
if type(meta) == "table" then
if type(meta.api_key) == "string" then key = meta.api_key end
-- 兜底:个别调用方只给 source.meta.api_key(与 kimicode 同一约定)
if key == "" and type(meta.source) == "table"
and type(meta.source.meta) == "table"
and type(meta.source.meta.api_key) == "string" then
key = meta.source.meta.api_key
end
end
if key ~= "" then
h["x-api-key"] = key
end
return h
end
-- 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