esengine--deepseek-reasonix
1379 行
50 KiB
Go
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)
|
|
}
|
|
}
|