mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
接 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 <noreply@modelrouter.dev>
324 lines
13 KiB
Go
324 lines
13 KiB
Go
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)
|
||
}
|
||
}
|