项目文件夹

文件
2026-07-13 13:00:08 +08:00

1379 行
50 KiB
Go

package openai
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/provider"
)
// TestStreamRetriesThenSucceeds drives the real retry path end-to-end: the
// server returns 503 twice, then a valid SSE stream. The provider must back off,
// fire the retry-notify callback for each attempt, and ultimately stream the answer.
func TestStreamRetriesThenSucceeds(t *testing.T) {
var reqs int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqs++
if reqs <= 2 {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":"overloaded"}`))
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hi there\"}}]}\n\ndata: [DONE]\n\n")
}))
defer srv.Close()
p, err := New(provider.Config{Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-v4", APIKey: "k"})
if err != nil {
t.Fatalf("New: %v", err)
}
var attempts []int
ctx := provider.WithRetryNotify(context.Background(), func(i provider.RetryInfo) {
attempts = append(attempts, i.Attempt)
if i.Max != provider.MaxRetries {
t.Errorf("RetryInfo.Max = %d, want %d", i.Max, provider.MaxRetries)
}
})
ch, err := p.Stream(ctx, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
if err != nil {
t.Fatalf("Stream after retries: %v", err)
}
var got strings.Builder
for chunk := range ch {
if chunk.Type == provider.ChunkError {
t.Fatalf("unexpected stream error: %v", chunk.Err)
}
if chunk.Type == provider.ChunkText {
got.WriteString(chunk.Text)
}
}
if got.String() != "hi there" {
t.Errorf("streamed text = %q, want %q", got.String(), "hi there")
}
if reqs != 3 {
t.Errorf("server saw %d requests, want 3 (2 failures + 1 success)", reqs)
}
if len(attempts) != 2 || attempts[0] != 1 || attempts[1] != 2 {
t.Errorf("retry-notify attempts = %v, want [1 2]", attempts)
}
}
// TestStreamInsufficientBalance verifies a 402 fails fast (no retry) as a typed
// *provider.APIError carrying the status, so the display layer can explain it.
func TestStreamInsufficientBalance(t *testing.T) {
var reqs int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqs++
w.WriteHeader(http.StatusPaymentRequired)
_, _ = w.Write([]byte(`{"error":"Insufficient Balance"}`))
}))
defer srv.Close()
p, _ := New(provider.Config{Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-v4", APIKey: "k"})
_, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}})
var apiErr *provider.APIError
if !errors.As(err, &apiErr) || apiErr.Status != 402 {
t.Fatalf("want *provider.APIError{Status:402}, got %T: %v", err, err)
}
if reqs != 1 {
t.Errorf("402 should not retry, server saw %d requests", reqs)
}
}
func TestStreamAnnotatesIndexedToolSchemaError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"message":"Tool 1 function has invalid 'parameters' schema"}}`))
}))
defer srv.Close()
p, err := New(provider.Config{Name: "mimo", BaseURL: srv.URL, Model: "mimo-v2.5-pro", APIKey: "k"})
if err != nil {
t.Fatalf("New: %v", err)
}
_, err = p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
Tools: []provider.ToolSchema{
{Name: "read_file", Parameters: json.RawMessage(`{"type":"object"}`)},
{Name: "mcp__files__search", Parameters: json.RawMessage(`{"type":"object"}`)},
},
})
var apiErr *provider.APIError
if !errors.As(err, &apiErr) || !strings.Contains(apiErr.ToolContext, `MCP server "files"`) {
t.Fatalf("Stream error = %v, want MCP tool source context", err)
}
}
func TestBuildRequestScopesLegacyTupleMigrationToMiMo(t *testing.T) {
legacy := json.RawMessage(`{"type":"object","properties":{"pair":{"type":"array","items":[{"type":"string"},{"type":"number"}]}}}`)
req := provider.Request{Tools: []provider.ToolSchema{{Name: "tuple", Parameters: legacy}}}
mimo := (&client{mimo: true}).buildRequest(req)
if got := string(mimo.Tools[0].Function.Parameters); !strings.Contains(got, `"prefixItems"`) || strings.Contains(got, `"items":[`) {
t.Fatalf("MiMo parameters = %s, want Draft 2020-12 tuple keywords", got)
}
other := (&client{}).buildRequest(req)
if got := string(other.Tools[0].Function.Parameters); got != string(legacy) {
t.Fatalf("non-MiMo parameters changed:\n got: %s\nwant: %s", got, legacy)
}
}
// TestStreamAuthError verifies a 401 surfaces as an actionable *provider.AuthError
// (naming the provider and its key env var) rather than a raw status body.
func TestStreamAuthError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"message":"Authentication Fails, Your api key: ****ae54 is invalid"}}`))
}))
defer srv.Close()
p, err := New(provider.Config{
Name: "deepseek",
BaseURL: srv.URL,
Model: "deepseek-v4",
APIKey: "bad",
Extra: map[string]any{"api_key_env": "DEEPSEEK_API_KEY"},
})
if err != nil {
t.Fatalf("New: %v", err)
}
_, err = p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
})
var authErr *provider.AuthError
if !errors.As(err, &authErr) {
t.Fatalf("want *provider.AuthError, got %T: %v", err, err)
}
if authErr.Provider != "deepseek" || authErr.KeyEnv != "DEEPSEEK_API_KEY" || authErr.Status != 401 {
t.Errorf("AuthError fields wrong: %+v", authErr)
}
if msg := authErr.Error(); !strings.Contains(msg, "DEEPSEEK_API_KEY") || strings.Contains(msg, "ae54") {
t.Errorf("message should name the env var and not dump the raw body: %q", msg)
}
}
func TestStreamUsesConfiguredChatURL(t *testing.T) {
var sawRequest bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawRequest = true
if r.URL.Path != "/proxy/v1/chat/completions" {
t.Errorf("path = %s, want /proxy/v1/chat/completions", r.URL.Path)
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer k" {
http.Error(w, "bad key", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
}))
defer srv.Close()
p, err := New(provider.Config{
Name: "custom",
BaseURL: srv.URL + "/base",
Model: "model-a",
APIKey: "k",
Extra: map[string]any{"chat_url": srv.URL + "/proxy/v1/chat/completions"},
})
if err != nil {
t.Fatalf("New: %v", err)
}
ch, err := p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
var got strings.Builder
for chunk := range ch {
if chunk.Type == provider.ChunkError {
t.Fatalf("stream error: %v", chunk.Err)
}
if chunk.Type == provider.ChunkText {
got.WriteString(chunk.Text)
}
}
if !sawRequest {
t.Fatal("server did not receive request")
}
if got.String() != "ok" {
t.Fatalf("streamed text = %q, want ok", got.String())
}
}
func TestStreamSendsCustomHeaders(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer real-key" {
http.Error(w, "authorization was not preserved", http.StatusUnauthorized)
return
}
if r.Header.Get("HTTP-Referer") != "https://app.example" || r.Header.Get("X-Title") != "Reasonix" {
http.Error(w, "custom headers missing", http.StatusForbidden)
return
}
if r.Header.Get("Accept") != "text/event-stream" {
http.Error(w, "reserved Accept header was overwritten", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
}))
defer srv.Close()
p, err := New(provider.Config{
Name: "custom",
BaseURL: srv.URL,
Model: "model-a",
APIKey: "real-key",
Extra: map[string]any{"headers": map[string]string{
"Authorization": "Bearer wrong",
"Accept": "application/json",
"HTTP-Referer": "https://app.example",
"X-Title": "Reasonix",
}},
})
if err != nil {
t.Fatalf("New: %v", err)
}
ch, err := p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
for chunk := range ch {
if chunk.Type == provider.ChunkError {
t.Fatalf("stream error: %v", chunk.Err)
}
}
}
func TestStreamUsesMiMoAPIKeyHeader(t *testing.T) {
var gotAuth, gotAPIKey string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotAPIKey = r.Header.Get("api-key")
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
}))
defer srv.Close()
p, err := New(provider.Config{
Name: "mimo",
BaseURL: "https://api.xiaomimimo.com/v1",
Model: "mimo-v2.5-pro",
APIKey: "mimo-key",
})
if err != nil {
t.Fatalf("New: %v", err)
}
c := p.(*client)
if !c.mimo {
t.Fatal("official MiMo endpoint did not enable the Draft 2020-12 schema adapter")
}
c.chatURL = srv.URL
ch, err := p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
for chunk := range ch {
if chunk.Type == provider.ChunkError {
t.Fatalf("stream error: %v", chunk.Err)
}
}
if gotAPIKey != "mimo-key" {
t.Fatalf("api-key = %q, want mimo-key", gotAPIKey)
}
if gotAuth != "" {
t.Fatalf("Authorization = %q, want omitted for MiMo", gotAuth)
}
}
func TestStreamSendsExtraBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body", http.StatusBadRequest)
return
}
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if req["enable_thinking"] != true {
http.Error(w, "extra enable_thinking missing", http.StatusBadRequest)
return
}
if got, ok := req["top_p"].(float64); !ok || got != 0.7 {
http.Error(w, "extra top_p missing", http.StatusBadRequest)
return
}
if req["model"] != "model-a" || req["stream"] != true {
http.Error(w, "reserved fields were overwritten", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
}))
defer srv.Close()
p, err := New(provider.Config{
Name: "custom",
BaseURL: srv.URL,
Model: "model-a",
APIKey: "real-key",
Extra: map[string]any{"extra_body": map[string]any{
"enable_thinking": true,
"top_p": 0.7,
"model": "wrong",
"stream": false,
}},
})
if err != nil {
t.Fatalf("New: %v", err)
}
ch, err := p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
for chunk := range ch {
if chunk.Type == provider.ChunkError {
t.Fatalf("stream error: %v", chunk.Err)
}
}
}
// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:
// DeepSeek rejects a message missing the `content` field, so every message must
// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,
// and accepted by DeepSeek — verified against a live multi-tool session); other
// roles serialize a string. The field must never be absent.
func TestBuildRequestAlwaysSerializesContent(t *testing.T) {
c := &client{model: "deepseek-v4"}
req := c.buildRequest(provider.Request{
Messages: []provider.Message{
{Role: provider.RoleUser, Content: "list the files"},
// Assistant turn with no text, only a tool call — the offending shape.
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "call_1", Name: "ls", Arguments: `{"path":"."}`},
}},
{Role: provider.RoleTool, Content: "main.go", ToolCallID: "call_1", Name: "ls"},
},
})
b, err := json.Marshal(req.Messages)
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Decode generically so we can assert the key's presence (not just its value).
var raw []map[string]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for i, m := range raw {
if _, ok := m["content"]; !ok {
t.Errorf("messages[%d] is missing the content field: %s", i, b)
}
}
// The tool-call-only assistant message must carry content:null and its tool_calls.
if got := string(raw[1]["content"]); got != `null` {
t.Errorf("assistant content = %s, want null", got)
}
if _, ok := raw[1]["tool_calls"]; !ok {
t.Errorf("assistant message lost its tool_calls: %s", b)
}
}
// TestStreamRepairsDanglingToolCalls reproduces and guards the DeepSeek 400
// "An assistant message with 'tool_calls' must be followed by tool messages
// responding to each 'tool_call_id'". A resumed/interrupted session can carry an
// assistant tool_calls turn whose tool results never landed; the server here
// mimics DeepSeek and rejects any unpaired tool_call with that exact 400, so the
// request must be repaired before it is sent.
func TestStreamRepairsDanglingToolCalls(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Messages []struct {
Role string `json:"role"`
ToolCalls []struct {
ID string `json:"id"`
} `json:"tool_calls"`
ToolCallID string `json:"tool_call_id"`
} `json:"messages"`
}
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &req)
answered := map[string]bool{}
for _, m := range req.Messages {
if m.Role == "tool" {
answered[m.ToolCallID] = true
}
}
for _, m := range req.Messages {
if m.Role != "assistant" {
continue
}
for _, tc := range m.ToolCalls {
if !answered[tc.ID] {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"message":"An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message)","type":"invalid_request_error","param":null,"code":"invalid_request_error"}}`))
return
}
}
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\n")
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":1,\"total_tokens\":6}}\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}))
defer srv.Close()
p, err := New(provider.Config{Name: "deepseek-flash", BaseURL: srv.URL, Model: "deepseek-v4", APIKey: "k"})
if err != nil {
t.Fatalf("New: %v", err)
}
// An assistant tool_calls turn whose tool result never landed (an interrupted
// turn), followed by a fresh user message — the exact shape that 400s.
ch, err := p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{
{Role: provider.RoleUser, Content: "list the files"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "call_1", Name: "ls", Arguments: `{"path":"."}`},
}},
{Role: provider.RoleUser, Content: "never mind, what time is it?"},
},
})
if err != nil {
t.Fatalf("Stream sent a dangling tool_calls to the API: %v", err)
}
var streamErr error
var text strings.Builder
for chunk := range ch {
switch chunk.Type {
case provider.ChunkText:
text.WriteString(chunk.Text)
case provider.ChunkError:
streamErr = chunk.Err
}
}
if streamErr != nil {
t.Fatalf("stream errored: %v", streamErr)
}
if text.String() != "done" {
t.Fatalf("completion text = %q, want \"done\"", text.String())
}
}
// TestNormaliseUsageDeepSeekShape covers DeepSeek's top-level cache fields.
func TestNormaliseUsageDeepSeekShape(t *testing.T) {
u := normaliseUsage(&wireUsage{
PromptTokens: 1000,
CompletionTokens: 200,
TotalTokens: 1200,
PromptCacheHitTokens: 900,
PromptCacheMissTokens: 100,
})
if u.CacheHitTokens != 900 || u.CacheMissTokens != 100 {
t.Errorf("DeepSeek-shape cache fields lost: hit=%d miss=%d", u.CacheHitTokens, u.CacheMissTokens)
}
}
// TestNormaliseUsageMiMoShape covers the nested prompt_tokens_details /
// completion_tokens_details path used by OpenAI and MiMo. Miss is derived
// from prompt - hit when only hit is provided.
func TestNormaliseUsageMiMoShape(t *testing.T) {
u := normaliseUsage(&wireUsage{
PromptTokens: 1000,
CompletionTokens: 500,
TotalTokens: 1500,
PromptTokensDetails: &struct {
CachedTokens int `json:"cached_tokens"`
}{CachedTokens: 600},
CompletionTokensDetails: &struct {
ReasoningTokens int `json:"reasoning_tokens"`
}{ReasoningTokens: 180},
})
if u.CacheHitTokens != 600 || u.CacheMissTokens != 400 {
t.Errorf("nested cache normalisation wrong: hit=%d miss=%d (want 600 / 400)", u.CacheHitTokens, u.CacheMissTokens)
}
if u.ReasoningTokens != 180 {
t.Errorf("reasoning tokens lost: %d", u.ReasoningTokens)
}
}
// TestBuildRequestDropsReasoningContent guards the cache/cost fix: an assistant
// turn's reasoning_content is a response-only signal and must never be echoed
// back in the outgoing request. DeepSeek otherwise counts it as paid prompt
// input (~500 tok/turn on a reasoner chain). The session keeps it for
// display/archive; the wire request must not carry it.
func TestBuildRequestDropsReasoningOnPlainAssistantTurn(t *testing.T) {
c := &client{model: "deepseek-reasoner", deepseek: true}
req := c.buildRequest(provider.Request{
Messages: []provider.Message{
{Role: provider.RoleUser, Content: "explain"},
{Role: provider.RoleAssistant, Content: "the answer", ReasoningContent: "SECRET-CHAIN-OF-THOUGHT"},
{Role: provider.RoleUser, Content: "thanks"},
},
})
b, err := json.Marshal(req.Messages)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(b), "reasoning_content") {
t.Errorf("a no-tool-calls assistant turn must not carry reasoning_content: %s", b)
}
if strings.Contains(string(b), "SECRET-CHAIN-OF-THOUGHT") {
t.Errorf("the assistant chain-of-thought leaked into the request: %s", b)
}
if !strings.Contains(string(b), "the answer") {
t.Errorf("assistant content was dropped along with reasoning: %s", b)
}
}
func TestBuildRequestDropsLocalMetadata(t *testing.T) {
c := &client{model: "deepseek-chat", deepseek: true}
req := c.buildRequest(provider.Request{
Messages: []provider.Message{
{Role: provider.RoleUser, Content: "continue"},
{Role: provider.RoleUser, Content: "edited prompt", Edited: true, Original: "original prompt"},
{Role: provider.RoleAssistant, Content: "done", WorkDurationMs: 24_000, MemoryCitations: []provider.MemoryCitation{{
ID: "mem-1", Source: "MEMORY.md", LineStart: 116, LineEnd: 123, Note: "workflow",
}}},
},
})
b, err := json.Marshal(req.Messages)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(b), "memoryCitations") || strings.Contains(string(b), "MEMORY.md") {
t.Fatalf("local memory citations leaked into OpenAI-compatible request: %s", b)
}
if strings.Contains(string(b), "workDurationMs") || strings.Contains(string(b), "work_duration_ms") {
t.Fatalf("local work duration leaked into OpenAI-compatible request: %s", b)
}
if strings.Contains(string(b), "original prompt") || strings.Contains(string(b), `"edited"`) || strings.Contains(string(b), `"original"`) {
t.Fatalf("local edit metadata leaked into OpenAI-compatible request: %s", b)
}
if !strings.Contains(string(b), "done") {
t.Fatalf("assistant content was dropped with local metadata: %s", b)
}
}
// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was
// dropped on a cache-miss replay, so it must be round-tripped — but only on the
// turn that carries tool calls, and only for the DeepSeek protocol.
func TestBuildRequestRoundTripsReasoningOnDeepSeekToolCalls(t *testing.T) {
msgs := []provider.Message{
{Role: provider.RoleUser, Content: "count the go files"},
{
Role: provider.RoleAssistant,
ReasoningContent: "CHAIN-OF-THOUGHT",
ToolCalls: []provider.ToolCall{{ID: "c1", Name: "bash", Arguments: `{"command":"ls"}`}},
},
{Role: provider.RoleTool, Content: "14", ToolCallID: "c1", Name: "bash"},
}
deepseek, _ := json.Marshal((&client{model: "deepseek-v4", deepseek: true}).buildRequest(provider.Request{Messages: msgs}).Messages)
if !strings.Contains(string(deepseek), "reasoning_content") || !strings.Contains(string(deepseek), "CHAIN-OF-THOUGHT") {
t.Errorf("DeepSeek tool_calls turn must round-trip reasoning_content: %s", deepseek)
}
other, _ := json.Marshal((&client{model: "mimo-v2"}).buildRequest(provider.Request{Messages: msgs}).Messages)
if strings.Contains(string(other), "CHAIN-OF-THOUGHT") {
t.Errorf("non-DeepSeek backends must not re-upload reasoning_content: %s", other)
}
}
func TestBuildRequestForwardsReasoningEffort(t *testing.T) {
c := &client{model: "mimo-v2", effort: "high"}
if got := c.buildRequest(provider.Request{}).ReasoningEffort; got != "high" {
t.Errorf("ReasoningEffort = %q, want high", got)
}
b, err := json.Marshal((&client{model: "deepseek-v4"}).buildRequest(provider.Request{}))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(b), "reasoning_effort") {
t.Errorf("empty effort must be omitted from the payload: %s", b)
}
}
func TestBuildRequestTemperatureSerialization(t *testing.T) {
c := &client{model: "m"}
omitted := c.buildRequest(provider.Request{})
if omitted.Temperature != nil {
t.Fatalf("unset request temperature = %v, want nil", omitted.Temperature)
}
b, err := json.Marshal(omitted)
if err != nil {
t.Fatalf("marshal omitted: %v", err)
}
if strings.Contains(string(b), "temperature") {
t.Fatalf("unset temperature must be omitted from payload: %s", b)
}
zero := c.buildRequest(provider.Request{Temperature: provider.TemperaturePtr(0)})
if zero.Temperature == nil || *zero.Temperature != 0 {
t.Fatalf("zero request temperature = %v, want ptr(0)", zero.Temperature)
}
b, err = json.Marshal(zero)
if err != nil {
t.Fatalf("marshal zero: %v", err)
}
if !strings.Contains(string(b), `"temperature":0`) {
t.Fatalf("explicit zero temperature must be serialized: %s", b)
}
nonzero := c.buildRequest(provider.Request{Temperature: provider.TemperaturePtr(0.25)})
if nonzero.Temperature == nil || *nonzero.Temperature != 0.25 {
t.Fatalf("nonzero request temperature = %v, want ptr(0.25)", nonzero.Temperature)
}
}
func TestBuildRequestDeepSeekThinking(t *testing.T) {
for _, tc := range []struct {
name string
effort string
wantThinking string
wantReasoning string
}{
{name: "high", effort: "high", wantThinking: "enabled", wantReasoning: "high"},
{name: "max", effort: "max", wantThinking: "enabled", wantReasoning: "max"},
} {
t.Run(tc.name, func(t *testing.T) {
req := (&client{model: "deepseek-v4", deepseek: true, effort: tc.effort}).buildRequest(provider.Request{})
if req.Thinking == nil || req.Thinking.Type != tc.wantThinking {
t.Fatalf("Thinking = %+v, want %q", req.Thinking, tc.wantThinking)
}
if req.ReasoningEffort != tc.wantReasoning {
t.Fatalf("ReasoningEffort = %q, want %q", req.ReasoningEffort, tc.wantReasoning)
}
})
}
}
func TestBuildRequestDeepSeekPreservesCallerTemperature(t *testing.T) {
c := &client{model: "deepseek-v4", deepseek: true, effort: "high"}
omitted := c.buildRequest(provider.Request{})
if omitted.Temperature != nil {
t.Fatalf("DeepSeek default temperature = %v, want omitted", omitted.Temperature)
}
zero := c.buildRequest(provider.Request{Temperature: provider.TemperaturePtr(0)})
if zero.Temperature == nil || *zero.Temperature != 0 {
t.Fatalf("DeepSeek explicit zero temperature = %v, want ptr(0)", zero.Temperature)
}
if zero.Thinking == nil || zero.Thinking.Type != "enabled" {
t.Fatalf("DeepSeek thinking = %+v, want enabled", zero.Thinking)
}
}
// TestBuildRequestMiniMaxThinking covers the M3 wire shape: thinking.type is
// the only knob (no reasoning_effort), and the empty-effort / auto case still
// emits an explicit "adaptive" because that's what the M3 model default means
// (M3 has no implicit "no thinking" mode at the wire level).
func TestBuildRequestMiniMaxThinking(t *testing.T) {
for _, tc := range []struct {
name string
effort string
wantThinking string
}{
{name: "auto-defaults-to-adaptive", effort: "", wantThinking: "adaptive"},
{name: "adaptive", effort: "adaptive", wantThinking: "adaptive"},
{name: "disabled", effort: "disabled", wantThinking: "disabled"},
} {
t.Run(tc.name, func(t *testing.T) {
req := (&client{model: "MiniMax-M3", minimax: true, effort: tc.effort}).buildRequest(provider.Request{})
if req.Thinking == nil || req.Thinking.Type != tc.wantThinking {
t.Fatalf("Thinking = %+v, want %q", req.Thinking, tc.wantThinking)
}
if req.ReasoningEffort != "" {
t.Fatalf("MiniMax must not send reasoning_effort, got %q", req.ReasoningEffort)
}
})
}
}
// TestNewMiniMaxEffortValidation locks in the boot-time validation for the
// MiniMax path. The config effort layer remaps legacy level names, so by the
// time effort reaches this factory it must be one of: "", "adaptive",
// "disabled". Anything else is a config bug, surfaced now (not at request
// time) for an actionable error.
func TestNewMiniMaxEffortValidation(t *testing.T) {
base := provider.Config{Name: "m3", BaseURL: "https://api.minimaxi.com/v1", Model: "MiniMax-M3", APIKey: "k"}
// happy path: auto (empty effort) and both explicit values are accepted
for _, ok := range []string{"", "adaptive", "disabled"} {
if _, err := New(withEffort(base, ok)); err != nil {
t.Errorf("effort=%q should be accepted: %v", ok, err)
}
}
// unhappy: anything else is rejected up front
for _, bad := range []string{"high", "low", "max", "turbo"} {
if _, err := New(withEffort(base, bad)); err == nil {
t.Errorf("effort=%q should be rejected", bad)
}
}
}
// TestNewMiniMaxSetsFlag is a smoke test for base-URL detection: the factory
// must set the `minimax` flag when the base URL points at api.minimaxi.com
// (with or without the /v1 suffix) so buildRequest picks the right wire shape.
func TestNewMiniMaxSetsFlag(t *testing.T) {
for _, baseURL := range []string{
"https://api.minimaxi.com/v1",
"https://api.minimaxi.com",
} {
p, err := New(provider.Config{Name: "m3", BaseURL: baseURL, Model: "MiniMax-M3", APIKey: "k"})
if err != nil {
t.Fatalf("New(%q): %v", baseURL, err)
}
c := p.(*client)
if !c.minimax {
t.Errorf("minimax flag not set for baseURL=%q", baseURL)
}
}
}
// TestBuildRequestZhipuThinking covers the Zhipu GLM wire shape: thinking.type
// is enabled|disabled and reasoning_effort is never sent (the endpoint ignores
// it). Auto (empty effort) defaults to "enabled" — the GLM model default.
func TestBuildRequestZhipuThinking(t *testing.T) {
for _, tc := range []struct {
name string
effort string
wantThinking string
}{
{name: "auto-defaults-to-enabled", effort: "", wantThinking: "enabled"},
{name: "enabled", effort: "enabled", wantThinking: "enabled"},
{name: "disabled", effort: "disabled", wantThinking: "disabled"},
} {
t.Run(tc.name, func(t *testing.T) {
req := (&client{model: "glm-4.5-air", zhipu: true, effort: tc.effort}).buildRequest(provider.Request{})
if req.Thinking == nil || req.Thinking.Type != tc.wantThinking {
t.Fatalf("Thinking = %+v, want %q", req.Thinking, tc.wantThinking)
}
if req.ReasoningEffort != "" {
t.Fatalf("Zhipu must not send reasoning_effort, got %q", req.ReasoningEffort)
}
})
}
}
// TestNewZhipuEffortValidation locks in boot-time validation for the Zhipu path.
// The config effort layer remaps depth levels, so by the time effort reaches the
// factory it must be one of: "", "enabled", "disabled".
func TestNewZhipuEffortValidation(t *testing.T) {
base := provider.Config{Name: "glm", BaseURL: "https://open.bigmodel.cn/api/paas/v4", Model: "glm-4.5-air", APIKey: "k"}
for _, ok := range []string{"", "enabled", "disabled"} {
if _, err := New(withEffort(base, ok)); err != nil {
t.Errorf("effort=%q should be accepted: %v", ok, err)
}
}
for _, bad := range []string{"high", "low", "max", "adaptive"} {
if _, err := New(withEffort(base, bad)); err == nil {
t.Errorf("effort=%q should be rejected", bad)
}
}
}
// TestNewZhipuSetsFlag is a smoke test for base-URL detection across both the
// China (bigmodel.cn) and international (z.ai) GLM endpoints.
func TestNewZhipuSetsFlag(t *testing.T) {
for _, baseURL := range []string{
"https://open.bigmodel.cn/api/paas/v4",
"https://api.z.ai/api/paas/v4",
} {
p, err := New(provider.Config{Name: "glm", BaseURL: baseURL, Model: "glm-4.5-air", APIKey: "k"})
if err != nil {
t.Fatalf("New(%q): %v", baseURL, err)
}
if c := p.(*client); !c.zhipu {
t.Errorf("zhipu flag not set for baseURL=%q", baseURL)
}
}
}
// TestBuildRequestGenericThinking covers the vendor-agnostic `thinking` config
// field on a provider we don't auto-detect: thinking.type is emitted as set, and
// an empty/unset field leaves thinking off the wire entirely.
func TestBuildRequestGenericThinking(t *testing.T) {
for _, tc := range []struct {
name string
thinking string
wantType string // "" means no thinking field
}{
{name: "enabled", thinking: "enabled", wantType: "enabled"},
{name: "disabled", thinking: "disabled", wantType: "disabled"},
{name: "unset-omits", thinking: "", wantType: ""},
} {
t.Run(tc.name, func(t *testing.T) {
req := (&client{model: "some-model", thinkingType: tc.thinking}).buildRequest(provider.Request{})
if tc.wantType == "" {
if req.Thinking != nil {
t.Fatalf("expected no thinking, got %+v", req.Thinking)
}
return
}
if req.Thinking == nil || req.Thinking.Type != tc.wantType {
t.Fatalf("Thinking = %+v, want %q", req.Thinking, tc.wantType)
}
})
}
}
// TestNewThinkingConfigParsing pins how the `thinking` config field is read:
// enabled|disabled are kept (case-insensitively), everything else is ignored so
// an unknown value can never break a request.
func TestNewThinkingConfigParsing(t *testing.T) {
base := provider.Config{Name: "gen", BaseURL: "https://api.example.com/v1", Model: "x", APIKey: "k"}
for in, want := range map[string]string{"enabled": "enabled", "DISABLED": "disabled", "adaptive": "", "garbage": "", "": ""} {
cfg := base
cfg.Extra = map[string]any{"thinking": in}
p, err := New(cfg)
if err != nil {
t.Fatalf("New(thinking=%q): %v", in, err)
}
if got := p.(*client).thinkingType; got != want {
t.Errorf("thinking=%q → thinkingType=%q, want %q", in, got, want)
}
}
}
// TestBuildRequestDeepSeekDisabled covers both user-facing ways to turn
// DeepSeek thinking off. Either input must route to thinking.type=disabled,
// drop reasoning_effort, and keep the pre-fix tool-call history bytes: a
// tool_calls turn with no reasoning omits the reasoning_content key entirely
// (only thinking mode requires it), while reasoning left over from a
// thinking-mode round still round-trips so the prompt-cache prefix of a mixed
// thinking-on→off session stays stable.
func TestBuildRequestDeepSeekDisabled(t *testing.T) {
base := provider.Config{Name: "ds", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4", APIKey: "k"}
for _, tc := range []struct {
name string
extra map[string]any
}{
{name: "effort-disabled", extra: map[string]any{"effort": "disabled"}},
{name: "thinking-disabled", extra: map[string]any{"thinking": "disabled"}},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := base
cfg.Extra = tc.extra
p, err := New(cfg)
if err != nil {
t.Fatalf("New(%v): %v", tc.extra, err)
}
req := p.(*client).buildRequest(provider.Request{
Messages: []provider.Message{
{Role: provider.RoleUser, Content: "inspect"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
ID: "call_1", Name: "read_file", Arguments: `{"path":"main.go"}`,
}}},
{Role: provider.RoleTool, ToolCallID: "call_1", Name: "read_file", Content: "package main"},
{Role: provider.RoleAssistant, ReasoningContent: "from a thinking round", ToolCalls: []provider.ToolCall{{
ID: "call_2", Name: "read_file", Arguments: `{"path":"go.mod"}`,
}}},
{Role: provider.RoleTool, ToolCallID: "call_2", Name: "read_file", Content: "module demo"},
},
})
if req.Thinking == nil || req.Thinking.Type != "disabled" {
t.Fatalf("Thinking = %+v, want disabled", req.Thinking)
}
if req.ReasoningEffort != "" {
t.Fatalf("disabled DeepSeek must not send reasoning_effort, got %q", req.ReasoningEffort)
}
if rc := req.Messages[1].ReasoningContent; rc != nil {
t.Fatalf("disabled mode must omit reasoning_content on a reasoning-less tool_calls turn, got %q", *rc)
}
if rc := req.Messages[3].ReasoningContent; rc == nil || *rc != "from a thinking round" {
t.Fatalf("disabled mode must keep round-tripping thinking-round reasoning, got %v", rc)
}
})
}
}
func withEffort(c provider.Config, effort string) provider.Config {
extra := c.Extra
if extra == nil {
extra = map[string]any{}
} else {
cp := make(map[string]any, len(extra)+1)
for k, v := range extra {
cp[k] = v
}
extra = cp
}
extra["effort"] = effort
c.Extra = extra
return c
}
func TestBuildRequestNonDeepSeekOmitsThinking(t *testing.T) {
req := (&client{model: "mimo-v2", effort: "high"}).buildRequest(provider.Request{})
if req.Thinking != nil {
t.Fatalf("non-DeepSeek request must not include thinking, got %+v", req.Thinking)
}
if req.ReasoningEffort != "high" {
t.Fatalf("ReasoningEffort = %q, want high", req.ReasoningEffort)
}
}
func TestNewOllamaCloudReasoningEffort(t *testing.T) {
p, err := New(provider.Config{Name: "ollama-cloud", BaseURL: "https://ollama.com/v1", Model: "nemotron-3-nano:30b", Extra: map[string]any{"effort": "max"}})
if err != nil {
t.Fatalf("New max: %v", err)
}
c := p.(*client)
if got := c.buildRequest(provider.Request{}).ReasoningEffort; got != "max" {
t.Fatalf("Ollama Cloud reasoning_effort = %q, want max", got)
}
p, err = New(provider.Config{Name: "ollama-cloud", BaseURL: "https://ollama.com/v1", Model: "nemotron-3-nano:30b", Extra: map[string]any{"effort": "none"}})
if err != nil {
t.Fatalf("New none: %v", err)
}
c = p.(*client)
b, err := json.Marshal(c.buildRequest(provider.Request{}))
if err != nil {
t.Fatalf("marshal none: %v", err)
}
if strings.Contains(string(b), "reasoning_effort") {
t.Fatalf("Ollama Cloud effort none must omit reasoning_effort: %s", b)
}
if _, err := New(provider.Config{Name: "ollama-cloud", BaseURL: "https://ollama.com/v1", Model: "nemotron-3-nano:30b", Extra: map[string]any{"effort": "ultra"}}); err == nil {
t.Fatal("New invalid effort succeeded, want error")
}
}
func TestNewDeepSeekThinkingDefaultsAndValidation(t *testing.T) {
p, err := New(provider.Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4"})
if err != nil {
t.Fatalf("New: %v", err)
}
c := p.(*client)
if !c.deepseek || c.effort != "high" {
t.Fatalf("deepseek=%v effort=%q, want true/high", c.deepseek, c.effort)
}
p, err = New(provider.Config{Name: "deepseek", BaseURL: "https://api.deepseek.com/v1", Model: "deepseek-v4", Extra: map[string]any{"effort": "max"}})
if err != nil {
t.Fatalf("New max: %v", err)
}
if got := p.(*client).effort; got != "max" {
t.Fatalf("effort = %q, want max", got)
}
if _, err := New(provider.Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4", Extra: map[string]any{"effort": "medium"}}); err == nil {
t.Fatal("New should reject invalid DeepSeek effort")
}
p, err = New(provider.Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4", Extra: map[string]any{"effort": "off"}})
if err != nil {
t.Fatalf("New should migrate retired effort=off, not reject it: %v", err)
}
if got := p.(*client).effort; got != "high" {
t.Fatalf("retired effort=off should fall back to high, got %q", got)
}
}
func TestNewReadsEffortFromConfig(t *testing.T) {
p, err := New(provider.Config{
Name: "mimo",
BaseURL: "https://api.example.com",
Model: "mimo-v2",
Extra: map[string]any{"effort": "medium"},
})
if err != nil {
t.Fatalf("New: %v", err)
}
if got := p.(*client).effort; got != "medium" {
t.Errorf("effort = %q, want medium", got)
}
}
func TestStreamReadsReasoningFallbackField(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"reasoning":"vllm thinking","content":"answer"}}]}`+"\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}))
defer srv.Close()
p, err := New(provider.Config{Name: "vllm", BaseURL: srv.URL, Model: "qwen", APIKey: "k"})
if err != nil {
t.Fatalf("New: %v", err)
}
ch, err := p.Stream(context.Background(), provider.Request{})
if err != nil {
t.Fatalf("Stream: %v", err)
}
var reasoning, text strings.Builder
for chunk := range ch {
switch chunk.Type {
case provider.ChunkReasoning:
reasoning.WriteString(chunk.Text)
case provider.ChunkText:
text.WriteString(chunk.Text)
case provider.ChunkError:
t.Fatalf("stream error: %v", chunk.Err)
}
}
if reasoning.String() != "vllm thinking" {
t.Fatalf("reasoning = %q, want vLLM fallback field", reasoning.String())
}
if text.String() != "answer" {
t.Fatalf("text = %q, want answer", text.String())
}
}
func TestStreamReasoningContentTakesPrecedenceOverFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"reasoning_content":"standard","reasoning":"fallback"}}]}`+"\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}))
defer srv.Close()
p, err := New(provider.Config{Name: "vllm", BaseURL: srv.URL, Model: "qwen", APIKey: "k"})
if err != nil {
t.Fatalf("New: %v", err)
}
ch, err := p.Stream(context.Background(), provider.Request{})
if err != nil {
t.Fatalf("Stream: %v", err)
}
var reasoning strings.Builder
for chunk := range ch {
switch chunk.Type {
case provider.ChunkReasoning:
reasoning.WriteString(chunk.Text)
case provider.ChunkError:
t.Fatalf("stream error: %v", chunk.Err)
}
}
if reasoning.String() != "standard" {
t.Fatalf("reasoning = %q, want reasoning_content precedence", reasoning.String())
}
}
// TestBuildRequestAlwaysSendsReasoningKeyOnDeepSeekToolCalls proves the wire
// contract verified against the live API: DeepSeek thinking mode 400s an
// assistant tool_calls turn whose reasoning_content KEY is missing from the
// request JSON, but accepts an empty string. A turn whose reasoning was lost
// upstream (gateway renamed/dropped the field, legacy session, model switch)
// must therefore still serialize the key — while plain assistant text turns
// keep omitting it.
func TestBuildRequestAlwaysSendsReasoningKeyOnDeepSeekToolCalls(t *testing.T) {
p, err := New(provider.Config{
Name: "deepseek-proxy",
BaseURL: "https://api.deepseek.com",
Model: "deepseek-v4-pro",
APIKey: "k",
Extra: map[string]any{"reasoning_protocol": "deepseek"},
})
if err != nil {
t.Fatalf("New: %v", err)
}
body, err := json.Marshal(p.(*client).buildRequest(provider.Request{
Messages: []provider.Message{
{Role: provider.RoleUser, Content: "inspect"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
ID: "call_1", Name: "read_file", Arguments: `{"path":"main.go"}`,
}}},
{Role: provider.RoleTool, ToolCallID: "call_1", Name: "read_file", Content: "package main"},
{Role: provider.RoleAssistant, Content: "plain text turn"},
},
}))
if err != nil {
t.Fatalf("marshal request: %v", err)
}
var req struct {
Messages []map[string]json.RawMessage `json:"messages"`
}
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
if len(req.Messages) != 4 {
t.Fatalf("messages = %d, want 4", len(req.Messages))
}
rc, ok := req.Messages[1]["reasoning_content"]
if !ok {
t.Fatal("tool_calls turn with lost reasoning must still serialize the reasoning_content key")
}
if string(rc) != `""` {
t.Fatalf("reasoning_content = %s, want empty string", rc)
}
if _, ok := req.Messages[3]["reasoning_content"]; ok {
t.Fatal("plain assistant text turn must keep omitting reasoning_content")
}
}
func TestWarnOnMissingToolCallReasoningMatchesDeepSeekModelFamily(t *testing.T) {
tests := []struct {
name string
model string
want bool
}{
{name: "exact flash", model: "deepseek-v4-flash", want: false},
{name: "namespaced flash", model: "deepseek/deepseek-v4-flash", want: false},
{name: "exact pro", model: "deepseek-v4-pro", want: true},
{name: "namespaced pro", model: "deepseek/deepseek-v4-pro", want: true},
{name: "mixed case pro", model: "deepseek-ai/DeepSeek-V4-Pro", want: true},
{name: "reasoner", model: "deepseek-reasoner", want: true},
{name: "r1", model: "deepseek-ai/DeepSeek-R1-0528", want: true},
{name: "generic deepseek", model: "deepseek-chat", want: false},
{name: "gateway deepseek v3", model: "deepseek-ai/DeepSeek-V3.2", want: false},
{name: "prover is not pro", model: "deepseek-ai/DeepSeek-Prover-V2", want: false},
{name: "dated pro variant", model: "deepseek-v4-pro-0923", want: true},
{name: "dotted pro variant", model: "deepseek-v4-pro.1", want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p, err := New(provider.Config{
Name: "deepseek-proxy",
BaseURL: "https://gateway.example/v1",
Model: tc.model,
APIKey: "k",
Extra: map[string]any{"reasoning_protocol": "deepseek"},
})
if err != nil {
t.Fatalf("New: %v", err)
}
if !provider.RequiresToolCallReasoning(p) {
t.Fatal("DeepSeek protocol should keep conservative reasoning_content replay for tool-call turns")
}
if got := provider.WarnOnMissingToolCallReasoning(p); got != tc.want {
t.Fatalf("WarnOnMissingToolCallReasoning() = %v, want %v", got, tc.want)
}
})
}
p, err := New(provider.Config{
Name: "deepseek-v4-pro-openai-protocol",
BaseURL: "https://gateway.example/v1",
Model: "deepseek-v4-pro",
APIKey: "k",
Extra: map[string]any{"reasoning_protocol": "openai"},
})
if err != nil {
t.Fatalf("New OpenAI protocol: %v", err)
}
if provider.WarnOnMissingToolCallReasoning(p) {
t.Fatal("OpenAI protocol should not warn using DeepSeek reasoning_content policy")
}
}
// TestBuildRequestRoundTripsDeepSeekToolCallReasoning keeps the healthy-path
// bytes intact: when the session has the provider-issued reasoning, it is
// replayed verbatim on the tool_calls turn.
func TestBuildRequestRoundTripsDeepSeekToolCallReasoning(t *testing.T) {
p, err := New(provider.Config{
Name: "deepseek-proxy",
BaseURL: "https://api.deepseek.com",
Model: "deepseek-v4-pro",
APIKey: "k",
Extra: map[string]any{"reasoning_protocol": "deepseek"},
})
if err != nil {
t.Fatalf("New: %v", err)
}
out := p.(*client).buildRequest(provider.Request{
Messages: []provider.Message{
{Role: provider.RoleUser, Content: "inspect"},
{Role: provider.RoleAssistant, ReasoningContent: "read main.go first", ToolCalls: []provider.ToolCall{{
ID: "call_1", Name: "read_file", Arguments: `{"path":"main.go"}`,
}}},
{Role: provider.RoleTool, ToolCallID: "call_1", Name: "read_file", Content: "package main"},
},
})
got := out.Messages[1].ReasoningContent
if got == nil || *got != "read main.go first" {
t.Fatalf("reasoning_content = %v, want provider-issued reasoning round-tripped", got)
}
}
// TestBuildRequestPreservesEmptyIDToolResults proves a multi-tool turn whose
// calls carry no id (some OpenAI-compatible gateways omit it, sending only the
// index) keeps every tool result through buildRequest. SanitizeToolPairing keys
// on tool_call_id, so empty ids collapse and all but the last result is dropped.
func TestBuildRequestPreservesEmptyIDToolResults(t *testing.T) {
c := &client{model: "deepseek-v4"}
req := c.buildRequest(provider.Request{
Messages: []provider.Message{
{Role: provider.RoleUser, Content: "scan"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "", Name: "read_file", Arguments: `{"p":"a"}`},
{ID: "", Name: "read_file", Arguments: `{"p":"b"}`},
}},
{Role: provider.RoleTool, ToolCallID: "", Name: "read_file", Content: "RESULT-A"},
{Role: provider.RoleTool, ToolCallID: "", Name: "read_file", Content: "RESULT-B"},
},
})
var toolContents []string
for _, m := range req.Messages {
if m.Role == string(provider.RoleTool) {
if s, ok := m.Content.(string); ok {
toolContents = append(toolContents, s)
}
}
}
if len(toolContents) != 2 {
t.Fatalf("want 2 tool results in request, got %d: %v", len(toolContents), toolContents)
}
if toolContents[0] == toolContents[1] {
t.Errorf("tool results collapsed to %q — a result was dropped from the model's context", toolContents[0])
}
}
// TestStreamSynthesizesMissingToolCallIDs covers a gateway that streams tool
// calls by index with no id (vLLM / llama.cpp do this). Each completed call must
// come back with a stable, distinct synthetic id so its result can pair back.
func TestStreamSynthesizesMissingToolCallIDs(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"read_file","arguments":"{\"p\":\"a\"}"}}]}}]}`+"\n\n")
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"name":"read_file","arguments":"{\"p\":\"b\"}"}}]}}]}`+"\n\n")
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`+"\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}))
defer srv.Close()
p, err := New(provider.Config{Name: "local", BaseURL: srv.URL, Model: "qwen", APIKey: "k"})
if err != nil {
t.Fatalf("New: %v", err)
}
ch, err := p.Stream(context.Background(), provider.Request{})
if err != nil {
t.Fatalf("Stream: %v", err)
}
var ids []string
for chunk := range ch {
if chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil {
ids = append(ids, chunk.ToolCall.ID)
}
}
if len(ids) != 2 {
t.Fatalf("want 2 tool calls, got %d: %v", len(ids), ids)
}
if ids[0] == "" || ids[1] == "" {
t.Errorf("a tool call came back with an empty id: %v", ids)
}
if ids[0] == ids[1] {
t.Errorf("synthesized ids must be distinct, got %v", ids)
}
}
func TestBuildRequestContentNullForAssistantToolCalls(t *testing.T) {
c := &client{name: "x", model: "m", baseURL: "https://api.example.com/v1"}
req := provider.Request{
Messages: []provider.Message{
{Role: provider.RoleAssistant, Content: "", ToolCalls: []provider.ToolCall{{ID: "c1", Name: "ls", Arguments: `{}`}}},
{Role: provider.RoleTool, Content: "", ToolCallID: "c1", Name: "ls"},
{Role: provider.RoleAssistant, Content: "all done"},
},
Tools: []provider.ToolSchema{{Name: "noargs", Parameters: provider.CanonicalizeSchema(nil)}},
}
body, err := json.Marshal(c.buildRequest(req))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !json.Valid(body) {
t.Fatalf("invalid JSON body: %s", body)
}
s := string(body)
if !strings.Contains(s, `"tool_calls"`) || !strings.Contains(s, `"content":null`) {
t.Errorf("assistant tool_calls turn should carry null content: %s", s)
}
if !strings.Contains(s, `{"role":"tool","content":""`) {
t.Errorf("tool message should keep empty-string content, not null: %s", s)
}
if !strings.Contains(s, `"content":"all done"`) {
t.Errorf("text assistant turn should keep its string content: %s", s)
}
if !strings.Contains(s, `"parameters":{"properties":{},"type":"object"}`) {
t.Errorf("no-param tool should serialize a strict empty-object schema: %s", s)
}
}
func TestBuildRequestOmitsResponseOnlyToolCallIndex(t *testing.T) {
c := &client{name: "x", model: "m", baseURL: "https://api.example.com/v1"}
req := provider.Request{
Messages: []provider.Message{{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{{
ID: "call_1",
Name: "bash",
Arguments: `{"cmd":"ls"}`,
}},
}},
}
body, err := json.Marshal(c.buildRequest(req))
if err != nil {
t.Fatalf("marshal: %v", err)
}
s := string(body)
if !strings.Contains(s, `"tool_calls"`) {
t.Fatalf("request body missing tool call: %s", s)
}
if strings.Contains(s, `"index"`) {
t.Fatalf("request body contains response-only tool_call index: %s", s)
}
}
func TestBuildRequestDefaultsEmptyToolParameters(t *testing.T) {
c := &client{name: "x", model: "m", baseURL: "https://api.example.com/v1"}
req := provider.Request{
Tools: []provider.ToolSchema{{Name: "noargs"}},
}
body, err := json.Marshal(c.buildRequest(req))
if err != nil {
t.Fatalf("marshal: %v", err)
}
var wire struct {
Tools []struct {
Function map[string]json.RawMessage `json:"function"`
} `json:"tools"`
}
if err := json.Unmarshal(body, &wire); err != nil {
t.Fatalf("unmarshal request: %v\n%s", err, body)
}
if len(wire.Tools) != 1 {
t.Fatalf("tools = %d, want 1: %s", len(wire.Tools), body)
}
fn := wire.Tools[0].Function
if string(fn["name"]) != `"noargs"` {
t.Fatalf("function name = %s, want noargs", fn["name"])
}
if _, ok := fn["description"]; ok {
t.Fatalf("empty description should be omitted: %s", body)
}
if got, want := string(fn["parameters"]), `{"properties":{},"type":"object"}`; got != want {
t.Fatalf("nil parameters should default to %s, got %s in %s", want, got, body)
}
}