-- 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