diff --git a/README.md b/README.md index ead99c6..178afd6 100644 --- a/README.md +++ b/README.md @@ -507,7 +507,8 @@ return { ### 内置适配器 -`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode`。 +`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode` +`qwen38` `sensenova` `trae`。 `anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式);若 源启用 `disable_thinking`,`deepseek` 适配器会把 `extra_body.thinking.type` 置为 diff --git a/README_EN.md b/README_EN.md index 8d7233b..b601221 100644 --- a/README_EN.md +++ b/README_EN.md @@ -487,7 +487,7 @@ Shared helpers: `hmac_sha256_hex(key, data)`, `sha256_hex(data)`, ### Built-in adapters `openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` -`kimicode`. +`kimicode` `qwen38` `sensenova` `trae`. `anthropic`/`gemini`/`ollama` include multimodal conversion (`image_url` → their native format); with `disable_thinking` the `deepseek` diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index ea67bc8..de7631c 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -796,7 +796,7 @@ func recordChatUsage(rec *Req, req *types.ChatRequest, resp *types.UnifiedRespon // keeps the billing plugin from guessing. // // Leaving it unrecorded is worse than useless: the adapters already parse it - // and forward it (opencodego/gemini/anthropic all do), so the number exists + // and forward it (gemini/anthropic/qwen38 all do), so the number exists // in the response and is thrown away at the one place that could keep it. if d := resp.TokenUsage.CompletionTokensDetails; d != nil && d.ReasoningTokens > 0 { rec.Reasoning = int64(d.ReasoningTokens) diff --git a/internal/gateway/chat_reasoning_test.go b/internal/gateway/chat_reasoning_test.go index cd22606..bc2ed4b 100644 --- a/internal/gateway/chat_reasoning_test.go +++ b/internal/gateway/chat_reasoning_test.go @@ -9,7 +9,7 @@ import ( // TestRecordChatUsageKeepsReasoningTokens pins the reasoning-token plumbing. // // The adapters already parse completion_tokens_details.reasoning_tokens and -// forward it (opencodego, gemini, anthropic all do), and types.go has carried +// forward it (qwen38, gemini, anthropic all do), and types.go has carried // the field since it was defined — but nothing ever READ it. The number arrived // in the response and was discarded at the one place that could have kept it, // so a pricing rule had no way to split thinking from output. diff --git a/internal/lua/adapters/agentrouter.lua b/internal/lua/adapters/agentrouter.lua deleted file mode 100644 index 4317f7e..0000000 --- a/internal/lua/adapters/agentrouter.lua +++ /dev/null @@ -1,212 +0,0 @@ -local adapter = {} - -adapter.name = "agentrouter" -adapter.version = "1.0.0" -adapter.endpoint = "/chat/completions" -adapter.headers = {} - --- AgentRouter 的阿里云 WAF 按三重维度校验:出口 IP(必须海外)、 --- TLS 指纹(JS/Go HTTP 层与 curl 不同)、浏览器指纹 header 集。 --- 纯 UA 已不够:WAF 拦 12 种 agent CLI UA 中的 10 种(全 405 HTML), --- 只有同时带 Origin: qwen.ai + Referer + X-Requested-With 才放行, --- 且同 IP 短时间连续 3+ 次请求会被速率封禁,靠 adapter 侧低频调用 + --- llmsproxy 的 prefFailStep 冷却自然限制,来源无法控制速率。 -local default_ua = "QwenCode/0.2.0 (linux; x64)" - --- AgentRouter fronts Claude models (claude-opus-4-8), and Claude upstreams --- enforce Anthropic's tool id rule ^[a-zA-Z0-9_-]{1,64}$ by rejecting the WHOLE --- request. Plain OpenAI does not, so an id minted by a permissive model (e.g. --- "bash:0" from moonshotai/kimi-k3) is replayed here by the client and kills --- every turn. See anthropic.lua / openai.lua for the same helper: Lua adapters --- have no shared prelude, so each carries its own copy. -local TOOL_ID_MAX = 64 - -local function safe_tool_id(id) - if type(id) ~= "string" or id == "" then return id 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 - -function adapter.transform_request(raw_body) - local ok, req = pcall(json.decode, raw_body) - if not ok then return raw_body end - req.disable_thinking = nil - req.extra_body = nil - if req.messages then - for _, msg in ipairs(req.messages) do - msg.reasoning_content = nil - if msg.tool_call_id ~= nil then - msg.tool_call_id = safe_tool_id(msg.tool_call_id) - end - if type(msg.tool_calls) == "table" then - for _, tc in ipairs(msg.tool_calls) do - if type(tc) == "table" and tc.id ~= nil then - tc.id = safe_tool_id(tc.id) - end - end - end - end - end - return json.encode(req) -end - --- 注入 Authorization + 官方客户端 User-Agent -function adapter.build_headers(meta) - local ua = default_ua - local meta_ua = (meta.source or {}).meta and (meta.source).meta.user_agent - if meta_ua and meta_ua ~= "" then - ua = meta_ua - end - return { - ["Content-Type"] = "application/json", - ["Authorization"] = "Bearer " .. meta.api_key, - ["User-Agent"] = ua, - -- QwenCode client fingerprint: WAF rejects bare UA without these. - ["Accept"] = "application/json, text/plain, */*", - ["Accept-Language"] = "zh-CN,zh;q=0.9", - ["Origin"] = "https://qwen.ai", - ["Referer"] = "https://qwen.ai/", - ["X-Requested-With"] = "XMLHttpRequest", - ["Connection"] = "keep-alive", - } -end - -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 type(resp.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 - local hit = 0 - if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then - hit = resp.usage.prompt_tokens_details.cached_tokens - unified.token_usage.prompt_tokens_details = { cached_tokens = hit } - end - if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then - unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens - unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0 - if hit == 0 then - unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens } - end - end - end - - if type(resp.choices) == "table" and #resp.choices > 0 then - local ch = resp.choices[1] - if type(ch.message) == "table" then - unified.content = ch.message.content or "" - if ch.message.reasoning_content then - unified.reasoning_content = ch.message.reasoning_content - end - if type(ch.message.tool_calls) == "table" then - local tcs = {} - for _, tc in ipairs(ch.message.tool_calls) do - local args_ok, args = pcall(json.decode, tc["function"].arguments) - if not args_ok then args = {} end - table.insert(tcs, { - id = safe_tool_id(tc.id), - type = tc.type or "function", - name = tc["function"].name, - arguments = args - }) - end - unified.tool_calls = tcs - end - end - unified.finish_reason = ch.finish_reason or "" - end - - return json.encode(unified) -end - -function adapter.transform_stream_chunk(raw_chunk) - local ok, chunk = pcall(json.decode, raw_chunk) - if not ok then return "" end - - -- usage-only terminal chunk (empty choices + usage): forward it so the - -- gateway emits exact token counts (and cache fields when present). - local uses = nil - if type(chunk.usage) == "table" then - uses = { - prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0, - completion = chunk.usage.completion_tokens or chunk.usage.completion or 0, - total = chunk.usage.total_tokens or chunk.usage.total or 0, - } - if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens } - elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then - uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens - uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0 - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } - end - end - - if not chunk.choices or #chunk.choices == 0 then - if uses ~= nil then - return json.encode({ usage = uses, done = false }) - end - return "" - end - local delta = chunk.choices[1].delta or {} - local fr = chunk.choices[1].finish_reason - - local finish = (type(fr) == "string" and fr ~= "") and fr or nil - - local unified = { - content = delta.content or "", - done = (finish ~= nil) - } - if finish then - unified.finish_reason = finish - end - if delta.reasoning_content then - unified.reasoning_content = delta.reasoning_content - end - if delta.tool_calls then - -- Sanitize outbound too: a dirty id must never enter a client session, - -- because the client replays it to every other source. Only the first - -- fragment of a streamed call carries an id; later argument fragments - -- have none and must stay id-less for index-based accumulation. - for _, tc in ipairs(delta.tool_calls) do - if type(tc) == "table" and tc.id ~= nil then - tc.id = safe_tool_id(tc.id) - end - end - unified.tool_calls = delta.tool_calls - end - if uses ~= nil then - unified.usage = uses - end - return json.encode(unified) -end - --- 错误收敛:前置 WAF 会返回整页 HTML(阿里云盾),JSON 时为 new-api 风格 -function adapter.transform_error(status, body) - local low = string.lower(body or "") - if string.sub(low, 1, 9) == " 0) - or msg.tool_call_id ~= nil then - msg.content = "" - else - drop = true - end - else - msg.content = parts - end - end - if not drop then - table.insert(kept, msg) - end - end - req.messages = kept - end - return json.encode(req) -end - -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 type(resp.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 - local hit = 0 - if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then - hit = resp.usage.prompt_tokens_details.cached_tokens - unified.token_usage.prompt_tokens_details = { cached_tokens = hit } - end - if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then - unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens - unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0 - if hit == 0 then - unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens } - end - end - end - - if type(resp.choices) == "table" and #resp.choices > 0 then - local ch = resp.choices[1] - if type(ch.message) == "table" then - unified.content = ch.message.content or "" - local reasoning = ch.message.reasoning_content or ch.message.reasoning - if reasoning then - unified.reasoning_content = reasoning - end - if type(ch.message.tool_calls) == "table" then - local tcs = {} - for _, tc in ipairs(ch.message.tool_calls) do - local args_ok, args = pcall(json.decode, tc["function"].arguments) - if not args_ok then args = {} end - table.insert(tcs, { - id = tc.id, - type = tc.type or "function", - name = tc["function"].name, - arguments = args - }) - end - unified.tool_calls = tcs - end - end - unified.finish_reason = ch.finish_reason or "" - end - - return json.encode(unified) -end - -function adapter.transform_stream_chunk(raw_chunk) - local ok, chunk = pcall(json.decode, raw_chunk) - if not ok then return "" end - - -- OpenAI-style streams may attach usage to a chunk with empty choices - -- (the final usage chunk). Preserve it; the gateway emits it as the - -- terminal usage chunk. Note: keys must match Go's TokenUsage json tags - -- (prompt/completion/total); the gateway re-emits standard *_tokens. - local uses = nil - if type(chunk.usage) == "table" then - uses = { - prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0, - completion = chunk.usage.completion_tokens or chunk.usage.completion or 0, - total = chunk.usage.total_tokens or chunk.usage.total or 0, - } - if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens } - elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then - uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens - uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0 - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } - end - end - - if not chunk.choices or #chunk.choices == 0 then - if uses ~= nil then - -- usage-only chunk is not a content/finish signal; the gateway - -- emits its own terminal stop chunk and merges this usage. - return json.encode({ usage = uses, done = false }) - end - return "" - end - local delta = chunk.choices[1].delta or {} - local fr = chunk.choices[1].finish_reason - - local finish = (type(fr) == "string" and fr ~= "") and fr or nil - - local unified = { - content = delta.content or "", - done = (finish ~= nil) - } - if finish then - unified.finish_reason = finish - end - if uses ~= nil then - unified.usage = uses - end - -- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content) - local reasoning = delta.reasoning_content or delta.reasoning - if reasoning then - unified.reasoning_content = reasoning - end - if delta.tool_calls then - -- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments - unified.tool_calls = delta.tool_calls - end - return json.encode(unified) -end - --- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}}; --- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。 -function adapter.transform_error(status, body) - local ok, resp = pcall(json.decode, body) - if not ok or type(resp) ~= "table" then return nil end - local e = resp.error - if type(e) ~= "table" then return nil end - if e.type == "FreeUsageLimitError" then - return "zen free pool quota exhausted" - end - return e.message -end - -return adapter diff --git a/internal/lua/adapters/opencodego.lua b/internal/lua/adapters/opencodego.lua deleted file mode 100644 index f1e094c..0000000 --- a/internal/lua/adapters/opencodego.lua +++ /dev/null @@ -1,354 +0,0 @@ -local adapter = {} - -adapter.name = "opencodego" -adapter.version = "1.0.0" -adapter.endpoint = "/chat/completions" - --- OpenCode Go(包月订阅端点,https://opencode.ai/zen/go/v1) --- --- 与 OpenCode Zen(opencodezen.lua,https://opencode.ai/zen/v1)是两个不同的 --- 服务,行为要求并不相同,因此各有专用适配器: --- --- * 本适配器(Go)服务包月订阅池。上游**强制要求** --- `x-opencode-session` 头,缺失直接 400 "MissingSessionID"。 --- * Go 的 thinking 模型(deepseek-v4.1-flash 等)**必须回传** assistant 轮的 --- `reasoning_content`,否则 400: --- The `reasoning_content` in the thinking mode must be passed back to the API. --- 这是本适配器与 Zen 适配器最关键的差异 —— Zen 会剥掉它,Go 必须原样保留。 --- * Go 上游对协议更严格:非流式请求带 `stream_options` 会被拒 --- ("stream_options should be set along with stream")。 --- --- 同样需要 opencode 客户端指纹(UA + x-opencode-*)才能被正确识别与路由。 -adapter.headers = { - ["User-Agent"] = "opencode/1.18.21 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14", -} - --- 每请求生成身份头。沙箱无 os/math,用 meta.timestamp + 请求体哈希派生: --- 同秒内重复请求 id 相同可接受(zen 只校验存在性,不校验格式)。 -local function rand_id(prefix, seed) - return prefix .. string.sub(sha256_hex(seed), 1, 24) -end - --- 会话指纹:取历史里**第一条 user 消息**。 --- --- 为什么不直接用客户端的会话 id:实测抓包(tcpdump 抓 127.0.0.1:8081 的真实 --- agent 请求)确认通用 OpenAI 客户端**根本不发**任何会话标识 —— body 里没有 --- user / session_id / conversation_id / metadata,请求头也只有 X-Stainless-* --- (OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套 x-opencode-session --- 是它自己的概念,通用客户端无从转发。 --- --- 退而求其次但要够用:历史被逐轮重放,**第一条 user 消息在整个会话里恒定**, --- 用它做指纹即可得到"每会话一个 session",而不是"每源一个 session"。 --- 拿不到时(无 user 消息)返回空串,调用方回退到按源稳定。 -local function conversation_fingerprint(body) - if type(body) ~= "string" or body == "" then return "" end - local ok, req = pcall(json.decode, body) - if not ok or type(req) ~= "table" then return "" end - for _, m in ipairs(req.messages or {}) do - if type(m) == "table" and m.role == "user" then - local c = m.content - if type(c) == "string" then - if c ~= "" then return c end - elseif type(c) == "table" then - for _, part in ipairs(c) do - if type(part) == "table" and part.type == "text" and part.text then - return part.text - end - end - end - return "" - end - end - return "" -end - --- session_seed 决定上游会话号(x-opencode-session 的种子)。 --- --- 优先级: --- 1) 客户端自带的会话标识(meta.client_session)——pi 等在开启 --- compat.sendSessionAffinityHeaders 后会发 x-session-affinity, --- 这是平台真实的会话 id,整个会话恒定且天然按会话隔离。 --- 2) 退路:历史里**第一条 user 消息**做会话指纹。 --- 3) 再退:只按源固定(连 user 消息都没有时)。 --- --- 为什么需要 2/3:通用 OpenAI 客户端默认**根本不发**会话标识 —— 实测抓包 --- (tcpdump 抓 127.0.0.1:8081 真实 agent 请求)确认 body 里没有 --- user / session_id / conversation_id / metadata,请求头也只有 --- X-Stainless-*(OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套 --- x-opencode-session 是它自己的概念,通用客户端无从转发。 --- --- 为什么必须稳定:上游前缀缓存是**会话级**的。会话号每请求一变, --- 缓存永不命中(实测:固定会号第 2 次命中 5888,每请求换会号则恒为 0)。 -local function session_seed(meta) - local src = (meta.source and meta.source.name) or "" - local base = "session|llmsproxy|" .. src - local cs = meta.client_session - if type(cs) == "string" and cs ~= "" then - return base .. "|client|" .. cs - end - return base .. "|" .. conversation_fingerprint(meta.body) -end - -function adapter.build_headers(meta) - local ts = tostring(meta.timestamp or "") - local src = (meta.source and meta.source.name) or "" - return { - ["User-Agent"] = adapter.headers["User-Agent"], - ["x-opencode-client"] = "cli", - -- project 固定:同网关实例共享一个工作区身份 - ["x-opencode-project"] = string.sub(sha256_hex("llmsproxy|" .. src), 1, 32), - -- session 必须**按源稳定**,不能每请求换:上游的前缀缓存在同一 session - -- 内才复用。实测(同一段 6032 token 提示词): - -- 固定 session -> 第 2 次命中 5888/6032,cached_tokens=5888 - -- 每请求换 session -> 永远 0 命中 - -- 原先用 meta.timestamp 派生,等于每请求都是新会话,缓存永远无效, - -- 上游也无法做会话亲和路由。 - ["x-opencode-session"] = rand_id("ses_", session_seed(meta)), - -- request id 仍每请求唯一(它只是请求标识,不参与缓存键) - ["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")), - } -end - --- OpenAI /chat/completions format (pass-through, strip provider-specific fields) --- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part --- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息 --- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游 --- "unknown variant `image_url`, expected `text`");带工具调用的必须保留, --- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。 --- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder: --- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游 --- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。 -local ROLE_WHITELIST = { - system = true, - user = true, - assistant = true, - tool = true, - latest_reminder = true, -} - -function adapter.transform_request(raw_body) - local ok, req = pcall(json.decode, raw_body) - if not ok then return raw_body end - req.disable_thinking = nil - req.extra_body = nil - -- stream_options is only valid alongside stream:true; sending it on a - -- non-streaming request is rejected by strict upstreams. - if req.stream then - if type(req.stream_options) ~= "table" then req.stream_options = {} end - req.stream_options.include_usage = true - end - if req.messages then - local kept = {} - for _, msg in ipairs(req.messages) do - if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then - msg.role = "system" - end - -- Go 的 thinking 模式**必须**回传 reasoning_content:剥掉它会让 - -- 上游直接 400("The `reasoning_content` in the thinking mode must - -- be passed back to the API"),agent 的每一轮都会失败。 - -- 注意:与 opencodezen.lua 的行为**相反**,不要在这里剥。 - -- - -- 但绝大多数客户端(pi 等)根本不保存也不回传 reasoning,只保留 - -- 工具调用本身。上游只在“带 tool_calls 的助手轮”上校验这个字段, - -- 实测**空串即可通过校验**,所以缺省时补空串:既满足上游的 - -- 一致性要求,又不伪造任何推理内容(用户看到的 reasoning 仍然是 - -- 上游本轮真实返回的)。 - if msg.role == "assistant" - and type(msg.tool_calls) == "table" and #msg.tool_calls > 0 - and msg.reasoning_content == nil then - msg.reasoning_content = "" - end - local drop = false - if type(msg.content) == "table" then - local parts = {} - for _, part in ipairs(msg.content) do - if type(part) == "table" and part.type ~= nil and part.type ~= "text" then - -- multimodal part not supported by zen - else - table.insert(parts, part) - end - end - if #parts == 0 then - -- Content collapsed to nothing after stripping unsupported - -- parts. A message that still carries a tool call must - -- NEVER be dropped: the very next message is its tool - -- result, and dropping the call orphans that result. The - -- model then sees a result for a call it never made and - -- re-issues the same tool call on every turn (observed as - -- an infinite "repeated tool call" loop). - if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0) - or msg.tool_call_id ~= nil then - msg.content = "" - else - drop = true - end - else - msg.content = parts - end - end - if not drop then - table.insert(kept, msg) - end - end - req.messages = kept - end - return json.encode(req) -end - -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 type(resp.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 - local hit = 0 - if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then - hit = resp.usage.prompt_tokens_details.cached_tokens - unified.token_usage.prompt_tokens_details = { cached_tokens = hit } - end - if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then - unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens - unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0 - if hit == 0 then - unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens } - end - end - -- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断 - -- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。 - local ctd = resp.usage.completion_tokens_details - if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then - unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } - end - end - - -- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。 - -- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。 - -- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。 - if resp.cost ~= nil then - unified.cost = tostring(resp.cost) - end - - if type(resp.choices) == "table" and #resp.choices > 0 then - local ch = resp.choices[1] - if type(ch.message) == "table" then - unified.content = ch.message.content or "" - local reasoning = ch.message.reasoning_content or ch.message.reasoning - if reasoning then - unified.reasoning_content = reasoning - end - if type(ch.message.tool_calls) == "table" then - local tcs = {} - for _, tc in ipairs(ch.message.tool_calls) do - local args_ok, args = pcall(json.decode, tc["function"].arguments) - if not args_ok then args = {} end - table.insert(tcs, { - id = tc.id, - type = tc.type or "function", - name = tc["function"].name, - arguments = args - }) - end - unified.tool_calls = tcs - end - end - unified.finish_reason = ch.finish_reason or "" - end - - return json.encode(unified) -end - -function adapter.transform_stream_chunk(raw_chunk) - local ok, chunk = pcall(json.decode, raw_chunk) - if not ok then return "" end - - -- OpenAI-style streams may attach usage to a chunk with empty choices - -- (the final usage chunk). Preserve it; the gateway emits it as the - -- terminal usage chunk. Note: keys must match Go's TokenUsage json tags - -- (prompt/completion/total); the gateway re-emits standard *_tokens. - local uses = nil - if type(chunk.usage) == "table" then - uses = { - prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0, - completion = chunk.usage.completion_tokens or chunk.usage.completion or 0, - total = chunk.usage.total_tokens or chunk.usage.total or 0, - } - -- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif: - -- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让 - -- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致 - -- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。 - if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens } - end - if (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then - uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens - uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0 - if uses.prompt_tokens_details == nil then - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } - end - end - local ctd = chunk.usage.completion_tokens_details - if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then - uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } - end - end - - if not chunk.choices or #chunk.choices == 0 then - -- 空 choices 的帧既不是内容也不是结束信号。上游发两种: - -- {"choices":[],"usage":{...}} —— 终帧用量 - -- {"choices":[],"cost":"0"} —— 单独的费用帧 - -- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。 - local out = { done = false } - if uses ~= nil then out.usage = uses end - if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end - if uses == nil and chunk.cost == nil then return "" end - return json.encode(out) - end - local delta = chunk.choices[1].delta or {} - local fr = chunk.choices[1].finish_reason - - local finish = (type(fr) == "string" and fr ~= "") and fr or nil - - local unified = { - content = delta.content or "", - done = (finish ~= nil) - } - if finish then - unified.finish_reason = finish - end - if uses ~= nil then - unified.usage = uses - end - -- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content) - local reasoning = delta.reasoning_content or delta.reasoning - if reasoning then - unified.reasoning_content = reasoning - end - if delta.tool_calls then - -- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments - unified.tool_calls = delta.tool_calls - end - return json.encode(unified) -end - --- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}}; --- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。 -function adapter.transform_error(status, body) - local ok, resp = pcall(json.decode, body) - if not ok or type(resp) ~= "table" then return nil end - local e = resp.error - if type(e) ~= "table" then return nil end - if e.type == "FreeUsageLimitError" then - return "zen free pool quota exhausted" - end - return e.message -end - -return adapter diff --git a/internal/lua/adapters/opencodezen.lua b/internal/lua/adapters/opencodezen.lua deleted file mode 100644 index 43097b7..0000000 --- a/internal/lua/adapters/opencodezen.lua +++ /dev/null @@ -1,342 +0,0 @@ -local adapter = {} - -adapter.name = "opencodezen" -adapter.version = "1.0.0" -adapter.endpoint = "/chat/completions" - --- OpenCode Zen(按量付费端点,https://opencode.ai/zen/v1) --- --- 与 OpenCode Go(opencodego.lua,https://opencode.ai/zen/go/v1)是两个不同的 --- 服务,行为要求并不相同,因此各有专用适配器: --- --- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端 --- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in --- OpenCode"。Zen 的免费池以非 thinking 模型为主,历史上回传 --- reasoning_content 会引出上游报错,故这里剥掉(Go 侧相反,见 opencodego.lua)。 --- * Go 订阅读者请看 opencodego.lua:那边**必须**回传 reasoning_content, --- 否则 thinking 模式直接 400。 --- --- 缺 x-opencode-client/session/request/project 身份头会被判为匿名客户端, --- 部分模型在带 tools 时上游直接失败(流式返回单 chunk --- "finish_reason":"network_error" 空 content,非流式 503 --- "Endpoint is unavailable",表现为"空回复")。 -adapter.headers = { - ["User-Agent"] = "opencode/1.18.21 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14", -} - --- 每请求生成身份头。沙箱无 os/math,用 meta.timestamp + 请求体哈希派生: --- 同秒内重复请求 id 相同可接受(zen 只校验存在性,不校验格式)。 -local function rand_id(prefix, seed) - return prefix .. string.sub(sha256_hex(seed), 1, 24) -end - --- 会话指纹:取历史里**第一条 user 消息**。 --- --- 为什么不直接用客户端的会话 id:实测抓包(tcpdump 抓 127.0.0.1:8081 的真实 --- agent 请求)确认通用 OpenAI 客户端**根本不发**任何会话标识 —— body 里没有 --- user / session_id / conversation_id / metadata,请求头也只有 X-Stainless-* --- (OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套 x-opencode-session --- 是它自己的概念,通用客户端无从转发。 --- --- 退而求其次但要够用:历史被逐轮重放,**第一条 user 消息在整个会话里恒定**, --- 用它做指纹即可得到"每会话一个 session",而不是"每源一个 session"。 --- 拿不到时(无 user 消息)返回空串,调用方回退到按源稳定。 -local function conversation_fingerprint(body) - if type(body) ~= "string" or body == "" then return "" end - local ok, req = pcall(json.decode, body) - if not ok or type(req) ~= "table" then return "" end - for _, m in ipairs(req.messages or {}) do - if type(m) == "table" and m.role == "user" then - local c = m.content - if type(c) == "string" then - if c ~= "" then return c end - elseif type(c) == "table" then - for _, part in ipairs(c) do - if type(part) == "table" and part.type == "text" and part.text then - return part.text - end - end - end - return "" - end - end - return "" -end - --- session_seed 决定上游会话号(x-opencode-session 的种子)。 --- --- 优先级: --- 1) 客户端自带的会话标识(meta.client_session)——pi 等在开启 --- compat.sendSessionAffinityHeaders 后会发 x-session-affinity, --- 这是平台真实的会话 id,整个会话恒定且天然按会话隔离。 --- 2) 退路:历史里**第一条 user 消息**做会话指纹。 --- 3) 再退:只按源固定(连 user 消息都没有时)。 --- --- 为什么需要 2/3:通用 OpenAI 客户端默认**根本不发**会话标识 —— 实测抓包 --- (tcpdump 抓 127.0.0.1:8081 真实 agent 请求)确认 body 里没有 --- user / session_id / conversation_id / metadata,请求头也只有 --- X-Stainless-*(OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套 --- x-opencode-session 是它自己的概念,通用客户端无从转发。 --- --- 为什么必须稳定:上游前缀缓存是**会话级**的。会话号每请求一变, --- 缓存永不命中(实测:固定会号第 2 次命中 5888,每请求换会号则恒为 0)。 -local function session_seed(meta) - local src = (meta.source and meta.source.name) or "" - local base = "session|llmsproxy|" .. src - local cs = meta.client_session - if type(cs) == "string" and cs ~= "" then - return base .. "|client|" .. cs - end - return base .. "|" .. conversation_fingerprint(meta.body) -end - -function adapter.build_headers(meta) - local ts = tostring(meta.timestamp or "") - local src = (meta.source and meta.source.name) or "" - return { - ["User-Agent"] = adapter.headers["User-Agent"], - ["x-opencode-client"] = "cli", - -- project 固定:同网关实例共享一个工作区身份 - ["x-opencode-project"] = string.sub(sha256_hex("llmsproxy|" .. src), 1, 32), - -- session 必须**按源稳定**,不能每请求换:上游的前缀缓存在同一 session - -- 内才复用。实测(同一段 6032 token 提示词): - -- 固定 session -> 第 2 次命中 5888/6032,cached_tokens=5888 - -- 每请求换 session -> 永远 0 命中 - -- 原先用 meta.timestamp 派生,等于每请求都是新会话,缓存永远无效, - -- 上游也无法做会话亲和路由。 - ["x-opencode-session"] = rand_id("ses_", session_seed(meta)), - -- request id 仍每请求唯一(它只是请求标识,不参与缓存键) - ["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")), - } -end - --- OpenAI /chat/completions format (pass-through, strip provider-specific fields) --- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part --- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息 --- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游 --- "unknown variant `image_url`, expected `text`");带工具调用的必须保留, --- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。 --- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder: --- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游 --- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。 -local ROLE_WHITELIST = { - system = true, - user = true, - assistant = true, - tool = true, - latest_reminder = true, -} - -function adapter.transform_request(raw_body) - local ok, req = pcall(json.decode, raw_body) - if not ok then return raw_body end - req.disable_thinking = nil - req.extra_body = nil - -- stream_options is only valid alongside stream:true; sending it on a - -- non-streaming request is rejected by strict upstreams. - if req.stream then - if type(req.stream_options) ~= "table" then req.stream_options = {} end - req.stream_options.include_usage = true - end - if req.messages then - local kept = {} - for _, msg in ipairs(req.messages) do - if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then - msg.role = "system" - end - -- Zen 免费池不接受回传 reasoning_content(Go 侧相反) - msg.reasoning_content = nil - local drop = false - if type(msg.content) == "table" then - local parts = {} - for _, part in ipairs(msg.content) do - if type(part) == "table" and part.type ~= nil and part.type ~= "text" then - -- multimodal part not supported by zen - else - table.insert(parts, part) - end - end - if #parts == 0 then - -- Content collapsed to nothing after stripping unsupported - -- parts. A message that still carries a tool call must - -- NEVER be dropped: the very next message is its tool - -- result, and dropping the call orphans that result. The - -- model then sees a result for a call it never made and - -- re-issues the same tool call on every turn (observed as - -- an infinite "repeated tool call" loop). - if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0) - or msg.tool_call_id ~= nil then - msg.content = "" - else - drop = true - end - else - msg.content = parts - end - end - if not drop then - table.insert(kept, msg) - end - end - req.messages = kept - end - return json.encode(req) -end - -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 type(resp.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 - local hit = 0 - if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then - hit = resp.usage.prompt_tokens_details.cached_tokens - unified.token_usage.prompt_tokens_details = { cached_tokens = hit } - end - if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then - unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens - unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0 - if hit == 0 then - unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens } - end - end - -- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断 - -- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。 - local ctd = resp.usage.completion_tokens_details - if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then - unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } - end - end - - -- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。 - -- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。 - -- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。 - if resp.cost ~= nil then - unified.cost = tostring(resp.cost) - end - - if type(resp.choices) == "table" and #resp.choices > 0 then - local ch = resp.choices[1] - if type(ch.message) == "table" then - unified.content = ch.message.content or "" - local reasoning = ch.message.reasoning_content or ch.message.reasoning - if reasoning then - unified.reasoning_content = reasoning - end - if type(ch.message.tool_calls) == "table" then - local tcs = {} - for _, tc in ipairs(ch.message.tool_calls) do - local args_ok, args = pcall(json.decode, tc["function"].arguments) - if not args_ok then args = {} end - table.insert(tcs, { - id = tc.id, - type = tc.type or "function", - name = tc["function"].name, - arguments = args - }) - end - unified.tool_calls = tcs - end - end - unified.finish_reason = ch.finish_reason or "" - end - - return json.encode(unified) -end - -function adapter.transform_stream_chunk(raw_chunk) - local ok, chunk = pcall(json.decode, raw_chunk) - if not ok then return "" end - - -- OpenAI-style streams may attach usage to a chunk with empty choices - -- (the final usage chunk). Preserve it; the gateway emits it as the - -- terminal usage chunk. Note: keys must match Go's TokenUsage json tags - -- (prompt/completion/total); the gateway re-emits standard *_tokens. - local uses = nil - if type(chunk.usage) == "table" then - uses = { - prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0, - completion = chunk.usage.completion_tokens or chunk.usage.completion or 0, - total = chunk.usage.total_tokens or chunk.usage.total or 0, - } - -- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif: - -- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让 - -- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致 - -- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。 - if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens } - end - if (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then - uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens - uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0 - if uses.prompt_tokens_details == nil then - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } - end - end - local ctd = chunk.usage.completion_tokens_details - if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then - uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } - end - end - - if not chunk.choices or #chunk.choices == 0 then - -- 空 choices 的帧既不是内容也不是结束信号。上游发两种: - -- {"choices":[],"usage":{...}} —— 终帧用量 - -- {"choices":[],"cost":"0"} —— 单独的费用帧 - -- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。 - local out = { done = false } - if uses ~= nil then out.usage = uses end - if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end - if uses == nil and chunk.cost == nil then return "" end - return json.encode(out) - end - local delta = chunk.choices[1].delta or {} - local fr = chunk.choices[1].finish_reason - - local finish = (type(fr) == "string" and fr ~= "") and fr or nil - - local unified = { - content = delta.content or "", - done = (finish ~= nil) - } - if finish then - unified.finish_reason = finish - end - if uses ~= nil then - unified.usage = uses - end - -- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content) - local reasoning = delta.reasoning_content or delta.reasoning - if reasoning then - unified.reasoning_content = reasoning - end - if delta.tool_calls then - -- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments - unified.tool_calls = delta.tool_calls - end - return json.encode(unified) -end - --- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}}; --- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。 -function adapter.transform_error(status, body) - local ok, resp = pcall(json.decode, body) - if not ok or type(resp) ~= "table" then return nil end - local e = resp.error - if type(e) ~= "table" then return nil end - if e.type == "FreeUsageLimitError" then - return "zen free pool quota exhausted" - end - return e.message -end - -return adapter diff --git a/internal/lua/toolcall_preservation_test.go b/internal/lua/toolcall_preservation_test.go index 74fb40d..ed56ea5 100644 --- a/internal/lua/toolcall_preservation_test.go +++ b/internal/lua/toolcall_preservation_test.go @@ -37,7 +37,7 @@ func TestAdaptersPreserveToolCalls(t *testing.T) { // Adapters that identify a tool result by the call id. byID := []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral", - "sensenova", "trae", "agentrouter", "opencode", "anthropic"} + "sensenova", "trae", "anthropic"} for _, a := range byID { out, err := vm.Transform(a, "transform_request", body) @@ -118,236 +118,3 @@ func TestAdaptersPreserveToolCalls(t *testing.T) { } } } - -// TestOpenCodeStreamOptionsOnlyWhenStreaming pins the fix for -// "stream_options should be set along with stream": OpenCode Go (and other -// strict OpenAI-compatible upstreams) reject a non-streaming request that -// carries stream_options, which broke every non-stream call through the -// opencode adapter. -func TestOpenCodeStreamOptionsOnlyWhenStreaming(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - - // non-streaming: stream_options must be absent entirely - out, err := vm.Transform("opencode", "transform_request", - `{"model":"m","messages":[{"role":"user","content":"hi"}]}`) - if err != nil { - t.Fatal(err) - } - if strings.Contains(out, "stream_options") { - t.Fatalf("non-streaming request must not carry stream_options: %s", out) - } - - // streaming: stream_options.include_usage must be set - sout, err := vm.Transform("opencode", "transform_request", - `{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}`) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(sout, "stream_options") || !strings.Contains(sout, "include_usage") { - t.Fatalf("streaming request must carry stream_options.include_usage: %s", sout) - } -} - -// TestOpenCodeGoVsZenReasoning pins the one behavioural difference that made a -// shared adapter wrong: OpenCode Go's thinking mode REQUIRES the assistant -// turn's reasoning_content to be echoed back ("The `reasoning_content` in the -// thinking mode must be passed back to the API"), while the Zen free pool must -// not receive it. Hence two purpose-built adapters. -func TestOpenCodeGoVsZenReasoning(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - - body := `{"model":"m","messages":[ - {"role":"user","content":"hi"}, - {"role":"assistant","content":"","reasoning_content":"I should read the file.","tool_calls":[{"id":"call_1","type":"function","function":{"name":"read","arguments":"{\"path\":\"/x\"}"}}]}, - {"role":"tool","tool_call_id":"call_1","content":"r"} - ]}` - - // Go: reasoning_content must survive, and the tool call must stay paired. - gout, err := vm.Transform("opencodego", "transform_request", body) - if err != nil { - t.Fatalf("opencodego: %v", err) - } - if !strings.Contains(gout, "I should read the file.") { - t.Errorf("opencodego must pass reasoning_content back (thinking mode requires it): %s", gout) - } - if !strings.Contains(gout, "call_1") || !strings.Contains(gout, "tool_call_id") { - t.Errorf("opencodego must keep the tool call paired with its result: %s", gout) - } - - // Zen: reasoning_content is stripped. - zout, err := vm.Transform("opencodezen", "transform_request", body) - if err != nil { - t.Fatalf("opencodezen: %v", err) - } - if strings.Contains(zout, "I should read the file.") { - t.Errorf("opencodezen must strip reasoning_content: %s", zout) - } - if !strings.Contains(zout, "call_1") { - t.Errorf("opencodezen must still keep the tool call: %s", zout) - } - - // An assistant turn that carries tool_calls but no reasoning_content must - // get an empty one injected: Go validates that field on tool-calling turns - // and 400s when it is absent, while most clients never send it. - // Verified against the live endpoint that an empty string passes. - bare := `{"model":"m","messages":[ - {"role":"user","content":"go"}, - {"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"read","arguments":"{}"}}]}, - {"role":"tool","tool_call_id":"c1","content":"r"} - ]}` - bout, err := vm.Transform("opencodego", "transform_request", bare) - if err != nil { - t.Fatalf("opencodego bare: %v", err) - } - if !strings.Contains(bout, `"reasoning_content":""`) { - t.Errorf("opencodego must inject an empty reasoning_content on a tool-calling turn: %s", bout) - } - // A plain assistant turn (no tool calls) must NOT gain the field. - plainAsst := `{"model":"m","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello"}]}` - pout, err := vm.Transform("opencodego", "transform_request", plainAsst) - if err != nil { - t.Fatal(err) - } - if strings.Contains(pout, "reasoning_content") { - t.Errorf("opencodego must not inject reasoning_content on a plain assistant turn: %s", pout) - } - - // Both must still omit stream_options on non-streaming requests. - for _, a := range []string{"opencodego", "opencodezen"} { - out, err := vm.Transform(a, "transform_request", `{"model":"m","messages":[{"role":"user","content":"hi"}]}`) - if err != nil { - t.Fatalf("%s: %v", a, err) - } - if strings.Contains(out, "stream_options") { - t.Errorf("%s must not send stream_options when not streaming: %s", a, out) - } - } -} - -// TestOpenCodeSessionIsStableForCache pins the session-derivation contract that -// makes the upstream prefix cache usable. -// -// Measured against the live OpenCode Go endpoint: the prefix cache is -// SESSION-scoped. Replaying the same 6032-token prompt hits 5888 cached tokens -// when the session id is fixed, and 0 when it changes per request. Deriving the -// session from meta.timestamp (the previous behaviour) meant every request was a -// new session, so the cache could never hit. -// -// The session must be stable WITHIN one conversation and differ BETWEEN -// conversations. Since generic OpenAI clients send no session id at all -// (verified by capturing real agent traffic: no user / session_id / -// conversation_id / metadata in the body, no session header), the conversation -// is fingerprinted by its FIRST user message — history is replayed every turn, -// so that message is invariant for the life of the conversation. -func TestOpenCodeSessionIsStableForCache(t *testing.T) { - for _, name := range []string{"opencodego", "opencodezen"} { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - meta := func(ts int, body string) map[string]interface{} { - return map[string]interface{}{ - "timestamp": ts, - "body": body, - "source": map[string]interface{}{"name": "somesource", "meta": nil}, - } - } - - // Same conversation, later turn: history grew, first user msg unchanged. - h1, err := vm.BuildHeaders(name, meta(1000, - `{"messages":[{"role":"user","content":"fix the bug"},{"role":"assistant","content":"looking"}]}`)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - h2, err := vm.BuildHeaders(name, meta(2000, - `{"messages":[{"role":"user","content":"fix the bug"},{"role":"assistant","content":"looking"},{"role":"user","content":"and tests"}]}`)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - // Different conversation. - h3, err := vm.BuildHeaders(name, meta(3000, - `{"messages":[{"role":"user","content":"an unrelated task"}]}`)) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - - if h1["x-opencode-session"] == "" { - t.Fatalf("%s: session header must be set", name) - } - if h1["x-opencode-session"] != h2["x-opencode-session"] { - t.Errorf("%s: x-opencode-session must be STABLE across turns of one conversation "+ - "(%q vs %q) — the prefix cache is session-scoped", name, h1["x-opencode-session"], h2["x-opencode-session"]) - } - if h1["x-opencode-session"] == h3["x-opencode-session"] { - t.Errorf("%s: a different conversation must get a different session", name) - } - if h1["x-opencode-request"] == h2["x-opencode-request"] { - t.Errorf("%s: x-opencode-request must differ per request", name) - } - // No user message at all falls back to per-source stability. - h4, _ := vm.BuildHeaders(name, meta(4000, `{"messages":[{"role":"system","content":"s"}]}`)) - h5, _ := vm.BuildHeaders(name, meta(5000, `{"messages":[{"role":"system","content":"s"}]}`)) - if h4["x-opencode-session"] != h5["x-opencode-session"] { - t.Errorf("%s: sessionless requests must still be stable per source", name) - } - vm.Stop() - } -} - -// 客户端自带的会话标识(pi 的 x-session-affinity)必须优先于会话指纹: -// 它是平台真实会话 id,历史压缩后也不会漂移,且天然按会话隔离。 -func TestOpenCodePrefersClientSessionID(t *testing.T) { - for _, name := range []string{"opencodego", "opencodezen"} { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - meta := func(body, cs string) map[string]interface{} { - return map[string]interface{}{ - "timestamp": 1000, - "body": body, - "client_session": cs, - "source": map[string]interface{}{"name": "somesource", "meta": nil}, - } - } - - // 同一条会话:历史在长,但客户端会话 id 不变。 - a, err := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, "cs-aaa")) - if err != nil { - t.Fatal(err) - } - b, err := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"a totally different opener"}]}`, "cs-aaa")) - if err != nil { - t.Fatal(err) - } - if a["x-opencode-session"] != b["x-opencode-session"] { - t.Errorf("%s: 客户端会话 id 相同就必须同会话(内容无关): %q vs %q", - name, a["x-opencode-session"], b["x-opencode-session"]) - } - - // 不同会话。 - c, _ := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, "cs-bbb")) - if a["x-opencode-session"] == c["x-opencode-session"] { - t.Errorf("%s: 不同客户端会话必须不同会号", name) - } - - // 客户端没给会话 id 时,回落到指纹(同内容 -> 同会号)。 - d, _ := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, "")) - e, _ := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, "")) - if d["x-opencode-session"] != e["x-opencode-session"] { - t.Errorf("%s: 无客户端会号时应回落到稳定指纹", name) - } - if d["x-opencode-session"] == a["x-opencode-session"] { - t.Errorf("%s: 客户端会号与指纹两条路必须产出不同会号", name) - } - vm.Stop() - } -} diff --git a/internal/lua/usage_cost_test.go b/internal/lua/usage_cost_test.go deleted file mode 100644 index dd36ffa..0000000 --- a/internal/lua/usage_cost_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package lua - -import ( - "encoding/json" - "testing" -) - -// OpenCode Zen/Go 在 usage 里报告输出有多少是思考 token,并在**顶层**用一个 -// **字符串**给出本次费用。前者决定「completion_tokens 里多少是可见回答」, -// 后者是唯一的费用信号。两者都曾被适配器丢掉。 -func TestOpenCodeCostAndReasoningPassthrough(t *testing.T) { - for _, name := range []string{"opencodego", "opencodezen"} { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - - // ---- 非流式 ---- - resp := `{"id":"x","object":"chat.completion","model":"deepseek-v4.1-flash", - "choices":[{"index":0,"message":{"role":"assistant","content":"hi","reasoning_content":"think"}, - "finish_reason":"stop"}], - "usage":{"prompt_tokens":37,"completion_tokens":40,"total_tokens":77, - "prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":37, - "prompt_tokens_details":{"cached_tokens":0}, - "completion_tokens_details":{"reasoning_tokens":40}}, - "cost":"0"}` - out, err := vm.Transform(name, "transform_response", resp) - if err != nil { - t.Fatalf("%s transform_response: %v", name, err) - } - var r struct { - TokenUsage struct { - Completion int `json:"completion"` - Details *struct { - ReasoningTokens int `json:"reasoning_tokens"` - } `json:"completion_tokens_details"` - } `json:"token_usage"` - Cost string `json:"cost"` - } - if err := json.Unmarshal([]byte(out), &r); err != nil { - t.Fatalf("%s unmarshal: %v (%s)", name, err, out) - } - if r.Cost != "0" { - t.Errorf("%s: cost 未透传(上游给的是字符串 \"0\"): %q", name, r.Cost) - } - if r.TokenUsage.Details == nil || r.TokenUsage.Details.ReasoningTokens != 40 { - t.Errorf("%s: completion_tokens_details.reasoning_tokens 未透传: %s", name, out) - } - // 前提校验:reasoning_tokens 确实 ≤ completion_tokens(全为思考)。 - if r.TokenUsage.Details != nil && r.TokenUsage.Details.ReasoningTokens > r.TokenUsage.Completion { - t.Errorf("%s: reasoning 不应超过 completion", name) - } - - // ---- 流式:非零费用也要透传 ---- - chunk := `{"id":"x","object":"chat.completion.chunk","choices":[], - "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15, - "completion_tokens_details":{"reasoning_tokens":4}},"cost":"0.0012"}` - co, err := vm.Transform(name, "transform_stream_chunk", chunk) - if err != nil { - t.Fatalf("%s transform_stream_chunk: %v", name, err) - } - var c struct { - Cost string `json:"cost"` - Usage *struct { - Details *struct { - ReasoningTokens int `json:"reasoning_tokens"` - } `json:"completion_tokens_details"` - } `json:"usage"` - } - if err := json.Unmarshal([]byte(co), &c); err != nil { - t.Fatalf("%s unmarshal chunk: %v (%s)", name, err, co) - } - if c.Usage == nil || c.Usage.Details == nil || c.Usage.Details.ReasoningTokens != 4 { - t.Errorf("%s: 流式 reasoning_tokens 未透传: %s", name, co) - } - if c.Cost != "0.0012" { - t.Errorf("%s: 流式 cost 未透传: %s", name, co) - } - - // ---- 独立的 cost 帧(上游就这么发:choices 为空、只有 cost)---- - only := `{"choices":[],"cost":"0"}` - oo, err := vm.Transform(name, "transform_stream_chunk", only) - if err != nil { - t.Fatalf("%s cost-only chunk: %v", name, err) - } - if oo == "" { - t.Fatalf("%s: 纯 cost 帧被整个丢弃(客户端将看不到费用)", name) - } - var oc struct { - Cost string `json:"cost"` - } - if err := json.Unmarshal([]byte(oo), &oc); err != nil { - t.Fatalf("%s unmarshal cost chunk: %v (%s)", name, err, oo) - } - if oc.Cost != "0" { - t.Errorf("%s: 纯 cost 帧内容丢失: %s", name, oo) - } - - // ---- 回归:没有 cost / 没有 details 时不得凭空造字段 ---- - bare := `{"choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}], - "usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}` - bo, _ := vm.Transform(name, "transform_response", bare) - if got := bo; len(got) > 0 && containsAll(got, `"cost"`) { - t.Errorf("%s: 上游没给 cost 却透传了该字段: %s", name, got) - } - - vm.Stop() - } -} - -func containsAll(s string, subs ...string) bool { - for _, sub := range subs { - found := false - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { - found = true - break - } - } - if !found { - return false - } - } - return true -} - -// 流式与非流式对同一上游切片的产出必须一致:命中缓存时上游同时给 -// prompt_tokens_details.cached_tokens 和独立的 hit/miss 字段,两边都要透传。 -func TestOpenCodeStreamCacheFieldsMatchNonStream(t *testing.T) { - for _, name := range []string{"opencodego", "opencodezen"} { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - type cache struct { - Details *struct { - CachedTokens int `json:"cached_tokens"` - } `json:"prompt_tokens_details"` - Hit int `json:"prompt_cache_hit_tokens"` - Miss int `json:"prompt_cache_miss_tokens"` - } - usage := `"usage":{"prompt_tokens":5732,"completion_tokens":20,"total_tokens":5752, - "prompt_cache_hit_tokens":5504,"prompt_cache_miss_tokens":228, - "prompt_tokens_details":{"cached_tokens":5504}}` - - // 非流式 - nout, _ := vm.Transform(name, "transform_response", - `{"choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],`+usage+`}`) - var nr struct { - U cache `json:"token_usage"` - } - if err := json.Unmarshal([]byte(nout), &nr); err != nil { - t.Fatalf("%s: %v", name, err) - } - // 流式 - sout, _ := vm.Transform(name, "transform_stream_chunk", - `{"choices":[],`+usage+`}`) - var sr struct { - U cache `json:"usage"` - } - if err := json.Unmarshal([]byte(sout), &sr); err != nil { - t.Fatalf("%s chunk: %v (%s)", name, err, sout) - } - - if nr.U.Hit != 5504 || nr.U.Miss != 228 { - t.Errorf("%s: 非流式 hit/miss 异常: %+v", name, nr.U) - } - if sr.U.Hit != nr.U.Hit || sr.U.Miss != nr.U.Miss { - t.Errorf("%s: 流式 hit/miss 与非流式不一致 (stream %d/%d vs non-stream %d/%d): %s", - name, sr.U.Hit, sr.U.Miss, nr.U.Hit, nr.U.Miss, sout) - } - if sr.U.Details == nil || sr.U.Details.CachedTokens != 5504 { - t.Errorf("%s: 流式 cached_tokens 丢失: %s", name, sout) - } - vm.Stop() - } -} diff --git a/internal/lua/vm_test.go b/internal/lua/vm_test.go index eac3fce..4f39844 100644 --- a/internal/lua/vm_test.go +++ b/internal/lua/vm_test.go @@ -32,7 +32,7 @@ func TestLoadBundledAdapters(t *testing.T) { for _, a := range adapters { names[a.Name] = true } - for _, want := range []string{"openai", "deepseek", "anthropic", "gemini", "ollama", "kimicode", "opencode"} { + for _, want := range []string{"openai", "deepseek", "anthropic", "gemini", "ollama", "kimicode"} { if !names[want] { t.Errorf("missing adapter %s (got %v)", want, names) } @@ -112,179 +112,6 @@ func TestBuildHeadersFallbackStatic(t *testing.T) { } } -func TestOpenCodeAdapterFingerprint(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - hdrs, err := vm.BuildHeaders("opencode", map[string]interface{}{ - "api_key": "public", - "timestamp": 1700000000, - "body": `{"model":"x"}`, - "url": "https://opencode.ai/zen/v1/chat/completions", - "method": "POST", - }) - if err != nil { - t.Fatalf("build headers: %v", err) - } - // zen 按客户端指纹路由请求池:UA 必须是 opencode 真实格式, - // 且必须携带 x-opencode-* 身份头,否则带 tools 的请求会被上游 - // 判为匿名客户端并返回 network_error 空流(表现为空回复)。 - if ua := hdrs["User-Agent"]; !strings.HasPrefix(ua, "opencode/") || - !strings.Contains(ua, "ai-sdk/provider-utils/") { - t.Fatalf("opencode adapter must send the real opencode client UA, got %q", ua) - } - for _, h := range []string{"x-opencode-client", "x-opencode-project", "x-opencode-session", "x-opencode-request"} { - if hdrs[h] == "" { - t.Fatalf("opencode adapter must send %q (zen client fingerprint), got %v", h, hdrs) - } - } - hdrs2, _ := vm.BuildHeaders("opencode", map[string]interface{}{"api_key": "public", "timestamp": 1700000001, "body": `{"model":"y"}`, "method": "POST"}) - if hdrs["x-opencode-request"] == hdrs2["x-opencode-request"] { - t.Fatalf("x-opencode-request must vary per request, got %q twice", hdrs["x-opencode-request"]) - } - if hdrs["Authorization"] != "" { - t.Fatalf("opencode adapter must not hardcode Authorization (config api_key supplies it), got %q", hdrs["Authorization"]) - } - // passthrough behaves like the openai adapter - out, err := vm.Transform("opencode", "transform_request", `{"model":"x","disable_thinking":true,"extra_body":{},"messages":[{"role":"user","content":"hi"}]}`) - if err != nil { - t.Fatal(err) - } - if strings.Contains(out, "disable_thinking") || strings.Contains(out, "extra_body") { - t.Fatalf("opencode transform_request must strip provider fields: %s", out) - } -} - -func TestOpenCodeNormalizesRoles(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - // zen 上游只接受 system/user/assistant/tool/latest_reminder; - // OpenAI 系客户端发 `developer`(Claude Code 还会用 `function`)必须归一化成 system。 - body := `{"model":"x","messages":[ - {"role":"developer","content":"you are helpful"}, - {"role":"system","content":"sys"}, - {"role":"user","content":"hi"}, - {"role":"assistant","content":"ok"}, - {"role":"tool","tool_call_id":"t1","content":"{\"ok\":true}"} - ]}` - out, err := vm.Transform("opencode", "transform_request", body) - if err != nil { - t.Fatal(err) - } - if strings.Contains(out, `"developer"`) { - t.Fatalf("developer role must be normalized: %s", out) - } - if !strings.Contains(out, `"system"`) || !strings.Contains(out, `"user"`) { - t.Fatalf("allowed roles must survive: %s", out) - } - if !strings.Contains(out, `"assistant"`) { - t.Fatalf("assistant role must survive: %s", out) - } - if !strings.Contains(out, `"tool"`) { - t.Fatalf("tool role must survive: %s", out) - } -} - -func TestOpenCodeStripsMultiModalParts(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - body := `{"model":"x","messages":[ - {"role":"user","content":[ - {"type":"text","text":"describe"}, - {"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}, - {"type":"input_audio","input_audio":{"data":"QQ","format":"wav"}} - ]}, - {"role":"user","content":[{"type":"image_url","image_url":{"url":"https://ex.com/a.png"}}]}, - {"role":"assistant","content":"plain"} - ]}` - out, err := vm.Transform("opencode", "transform_request", body) - if err != nil { - t.Fatal(err) - } - if strings.Contains(out, "image_url") || strings.Contains(out, "input_audio") { - t.Fatalf("opencode must strip multimodal parts: %s", out) - } - if !strings.Contains(out, "describe") { - t.Fatalf("text parts must survive: %s", out) - } - // image-only message dropped: only the text message and plain assistant message remain - if got := strings.Count(out, `"role"`); got != 2 { - t.Fatalf("image-only message must be dropped, got %d messages: %s", got, out) - } -} - -// TestOpenCodeKeepsToolCallWithEmptyContent pins the fix for an infinite -// "repeated tool call" loop: an assistant turn whose content serialises to an -// empty ARRAY while carrying tool_calls must survive the multimodal strip. -// Dropping it orphans the following tool result, so the model never sees the -// call it already made and re-issues it on every turn. -func TestOpenCodeKeepsToolCallWithEmptyContent(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatal(err) - } - defer vm.Stop() - - body := `{"model":"x","messages":[ - {"role":"user","content":"read /tmp/x"}, - {"role":"assistant","content":[],"tool_calls":[{"id":"call_abc","type":"function","function":{"name":"read","arguments":"{\"path\":\"/tmp/x\"}"}}]}, - {"role":"tool","tool_call_id":"call_abc","content":"hello"}, - {"role":"user","content":"now summarize"} - ]}` - out, err := vm.Transform("opencode", "transform_request", body) - if err != nil { - t.Fatalf("transform_request: %v", err) - } - - var req struct { - Messages []struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - ToolCalls []struct { - ID string `json:"id"` - } `json:"tool_calls"` - ToolCallID string `json:"tool_call_id"` - } `json:"messages"` - } - if err := json.Unmarshal([]byte(out), &req); err != nil { - t.Fatalf("unmarshal: %v (%s)", err, out) - } - if len(req.Messages) != 4 { - t.Fatalf("expected 4 messages (tool call must not be dropped), got %d: %s", len(req.Messages), out) - } - asst := req.Messages[1] - if asst.Role != "assistant" || len(asst.ToolCalls) != 1 || asst.ToolCalls[0].ID != "call_abc" { - t.Fatalf("assistant tool_calls lost: %s", out) - } - if string(asst.Content) != `""` { - t.Fatalf("empty content must be rewritten to \"\", got %s", asst.Content) - } - if req.Messages[2].Role != "tool" || req.Messages[2].ToolCallID != "call_abc" { - t.Fatalf("tool result must stay paired with its call: %s", out) - } - - // Negative control: a message with neither text nor tool calls is still dropped. - only := `{"model":"x","messages":[ - {"role":"user","content":[{"type":"image_url","image_url":{"url":"https://ex.com/a.png"}}]}, - {"role":"user","content":"keep me"} - ]}` - out2, err := vm.Transform("opencode", "transform_request", only) - if err != nil { - t.Fatal(err) - } - if got := strings.Count(out2, `"role"`); got != 1 { - t.Fatalf("image-only message without tool calls must still be dropped, got %d: %s", got, out2) - } -} - func TestBuildHeadersCustomHook(t *testing.T) { vm := NewVM(freshAdapterDir(t)) if err := vm.Start(); err != nil { @@ -432,74 +259,6 @@ func TestMultimodalTransform(t *testing.T) { } } -func TestOpenCodeAdapterNormalizesDeveloperRole(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatalf("start: %v", err) - } - defer vm.Stop() - - raw := `{"model":"zen","messages":[` + - `{"role":"developer","content":"be concise"},` + - `{"role":"user","content":"hi"},` + - `{"role":"assistant","content":"hello"},` + - `{"role":"function","content":"{\"a\":1}"},` + - `{"role":"latest_reminder","content":"remind"}]}` - out, err := vm.Transform("opencode", "transform_request", raw) - if err != nil { - t.Fatalf("transform_request: %v", err) - } - var req struct { - Messages []struct { - Role string `json:"role"` - } `json:"messages"` - } - if err := json.Unmarshal([]byte(out), &req); err != nil { - t.Fatalf("output not JSON: %v\n%s", err, out) - } - want := []string{"system", "user", "assistant", "system", "latest_reminder"} - if len(req.Messages) != len(want) { - t.Fatalf("got %d messages, want %d: %s", len(req.Messages), len(want), out) - } - for i, w := range want { - if req.Messages[i].Role != w { - t.Errorf("message %d role = %q, want %q", i, req.Messages[i].Role, w) - } - } -} - -func TestOpenCodeAdapterKeepsWhitelistedRolesAndDropsMultimodal(t *testing.T) { - vm := NewVM(freshAdapterDir(t)) - if err := vm.Start(); err != nil { - t.Fatalf("start: %v", err) - } - defer vm.Stop() - - raw := `{"messages":[` + - `{"role":"system","content":"sys"},` + - `{"role":"user","content":[{"type":"text","text":"keep"},{"type":"image_url","url":"x"}]},` + - `{"role":"user","content":[{"type":"image_url","url":"x"}]}]}` - out, err := vm.Transform("opencode", "transform_request", raw) - if err != nil { - t.Fatalf("transform_request: %v", err) - } - var req struct { - Messages []struct { - Role string `json:"role"` - Content any `json:"content"` - } `json:"messages"` - } - if err := json.Unmarshal([]byte(out), &req); err != nil { - t.Fatalf("output not JSON: %v\n%s", err, out) - } - if len(req.Messages) != 2 { - t.Fatalf("got %d messages, want 2 (multimodal-only dropped): %s", len(req.Messages), out) - } - if req.Messages[0].Role != "system" || req.Messages[1].Role != "user" { - t.Fatalf("unexpected roles: %s", out) - } -} - func TestAdaptersPassFinishReason(t *testing.T) { vm := NewVM(freshAdapterDir(t)) if err := vm.Start(); err != nil { @@ -511,7 +270,7 @@ func TestAdaptersPassFinishReason(t *testing.T) { // every chunk) must NOT terminate the stream. chunk := `{"choices":[{"index":0,"finish_reason":"tool_calls","delta":{"content":""}}]}` empty := `{"choices":[{"index":0,"finish_reason":"","delta":{"content":"x"}}]}` - for _, name := range []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral", "opencode"} { + for _, name := range []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral"} { out, err := vm.Transform(name, "transform_stream_chunk", chunk) if err != nil { t.Fatalf("%s: %v", name, err) @@ -736,8 +495,7 @@ func TestAdaptersReportZeroCacheHit(t *testing.T) { defer vm.Stop() // OpenAI-shaped upstreams: usage.prompt_tokens_details.cached_tokens = 0 - openaiLike := []string{"openai", "deepseek", "sensenova", "opencode", - "agentrouter", "github", "groq", "kimicode", "mistral"} + openaiLike := []string{"openai", "deepseek", "sensenova", "github", "groq", "kimicode", "mistral"} respBody := `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}], "usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12, "prompt_tokens_details":{"cached_tokens":0}}}` @@ -1465,7 +1223,7 @@ func TestNonStreamToolCallsPreserved(t *testing.T) { for _, name := range []string{ "openai", "sensenova", "trae", "deepseek", "github", - "groq", "kimicode", "mistral", "opencode", "agentrouter", + "groq", "kimicode", "mistral", } { out, err := vm.Transform(name, "transform_response", body) if err != nil { @@ -1741,9 +1499,8 @@ func TestToolIDSanitize(t *testing.T) { } } - // ---- openai + agentrouter: same rewrite, and both sites must stay in sync ---- - // agentrouter fronts claude-opus-4-8, so it inherits the same id rule. - for _, adapterName := range []string{"openai", "agentrouter"} { + // ---- openai: tool-id rewrite ---- + for _, adapterName := range []string{"openai"} { for _, id := range []string{"bash:0", "call_ok_123", long} { out, err := vm.Transform(adapterName, "transform_request", body(id)) if err != nil { @@ -1838,12 +1595,12 @@ func TestToolIDSanitizeResponse(t *testing.T) { legal := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) - // ---- openai + agentrouter non-streaming ---- + // ---- openai non-streaming ---- resp := `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[ {"id":"bash:0","type":"function","function":{"name":"bash","arguments":"{\"command\":\"whoami\"}"}}]}, "finish_reason":"tool_calls"}]}` var nonStreamID string - for _, adapterName := range []string{"openai", "agentrouter"} { + for _, adapterName := range []string{"openai"} { out, err := vm.Transform(adapterName, "transform_response", resp) if err != nil { t.Fatalf("%s transform_response: %v", adapterName, err) @@ -1878,7 +1635,7 @@ func TestToolIDSanitizeResponse(t *testing.T) { ID string `json:"id"` } `json:"tool_calls"` } - for _, adapterName := range []string{"openai", "agentrouter"} { + for _, adapterName := range []string{"openai"} { out, err := vm.Transform(adapterName, "transform_stream_chunk", chunk) if err != nil { t.Fatalf("%s transform_stream_chunk: %v", adapterName, err) @@ -1902,7 +1659,7 @@ func TestToolIDSanitizeResponse(t *testing.T) { // ---- a later argument fragment has no id and must stay id-less ---- argChunk := `{"choices":[{"delta":{"tool_calls":[ {"index":0,"function":{"arguments":"{\"command\":\"whoami\"}"}}]},"finish_reason":null}]}` - for _, adapterName := range []string{"openai", "agentrouter"} { + for _, adapterName := range []string{"openai"} { out, err := vm.Transform(adapterName, "transform_stream_chunk", argChunk) if err != nil { t.Fatalf("%s arg fragment: %v", adapterName, err) diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index aaea842..59a6d14 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -457,7 +457,7 @@ func TestChatStreamErrorFinishFailsCandidate(t *testing.T) { fmt.Fprint(w, "data: [DONE]\n\n") })) defer up.Close() - p := newTestProvider(t, src("mock", up.URL, "opencode", "m")) + p := newTestProvider(t, src("mock", up.URL, "openai", "m")) _, err := p.ChatStream(context.Background(), &types.ChatRequest{ Model: "m", Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}}, @@ -546,20 +546,23 @@ func TestStandardSSEChunkFinishReason(t *testing.T) { // per-source error format; its reason must reach the client verbatim. func TestAdapterHookCondensesError(t *testing.T) { up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") + // A non-JSON HTML error page: the adapter's transform_error hook must + // distill it into a short reason rather than letting the raw markup + // reach the client or the log. + w.Header().Set("Content-Type", "text/html") w.WriteHeader(503) - fmt.Fprint(w, `{"error":{"type":"FreeUsageLimitError","message":"Rate limit exceeded. Please try again later."}}`) + fmt.Fprint(w, `