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