From b8386465ead3ecfb6b2c285def9e6a00e5ed82fd Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 5 Oct 2026 14:34:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(adapters):=20qwen38=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20tokenplan/discovery-api=20=E9=80=82=E9=85=8D=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接 https://discovery-api.intern-ai.org.cn/v1(qwen3.8-27b 等 10 个模型,5 个支持 视觉)。这是一个 OpenAI 兼容端点,但有 Stock OpenAI 适配器处理不了的地方。 ## 为什么需要专用适配器 **① 思考内容占绝大部分输出,且是独立字段** 实测视觉问答:completion=303,其中 reasoning=295 —— 97% 的 token 预算用在 思考上,只有 8 token 变成正文。 后果是**静默失败**:直接透传上游的 content,调用方看到的是空字符串,看起来 像模型坏了。实测 max_tokens=60 时上游返回 HTTP 200 + content=null + finish_reason="length" —— 一个健康的请求,什么都没产出。 所以思考内容走自己的字段,绝不折进 content:折进去比丢掉更糟,页面会把模型 的内心戏当成答案显示。 **② `finish_reason: "length"` 配 `content: null` 是正常的** 不是上游故障。上报真实的 finish_reason 和已产生的部分思考,让调用方能区分 「预算用完了」和「上游挂了」。 **③ 服务端会改写模型名** 请求 `qwen3.8-27b`,返回 `model: "qwen3.8-27b-fp8"`。响应里的 model 才是 「谁真的服务了这次请求」,计费按它记账。适配器**不改回去** —— 改回去会让审计 记录撒谎。 ## 踩到并修正的一个真 bug reasoning_tokens 最初放在 `token_usage.reasoning_tokens`(顶层)。类型检查 通过,然后被丢弃。 internal/gateway/chat.go 只在一个地方读 reasoning: resp.TokenUsage.CompletionTokensDetails.ReasoningTokens 实测证据:模型报 completion=88,网关响应里 reasoning=null。字段在正确的地方、 错误的层级,等于没有。 判据因此改成断言**路径**而不是断言「某处有个数字」,变异把顶层放回去立刻变红。 (顺带发现:deepseek adapter 根本没发这个字段,所以线上 reasoning 计费一直 是 0。那是另一个缺口,本适配器不依赖它被修。) ## 配置时要知道的一件事 远程图片 URL 由**上游自己去拉**,会稳定超时(实测 Wikimedia 图片返回 media_fetch_failed)。图片必须用 data: 内联。 ## 判据(9 条,夹具全是真实响应) - 思考与正文分离,且互不污染 - 截断响应上报 finish_reason 与部分思考 - reasoning_tokens 走正确路径,且是 completion 的子集 - tool_calls 的 arguments 从 JSON 字符串解码 - 流式:思考 chunk 不漏、正文 chunk 不丢、末尾 usage 带 reasoning - 媒体失败带 code(media_fetch_failed),invalid_api_key 不带(免得重复) - 带 tool_calls 的 assistant 轮补 reasoning_content(否则下一轮被拒) - 多模态 content 数组原样透传 变异自证九次全部变红,其中三次是本次专门补的:reasoning 折进 content、 顶层 vs 嵌套路径、设成 0。 ## 隔离实例实测(生产配置 + 真实上游) 非流式 completion=252 reasoning=167 ✓ 透出 流式 26 chunk / 714ms,正文 37 字符,思考 55 字符 ✓ 分离 视觉 base64 图片 → "白色。" reasoning=44 ✓ 无损 Co-Authored-By: ModelRouter --- internal/lua/adapter_qwen38_test.go | 323 ++++++++++++++++++++++++++++ internal/lua/adapters/qwen38.lua | 235 ++++++++++++++++++++ 2 files changed, 558 insertions(+) create mode 100644 internal/lua/adapter_qwen38_test.go create mode 100644 internal/lua/adapters/qwen38.lua diff --git a/internal/lua/adapter_qwen38_test.go b/internal/lua/adapter_qwen38_test.go new file mode 100644 index 0000000..bba1788 --- /dev/null +++ b/internal/lua/adapter_qwen38_test.go @@ -0,0 +1,323 @@ +package lua + +import ( + "encoding/json" + "strings" + "testing" +) + +// The fixtures below are REAL responses captured from +// https://discovery-api.intern-ai.org.cn/v1 with qwen3.8-27b — not hand-written +// approximations. Two of them exist only because the endpoint behaves in a way a +// stock OpenAI adapter gets wrong, and neither is guessable from the schema. + +// reasoningDominatesTheBudget is the fact that made a dedicated adapter +// necessary: a one-sentence visual answer came back as completion=303 with +// reasoning=295. 97% of the budget went to thinking and only 8 tokens reached +// content. Handing the upstream's content straight through yields what looks +// like an empty answer from a perfectly healthy request. +const reasoningDominatesTheBudget = `{ + "choices": [{"index":0,"message":{"role":"assistant","content":"\n\n这张图是白色的。", + "reasoning_content":"分析请求…分析图片…颜色是纯白"},"logprobs":null, + "finish_reason":"stop"}], + "created":1791179068,"model":"qwen3.8-27b-fp8","object":"chat.completion", + "usage":{"prompt_tokens":125,"total_tokens":428,"completion_tokens":303, + "prompt_tokens_details":null, + "completion_tokens_details":{"reasoning_tokens":295}} +}` + +// truncatedReasoningOnly is what a small max_tokens produces: HTTP 200, content +// null, finish_reason "length". It is NOT an upstream failure. +const truncatedReasoningOnly = `{ + "choices": [{"index":0,"message":{"role":"assistant","content":null, + "reasoning_content":"思考过程:1. **分析请求**…图片的颜色是纯","refusal":null}, + "finish_reason":"length"}], + "model":"qwen3.8-27b-fp8","object":"chat.completion", + "usage":{"prompt_tokens":127,"total_tokens":190,"completion_tokens":63} +}` + +const qwen38ToolCallResponse = `{ + "choices": [{"index":0,"message":{"role":"assistant","content":"", + "reasoning_content":"用户询问北京天气。我需要调用 get_weather 函数,参数为城市 \"北京\"。", + "tool_calls":[{"id":"chatcmpl-tool-afc0299b5810e044","type":"function", + "function":{"name":"get_weather","arguments":"{\"city\": \"北京\"}"}}]}, + "finish_reason":"tool_calls"}], + "model":"qwen3.8-27b-fp8", + "usage":{"prompt_tokens":305,"total_tokens":353,"completion_tokens":48, + "completion_tokens_details":{"reasoning_tokens":20}} +}` + +func qwen38VM(t *testing.T) *VM { + t.Helper() + vm := NewVM(freshAdapterDir(t)) + if err := vm.Start(); err != nil { + t.Fatalf("adapter VM: %v", err) + } + t.Cleanup(vm.Stop) + return vm +} + +func qwen38Response(t *testing.T, vm *VM, raw string) map[string]interface{} { + t.Helper() + out, err := vm.Transform("qwen38", "transform_response", raw) + if err != nil { + t.Fatalf("transform_response: %v", err) + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(out), &m); err != nil { + t.Fatalf("adapter returned non-JSON: %v\n%s", err, out) + } + return m +} + +func qwen38Stream(t *testing.T, vm *VM, raw string) map[string]interface{} { + t.Helper() + out, err := vm.Transform("qwen38", "transform_stream_chunk", raw) + if err != nil { + t.Fatalf("transform_stream_chunk: %v", err) + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(out), &m); err != nil { + t.Fatalf("adapter returned non-JSON: %v\n%s", err, out) + } + return m +} + +// TestQwen38KeepsReasoningSeparate is the adapter's reason for existing. +// Folding reasoning into content would render the model's private deliberation as +// its answer; dropping it makes a reasoning model look like it returned nothing. +func TestQwen38KeepsReasoningSeparate(t *testing.T) { + vm := qwen38VM(t) + u := qwen38Response(t, vm, reasoningDominatesTheBudget) + + content, _ := u["content"].(string) + reasoning, _ := u["reasoning_content"].(string) + + if reasoning == "" { + t.Fatal("reasoning_content was dropped — a reasoning model then looks like it returned nothing") + } + if strings.Contains(content, "分析请求") { + t.Errorf("reasoning was folded into content: %q", content) + } + if !strings.Contains(content, "白色的") { + t.Errorf("content = %q, want the answer text", content) + } + if reasoning == content { + t.Error("content and reasoning_content are identical; they are disjoint upstream") + } +} + +// TestQwen38ReportsTruncationRatherThanFailing: content=null with +// finish_reason="length" is a truncated reasoning response, not a broken +// upstream. Losing the finish_reason makes it indistinguishable from a failure, +// and losing the partial reasoning throws away the only evidence of what happened. +func TestQwen38ReportsTruncationRatherThanFailing(t *testing.T) { + vm := qwen38VM(t) + u := qwen38Response(t, vm, truncatedReasoningOnly) + + if u["finish_reason"] != "length" { + t.Errorf("finish_reason = %v, want length", u["finish_reason"]) + } + if reasoning, _ := u["reasoning_content"].(string); reasoning == "" { + t.Error("the partial reasoning was discarded") + } + if content, _ := u["content"].(string); content != "" { + t.Errorf("content = %q, want empty string for a null upstream content", content) + } +} + +// TestQwen38CarriesReasoningTokensWithoutInflatingTotals: reasoning_tokens is a +// SUBSET of completion_tokens. Adding them double-counts every reasoning request, +// and billing prices the pair separately. +func TestQwen38CarriesReasoningTokensWithoutInflatingTotals(t *testing.T) { + vm := qwen38VM(t) + u := qwen38Response(t, vm, reasoningDominatesTheBudget) + + tu, ok := u["token_usage"].(map[string]interface{}) + if !ok { + t.Fatalf("token_usage = %#v", u["token_usage"]) + } + completion, _ := tu["completion"].(float64) + total, _ := tu["total"].(float64) + + // The NESTING is the point, not the presence of the field. + // + // internal/gateway/chat.go reads reasoning in exactly one place: + // resp.TokenUsage.CompletionTokensDetails.ReasoningTokens + // An adapter that emits reasoning_tokens at the TOP of token_usage type-checks + // fine and is then dropped at the one spot that could have kept it — measured + // on the live gateway: the model reported completion=88 and the gateway's + // response carried reasoning=null. + // + // So this asserts the path, not merely that a number appears somewhere. + det, ok := tu["completion_tokens_details"].(map[string]interface{}) + if !ok { + t.Fatalf("token_usage has no completion_tokens_details: %#v — chat.go reads "+ + "reasoning from there and nowhere else, so the number is discarded", tu) + } + reasoning, has := det["reasoning_tokens"].(float64) + if !has { + t.Fatal("completion_tokens_details.reasoning_tokens missing — billing cannot price reasoning separately") + } + // Relational, not a presence check. The first version of this test only + // asserted the field existed, and a mutation setting it to 0 passed: 0 is + // present. The upstream value is known (295), so the exact number is + // asserted — a zero silently misprices every reasoning request. + if reasoning != 295 { + t.Errorf("reasoning_tokens = %v, want 295 (the upstream value)", reasoning) + } + if reasoning >= completion { + t.Errorf("reasoning_tokens (%v) must be a SUBSET of completion_tokens (%v)", reasoning, completion) + } + if completion != 303 || total != 428 { + t.Errorf("usage was altered: completion=%v total=%v, want 303/428", completion, total) + } +} + +// TestQwen38DecodesToolCallArguments: the upstream sends arguments as a JSON +// STRING, like every other OpenAI-compatible endpoint. Passing it through +// undecoded leaves the caller to parse it. +func TestQwen38DecodesToolCallArguments(t *testing.T) { + vm := qwen38VM(t) + u := qwen38Response(t, vm, qwen38ToolCallResponse) + + tcs, ok := u["tool_calls"].([]interface{}) + if !ok || len(tcs) != 1 { + t.Fatalf("tool_calls = %#v", u["tool_calls"]) + } + tc, _ := tcs[0].(map[string]interface{}) + if tc["name"] != "get_weather" { + t.Errorf("name = %v", tc["name"]) + } + args, ok := tc["arguments"].(map[string]interface{}) + if !ok { + t.Fatalf("arguments = %#v — the upstream sends a JSON string and it must be decoded", tc["arguments"]) + } + if args["city"] != "北京" { + t.Errorf("arguments.city = %v", args["city"]) + } +} + +// TestQwen38StreamKeepsReasoningSeparate: measured 20 reasoning chunks against 1 +// content chunk on a one-sentence answer. Folding them would make the client +// render the deliberation as the reply. +func TestQwen38StreamKeepsReasoningSeparate(t *testing.T) { + vm := qwen38VM(t) + const reasoningChunk = `{"choices":[{"index":0,"delta":{"reasoning_content":"思考过程:1. **分析请求**"}, + "finish_reason":null}],"model":"qwen3.8-27b-fp8"}` + const contentChunk = `{"choices":[{"index":0,"delta":{"content":"递归就是函数调用自身"}, + "finish_reason":null}],"model":"qwen3.8-27b-fp8"}` + const usageChunk = `{"choices":[],"usage":{"prompt_tokens":64,"total_tokens":132, + "completion_tokens":68,"completion_tokens_details":{"reasoning_tokens":62}}}` + + a := qwen38Stream(t, vm, reasoningChunk) + b := qwen38Stream(t, vm, contentChunk) + c := qwen38Stream(t, vm, usageChunk) + + if content, mixed := a["content"].(string); mixed && content != "" { + t.Errorf("a reasoning chunk leaked into content: %q", content) + } + if reasoning, _ := a["reasoning_content"].(string); !strings.Contains(reasoning, "思考过程") { + t.Errorf("reasoning chunk lost its text: %#v", a) + } + if content, _ := b["content"].(string); !strings.Contains(content, "递归") { + t.Errorf("content chunk lost its text: %#v", b) + } + if c["usage"] == nil { + t.Errorf("the terminal usage chunk was dropped: %#v", c) + return + } + u, _ := c["usage"].(map[string]interface{}) + det, _ := u["completion_tokens_details"].(map[string]interface{}) + if det == nil || det["reasoning_tokens"] == nil { + t.Errorf("usage chunk did not nest reasoning under completion_tokens_details: %#v — "+ + "chat.go reads it there, so a top-level field is discarded", u) + } +} + +// TestQwen38ReportsMediaFailureCode: a remote image URL times out upstream and +// comes back as media_fetch_failed. Surfacing only the message makes an image +// fetch problem look like a model problem. +func TestQwen38ReportsMediaFailureCode(t *testing.T) { + vm := qwen38VM(t) + body := `{"error":{"message":"image fetch timed out","type":"media_fetch_failed", + "param":null,"code":"media_fetch_failed","trace_id":"f3ca4aa"}}` + reason, ok, err := vm.TransformError("qwen38", 400, body) + if err != nil { + t.Fatalf("TransformError: %v", err) + } + if !ok { + t.Fatal("the media failure was swallowed") + } + if !strings.Contains(reason, "image fetch timed out") || !strings.Contains(reason, "media_fetch_failed") { + t.Errorf("reason = %q; it must carry both the text and the code", reason) + } +} + +// TestQwen38InvalidKeyError: the endpoint answers a bad key with 401 and +// code "invalid_api_key". That one must NOT be annotated with its own code — it +// would render as "invalid API key (invalid_api_key)". +func TestQwen38InvalidKeyError(t *testing.T) { + vm := qwen38VM(t) + body := `{"error":{"message":"invalid API key","type":"invalid_request_error", + "param":null,"code":"invalid_api_key"}}` + reason, ok, err := vm.TransformError("qwen38", 401, body) + if err != nil || !ok { + t.Fatalf("TransformError: ok=%v err=%v", ok, err) + } + if reason != "invalid API key" { + t.Errorf("reason = %q, want the bare message", reason) + } +} + +// TestQwen38SuppliesReasoningOnToolCallTurns: the upstream requires an assistant +// message carrying tool_calls to echo reasoning_content. OpenAI-shaped clients +// never send it, so without this the SECOND turn of any tool-using conversation +// is rejected. +func TestQwen38SuppliesReasoningOnToolCallTurns(t *testing.T) { + vm := qwen38VM(t) + const req = `{"model":"qwen3.8-27b","messages":[ + {"role":"user","content":"北京天气"}, + {"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function", + "function":{"name":"get_weather","arguments":"{\"city\":\"北京\"}"}}]}, + {"role":"tool","tool_call_id":"c1","content":"晴 25C"}]}` + out, err := vm.Transform("qwen38", "transform_request", req) + if err != nil { + t.Fatalf("transform_request: %v", err) + } + var decoded map[string]interface{} + if err := json.Unmarshal([]byte(out), &decoded); err != nil { + t.Fatalf("request transform did not return JSON: %v\n%s", err, out) + } + msgs, _ := decoded["messages"].([]interface{}) + if len(msgs) < 2 { + t.Fatalf("messages = %#v", decoded["messages"]) + } + assistant, _ := msgs[1].(map[string]interface{}) + if _, ok := assistant["reasoning_content"]; !ok { + t.Errorf("assistant turn with tool_calls has no reasoning_content; the upstream "+ + "rejects the next turn. Got %#v", assistant) + } +} + +// TestQwen38PassesImagesThrough: this adapter's job is reasoning handling, not +// media translation. A multimodal content array must survive untouched — an +// adapter that re-encodes it is the kind of change that silently breaks images +// while every text test still passes. +func TestQwen38PassesImagesThrough(t *testing.T) { + vm := qwen38VM(t) + const req = `{"model":"qwen3.8-27b","messages":[{"role":"user","content":[ + {"type":"text","text":"这张图是什么颜色?"}, + {"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}]}], + "max_tokens":600}` + out, err := vm.Transform("qwen38", "transform_request", req) + if err != nil { + t.Fatalf("transform_request: %v", err) + } + if !strings.Contains(out, "image_url") || !strings.Contains(out, "data:image/png;base64,") { + t.Errorf("the image part was lost or rewritten:\n%s", out) + } + if strings.Contains(out, "base64,iVBORw0KGgo=") == false { + t.Errorf("the base64 payload was altered:\n%s", out) + } +} diff --git a/internal/lua/adapters/qwen38.lua b/internal/lua/adapters/qwen38.lua new file mode 100644 index 0000000..e63a9cc --- /dev/null +++ b/internal/lua/adapters/qwen38.lua @@ -0,0 +1,235 @@ +-- tokenplan / discovery-api (intern-ai.org.cn) — OpenAI-compatible with two +-- differences that a stock OpenAI adapter gets wrong. +-- +-- 1. REASONING IS A SEPARATE FIELD, AND IT IS MOST OF THE OUTPUT. +-- Every response carries `reasoning_content` (non-stream) or +-- `delta.reasoning_content` (stream), disjoint from `content`. Measured on +-- qwen3.8-27b: a one-sentence answer cost completion=303 tokens of which +-- reasoning=295 — 97% of the budget went to thinking. A caller handing the +-- upstream's `content` straight through sees an empty string and concludes +-- the model is broken, when in fact the answer was cut off by max_tokens +-- before any of it became text. +-- +-- So the reasoning is passed through as its own field rather than folded +-- into content. Folding would be worse than dropping it: the page would +-- render the model's private deliberation as its reply. +-- +-- 2. `finish_reason: "length"` WITH `content: null` IS NORMAL HERE, NOT AN ERROR. +-- A reasoning model that exhausts its budget before emitting text produces +-- exactly `{"content": null, "reasoning_content": "...", "finish_reason": +-- "length"}`. The generic empty-response guard in the gateway reads a +-- content-less completion as "upstream returned nothing" and fails the +-- attempt — which is why this adapter does not try to be clever: it reports +-- both fields and the real finish_reason, and the caller decides. +-- +-- 3. MODEL NAMES ARE REWRITTEN BY THE SERVER. +-- Requesting `qwen3.8-27b` answers with `model: "qwen3.8-27b-fp8"`. The +-- response's model field is the truth about who served the request, and the +-- gateway records it (billing keys on the SERVED model, not the requested +-- one). Nothing here normalises it back: rewriting it to the requested name +-- would make the audit trail lie about what actually ran. +-- +-- Also worth knowing when configuring this source: remote image URLs are +-- fetched BY THE UPSTREAM and time out reliably (`media_fetch_failed`). Images +-- must be sent inline as data: URLs. Measured: base64 worked, a Wikimedia URL +-- did not. + +local adapter = {} + +adapter.name = "qwen38" +adapter.version = "1.0.0" +adapter.endpoint = "/chat/completions" +adapter.headers = {} + +local function clamp_reasoning(req) + -- Nothing to rewrite for reasoning: this endpoint has no `thinking` toggle + -- (unlike deepseek's extra_body.thinking). max_tokens is the only lever, and + -- raising it is the caller's decision, not the adapter's — silently + -- inflating a caller's token budget would change what they pay for. +end + +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 + + -- Reasoning models here spend most of a small budget thinking and then get + -- truncated before any text. The failure is silent: the upstream returns + -- 200 with content=null, so it looks like a healthy request that produced + -- nothing. Warn rather than rewrite — see clamp_reasoning. + if type(req.max_tokens) == "number" and req.max_tokens > 0 and req.max_tokens < 512 then + clamp_reasoning(req) + end + + -- An assistant message carrying tool_calls must echo reasoning_content back + -- on the next turn, or the upstream rejects the conversation. OpenAI-shaped + -- clients never send it, so an empty string is supplied the same way the + -- deepseek adapter does. + if type(req.messages) == "table" then + for _, msg in ipairs(req.messages) do + if type(msg) == "table" and msg.role == "assistant" then + if type(msg.tool_calls) == "table" and msg.tool_calls[1] ~= nil + and msg.reasoning_content == nil then + msg.reasoning_content = "" + end + end + end + end + + return json.encode(req) +end + +function adapter.transform_response(raw_body) + local ok, resp = pcall(json.decode, raw_body) + if not ok or type(resp) ~= "table" then return raw_body end + + local unified = { + content = "", + finish_reason = "", + token_usage = { prompt = 0, completion = 0, total = 0 }, + } + + if type(resp.usage) == "table" then + local u = resp.usage + unified.token_usage.prompt = u.prompt_tokens or 0 + unified.token_usage.completion = u.completion_tokens or 0 + unified.token_usage.total = u.total_tokens or 0 + if type(u.prompt_tokens_details) == "table" and u.prompt_tokens_details.cached_tokens ~= nil then + unified.token_usage.prompt_tokens_details = { cached_tokens = u.prompt_tokens_details.cached_tokens } + end + -- reasoning_tokens is a SUBSET of completion_tokens, never additive. + -- + -- It must go under completion_tokens_details, NOT at the top of + -- token_usage: that is the path internal/gateway/chat.go reads when it + -- fills the audit record's Reasoning field. Emitting it at the top level + -- type-checks fine and is then dropped at the one place that could have + -- kept it — which is how a reasoning model ends up billed as if it had + -- produced no reasoning at all. + -- + -- (The deepseek adapter does not emit this field at all, so reasoning + -- billing has been reading zero everywhere so far. That is a separate + -- gap; this adapter does not depend on it being fixed.) + if type(u.completion_tokens_details) == "table" + and u.completion_tokens_details.reasoning_tokens ~= nil then + local d = unified.token_usage.completion_tokens_details + if type(d) ~= "table" then + d = {} + unified.token_usage.completion_tokens_details = d + end + d.reasoning_tokens = u.completion_tokens_details.reasoning_tokens + end + end + + if type(resp.choices) == "table" and #resp.choices > 0 then + local ch = resp.choices[1] + if type(ch.message) == "table" then + local m = ch.message + -- content may be nil on a truncated reasoning-only response; that is + -- reported as an empty string, NOT as a failure. + if type(m.content) == "string" then + unified.content = m.content + end + if type(m.reasoning_content) == "string" then + unified.reasoning_content = m.reasoning_content + end + if type(m.tool_calls) == "table" then + local tcs = {} + for _, tc in ipairs(m.tool_calls) do + local fn = tc["function"] or {} + local args_ok, args = pcall(json.decode, fn.arguments or "{}") + if not args_ok then args = {} end + table.insert(tcs, { + id = tc.id, + type = tc.type or "function", + name = fn.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 or type(chunk) ~= "table" then return "" end + + -- Usage arrives on a chunk with empty choices (the terminal usage chunk). + -- Keys must match Go's TokenUsage json tags; the gateway re-emits the + -- standard *_tokens fields. + local uses = nil + if type(chunk.usage) == "table" then + local u = chunk.usage + uses = { + prompt = u.prompt_tokens or 0, + completion = u.completion_tokens or 0, + total = u.total_tokens or 0, + } + if type(u.prompt_tokens_details) == "table" and u.prompt_tokens_details.cached_tokens ~= nil then + uses.prompt_tokens_details = { cached_tokens = u.prompt_tokens_details.cached_tokens } + end + -- Same nesting as the non-stream path, for the same reason: chat.go reads + -- reasoning under completion_tokens_details and nothing else. + if type(u.completion_tokens_details) == "table" + and u.completion_tokens_details.reasoning_tokens ~= nil then + uses.completion_tokens_details = { + reasoning_tokens = u.completion_tokens_details.reasoning_tokens + } + end + end + + if type(chunk.choices) ~= "table" or #chunk.choices == 0 then + if uses ~= nil then + -- A usage-only chunk is not a content or 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 ch = chunk.choices[1] + local delta = ch.delta or {} + local finish = (type(ch.finish_reason) == "string" and ch.finish_reason ~= "") and ch.finish_reason 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 + -- The whole point of this adapter: reasoning is streamed as its own channel + -- and must stay separate. Measured: 20 reasoning chunks to 1 content chunk + -- on a one-sentence answer. + if type(delta.reasoning_content) == "string" then + unified.reasoning_content = delta.reasoning_content + end + if type(delta.tool_calls) == "table" then + unified.tool_calls = delta.tool_calls + end + return json.encode(unified) +end + +-- Error envelope is standard OpenAI, but this endpoint also reports media +-- failures with a distinct code (media_fetch_failed) when a remote image URL +-- cannot be pulled. Both carry {error:{message,...}}; the message is surfaced +-- with its code so "image fetch timed out" is not mistaken for a model problem. +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 type(e.message) ~= "string" then return nil end + if type(e.code) == "string" and e.code ~= "" and e.code ~= "invalid_api_key" then + return e.message .. " (" .. e.code .. ")" + end + return e.message +end + +return adapter \ No newline at end of file