mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 02:14:39 +00:00
OpenAI rejects non-default temperature on gpt-5 / gpt-5-chat with
HTTP 400 ("Unsupported value: 'temperature' does not support 0.7
with this model. Only the default (1) value is supported.").
skipTemp in buildRequestBody only covered gpt-5-mini / gpt-5-nano and
the o-series, so base gpt-5 and gpt-5-chat fell through and received
config.DefaultTemperature (0.7). On channel integrations the inbound
error is suppressed, leaving the agent silently dead.
The gpt-5.X flagship variants (gpt-5.1, gpt-5.4, gpt-5.5) keep their
temperature — only the base gpt-5 and gpt-5-chat aliases are locked
to the default.
Regression cases added for gpt-5, gpt-5-chat, gpt-5-chat-latest and
provider-prefixed forms; gpt-5 moved to the locked list in
TestBuildRequestBody_TemperatureKeptForNonReasoningModels per the
maintainer triage on #1460.
Fixes #1460
759 lines
24 KiB
Go
759 lines
24 KiB
Go
package providers
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestTruncateToolCallID(t *testing.T) {
|
|
t.Run("short IDs stay unchanged", func(t *testing.T) {
|
|
ids := []string{
|
|
"",
|
|
"call_abc123",
|
|
"call_0123456789abcdef0123456789abcdef012", // exactly 40 chars
|
|
}
|
|
for _, id := range ids {
|
|
if got := truncateToolCallID(id); got != id {
|
|
t.Errorf("truncateToolCallID(%q) = %q, want unchanged", id, got)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("long IDs are shortened deterministically", func(t *testing.T) {
|
|
id := "call_0123456789abcdef0123456789abcdef01234"
|
|
got1 := truncateToolCallID(id)
|
|
got2 := truncateToolCallID(id)
|
|
if got1 != got2 {
|
|
t.Fatalf("truncateToolCallID(%q) should be deterministic: %q != %q", id, got1, got2)
|
|
}
|
|
if len(got1) != maxToolCallIDLen {
|
|
t.Fatalf("truncateToolCallID(%q) length = %d, want %d", id, len(got1), maxToolCallIDLen)
|
|
}
|
|
if got1 == id {
|
|
t.Fatalf("truncateToolCallID(%q) should shorten long IDs", id)
|
|
}
|
|
if !strings.HasPrefix(got1, "call_") {
|
|
t.Fatalf("truncateToolCallID(%q) should preserve call_ prefix, got %q", id, got1)
|
|
}
|
|
})
|
|
|
|
t.Run("shared-prefix long IDs stay unique", func(t *testing.T) {
|
|
prefix40 := "call_0123456789abcdef0123456789abcdef012"
|
|
id1 := prefix40 + "_0"
|
|
id2 := prefix40 + "_1"
|
|
got1 := truncateToolCallID(id1)
|
|
got2 := truncateToolCallID(id2)
|
|
if got1 == got2 {
|
|
t.Fatalf("shared-prefix IDs collided after shortening: %q", got1)
|
|
}
|
|
if len(got1) > maxToolCallIDLen || len(got2) > maxToolCallIDLen {
|
|
t.Fatalf("shared-prefix IDs should be <= %d chars: %q / %q", maxToolCallIDLen, got1, got2)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBuildRequestBody_TemperatureSkippedForReasoningModels(t *testing.T) {
|
|
p := NewOpenAIProvider("test", "key", "https://api.openai.com/v1", "gpt-4")
|
|
|
|
// These model families don't support custom temperature (locked to default).
|
|
// This is a model-level constraint, not provider-specific.
|
|
models := []string{
|
|
"gpt-5",
|
|
"gpt-5-chat",
|
|
"gpt-5-chat-latest",
|
|
"gpt-5-mini",
|
|
"gpt-5-mini-2025-01",
|
|
"gpt-5-nano",
|
|
"o1",
|
|
"o1-mini",
|
|
"o1-preview",
|
|
"o3",
|
|
"o3-mini",
|
|
"o4-mini",
|
|
"openai/gpt-5",
|
|
"openai/gpt-5-chat-latest",
|
|
"openai/gpt-5-mini",
|
|
"openai/o3-mini",
|
|
}
|
|
|
|
for _, model := range models {
|
|
t.Run(model, func(t *testing.T) {
|
|
req := ChatRequest{
|
|
Messages: []Message{{Role: "user", Content: "test"}},
|
|
Options: map[string]any{
|
|
OptTemperature: 0.7,
|
|
},
|
|
}
|
|
body := p.buildRequestBody(model, req, false)
|
|
if _, hasTemp := body["temperature"]; hasTemp {
|
|
t.Errorf("model %q: temperature should be skipped but was included", model)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_TemperatureKeptForNonReasoningModels(t *testing.T) {
|
|
p := NewOpenAIProvider("test", "key", "https://api.openai.com/v1", "gpt-4")
|
|
|
|
// These models DO support custom temperature -- must not be suppressed.
|
|
models := []string{
|
|
"gpt-4",
|
|
"gpt-4o",
|
|
"gpt-4-turbo",
|
|
"gpt-5.1",
|
|
"gpt-5.4",
|
|
"openai/gpt-5.4",
|
|
"claude-3-sonnet",
|
|
"llama-3",
|
|
}
|
|
|
|
for _, model := range models {
|
|
t.Run(model, func(t *testing.T) {
|
|
req := ChatRequest{
|
|
Messages: []Message{{Role: "user", Content: "test"}},
|
|
Options: map[string]any{
|
|
OptTemperature: 0.7,
|
|
},
|
|
}
|
|
body := p.buildRequestBody(model, req, false)
|
|
if _, hasTemp := body["temperature"]; !hasTemp {
|
|
t.Errorf("model %q: temperature should be included but was skipped", model)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_TemperatureDependsOnModelNotAPIBase(t *testing.T) {
|
|
p := NewOpenAIProvider("azure", "key", "https://example.openai.azure.com/openai/deployments/test", "gpt-4")
|
|
|
|
req := ChatRequest{
|
|
Messages: []Message{{Role: "user", Content: "test"}},
|
|
Options: map[string]any{
|
|
OptTemperature: 0.7,
|
|
},
|
|
}
|
|
|
|
body := p.buildRequestBody("gpt-5.4", req, false)
|
|
if _, hasTemp := body["temperature"]; !hasTemp {
|
|
t.Fatal("azure-backed gpt-5.4 should keep temperature; gating must stay model-based")
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_ReasoningEffortOpenAIOnly(t *testing.T) {
|
|
thinkOpts := map[string]any{OptThinkingLevel: "medium"}
|
|
|
|
t.Run("Together Qwen omits reasoning_effort", func(t *testing.T) {
|
|
p := NewOpenAIProvider("togetherai", "key", "https://api.together.xyz/v1", "")
|
|
req := ChatRequest{
|
|
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
Options: thinkOpts,
|
|
}
|
|
body := p.buildRequestBody("Qwen/Qwen3.5-397B-A17B", req, false)
|
|
if _, ok := body[OptReasoningEffort]; ok {
|
|
t.Fatalf("Qwen on Together must not send %q (HTTP 400 on unknown fields), body=%v", OptReasoningEffort, body)
|
|
}
|
|
})
|
|
|
|
t.Run("OpenAI gpt-5 keeps reasoning_effort", func(t *testing.T) {
|
|
p := NewOpenAIProvider("openai", "key", "https://api.openai.com/v1", "gpt-5")
|
|
req := ChatRequest{
|
|
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
Options: thinkOpts,
|
|
}
|
|
body := p.buildRequestBody("gpt-5.4", req, false)
|
|
if got, ok := body[OptReasoningEffort].(string); !ok || got != "medium" {
|
|
t.Fatalf("gpt-5.4 should send reasoning_effort medium, got body=%v", body)
|
|
}
|
|
})
|
|
|
|
t.Run("OpenAI o3-mini keeps reasoning_effort", func(t *testing.T) {
|
|
p := NewOpenAIProvider("openai", "key", "https://api.openai.com/v1", "")
|
|
req := ChatRequest{
|
|
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
Options: thinkOpts,
|
|
}
|
|
body := p.buildRequestBody("o3-mini", req, false)
|
|
if got, ok := body[OptReasoningEffort].(string); !ok || got != "medium" {
|
|
t.Fatalf("o3-mini should send reasoning_effort medium, got body=%v", body)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBuildRequestBody_TogetherOmitsStreamOptions(t *testing.T) {
|
|
together := NewOpenAIProvider("togetherai", "key", "https://api.together.xyz/v1", "")
|
|
openai := NewOpenAIProvider("openai", "key", "https://api.openai.com/v1", "gpt-4")
|
|
|
|
req := ChatRequest{
|
|
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
}
|
|
|
|
gotTogether := together.buildRequestBody("Qwen/Qwen3.5-397B-A17B", req, true)
|
|
if _, ok := gotTogether["stream_options"]; ok {
|
|
t.Fatalf("Together streaming must omit stream_options, got %v", gotTogether["stream_options"])
|
|
}
|
|
|
|
gotOpenAI := openai.buildRequestBody("gpt-4o", req, true)
|
|
if _, ok := gotOpenAI["stream_options"]; !ok {
|
|
t.Fatal("OpenAI streaming should include stream_options for usage")
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_TogetherOmitsStrayDashScopeKeys(t *testing.T) {
|
|
p := NewOpenAIProvider("togetherai", "key", "https://api.together.xyz/v1", "")
|
|
req := ChatRequest{
|
|
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
Options: map[string]any{
|
|
OptEnableThinking: true,
|
|
OptThinkingBudget: 4096,
|
|
},
|
|
}
|
|
body := p.buildRequestBody("Qwen/Qwen3.5-397B-A17B", req, false)
|
|
if _, ok := body[OptEnableThinking]; ok {
|
|
t.Fatalf("Together must not send %q", OptEnableThinking)
|
|
}
|
|
if _, ok := body[OptThinkingBudget]; ok {
|
|
t.Fatalf("Together must not send %q", OptThinkingBudget)
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_MultimodalTextBeforeImages(t *testing.T) {
|
|
p := NewOpenAIProvider("test", "key", "https://api.openai.com/v1", "gpt-4")
|
|
req := ChatRequest{
|
|
Messages: []Message{
|
|
{
|
|
Role: "user",
|
|
Content: "describe",
|
|
Images: []ImageContent{
|
|
{MimeType: "image/jpeg", Data: "qq=="},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
body := p.buildRequestBody("gpt-4o", req, false)
|
|
msgs := body["messages"].([]map[string]any)
|
|
parts, ok := msgs[0]["content"].([]map[string]any)
|
|
if !ok || len(parts) < 2 {
|
|
t.Fatalf("want multimodal parts, got %v", msgs[0]["content"])
|
|
}
|
|
if typ, _ := parts[0]["type"].(string); typ != "text" {
|
|
t.Fatalf("first part should be text, got type=%q parts=%v", typ, parts)
|
|
}
|
|
if typ, _ := parts[1]["type"].(string); typ != "image_url" {
|
|
t.Fatalf("second part should be image_url, got type=%q", typ)
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_MultimodalWithImageURL(t *testing.T) {
|
|
p := NewOpenAIProvider("test", "key", "https://api.openai.com/v1", "gpt-4")
|
|
req := ChatRequest{
|
|
Messages: []Message{
|
|
{
|
|
Role: "user",
|
|
Content: "describe",
|
|
Images: []ImageContent{
|
|
{URL: "https://example.com/image.png"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
body := p.buildRequestBody("gpt-4o", req, false)
|
|
msgs := body["messages"].([]map[string]any)
|
|
parts, ok := msgs[0]["content"].([]map[string]any)
|
|
if !ok || len(parts) < 2 {
|
|
t.Fatalf("want multimodal parts, got %v", msgs[0]["content"])
|
|
}
|
|
imgPart := parts[1]["image_url"].(map[string]any)
|
|
if urlVal, _ := imgPart["url"].(string); urlVal != "https://example.com/image.png" {
|
|
t.Errorf("expected URL to be https://example.com/image.png, got %q", urlVal)
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_MultimodalWithVideoURL(t *testing.T) {
|
|
p := NewOpenAIProvider("test", "key", "https://api.openai.com/v1", "gpt-4")
|
|
req := ChatRequest{
|
|
Messages: []Message{
|
|
{
|
|
Role: "user",
|
|
Content: "describe video",
|
|
Videos: []VideoContent{
|
|
{MimeType: "video/mp4", URL: "https://example.com/video.mp4"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
body := p.buildRequestBody("gpt-4o", req, false)
|
|
msgs := body["messages"].([]map[string]any)
|
|
parts, ok := msgs[0]["content"].([]map[string]any)
|
|
if !ok || len(parts) < 2 {
|
|
t.Fatalf("want multimodal parts, got %v", msgs[0]["content"])
|
|
}
|
|
videoPart, ok := parts[1]["video_url"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected video_url part, got %v", parts[1])
|
|
}
|
|
if urlVal, _ := videoPart["url"].(string); urlVal != "https://example.com/video.mp4" {
|
|
t.Errorf("expected URL to be https://example.com/video.mp4, got %q", urlVal)
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_TogetherDetectedByProviderType(t *testing.T) {
|
|
// Together behind reverse proxy — detected by providerType, not URL.
|
|
p := NewOpenAIProvider("my-proxy", "key", "https://proxy.internal/v1", "")
|
|
p.WithProviderType("together")
|
|
|
|
req := ChatRequest{
|
|
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
Options: map[string]any{OptThinkingLevel: "medium"},
|
|
}
|
|
|
|
body := p.buildRequestBody("Qwen/Qwen3.5-397B-A17B", req, true)
|
|
if _, ok := body["stream_options"]; ok {
|
|
t.Fatal("Together (via providerType) must omit stream_options")
|
|
}
|
|
if _, ok := body[OptReasoningEffort]; ok {
|
|
t.Fatal("Together (via providerType) must omit reasoning_effort")
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_TogetherDetectedByName(t *testing.T) {
|
|
// Together detected by provider name.
|
|
p := NewOpenAIProvider("together-prod", "key", "https://proxy.internal/v1", "")
|
|
|
|
body := p.buildRequestBody("Qwen/Qwen3.5-397B-A17B", ChatRequest{
|
|
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
}, true)
|
|
if _, ok := body["stream_options"]; ok {
|
|
t.Fatal("Together (via name) must omit stream_options")
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_MultimodalImagesOnlyNoEmptyTextPart(t *testing.T) {
|
|
p := NewOpenAIProvider("test", "key", "https://api.openai.com/v1", "gpt-4")
|
|
req := ChatRequest{
|
|
Messages: []Message{
|
|
{
|
|
Role: "user",
|
|
Content: "", // no text
|
|
Images: []ImageContent{{MimeType: "image/png", Data: "abc=="}},
|
|
},
|
|
},
|
|
}
|
|
body := p.buildRequestBody("gpt-4o", req, false)
|
|
msgs := body["messages"].([]map[string]any)
|
|
parts, ok := msgs[0]["content"].([]map[string]any)
|
|
if !ok {
|
|
t.Fatal("want multimodal parts array")
|
|
}
|
|
// Only image parts — no empty text part should be present
|
|
for _, part := range parts {
|
|
if typ, _ := part["type"].(string); typ == "text" {
|
|
t.Fatalf("images-only message should not have text part, got %v", parts)
|
|
}
|
|
}
|
|
if len(parts) != 1 {
|
|
t.Fatalf("want 1 image part, got %d parts: %v", len(parts), parts)
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_OllamaModelOmitsReasoningContent(t *testing.T) {
|
|
// Ollama/vLLM self-hosted models should NOT get reasoning_content
|
|
// since most don't support it and it causes HTTP 400.
|
|
p := NewOpenAIProvider("ollama", "key", "http://localhost:11434/v1", "")
|
|
|
|
req := ChatRequest{
|
|
Messages: []Message{
|
|
{Role: "assistant", Content: "sure", Thinking: "let me think..."},
|
|
{Role: "user", Content: "continue"},
|
|
},
|
|
}
|
|
|
|
body := p.buildRequestBody("qwen2:7b", req, false)
|
|
msgs := body["messages"].([]map[string]any)
|
|
for _, m := range msgs {
|
|
if role, _ := m["role"].(string); role == "assistant" {
|
|
if _, has := m["reasoning_content"]; has {
|
|
t.Fatalf("Ollama qwen2:7b must omit reasoning_content, got %v", m)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_DeepSeekKeepsReasoningContent(t *testing.T) {
|
|
// DeepSeek models must keep reasoning_content (required for thinking replay).
|
|
p := NewOpenAIProvider("deepseek", "key", "https://api.deepseek.com/v1", "")
|
|
|
|
req := ChatRequest{
|
|
Messages: []Message{
|
|
{Role: "assistant", Content: "result", Thinking: "step by step"},
|
|
{Role: "user", Content: "next"},
|
|
},
|
|
}
|
|
|
|
body := p.buildRequestBody("deepseek-r1", req, false)
|
|
msgs := body["messages"].([]map[string]any)
|
|
for _, m := range msgs {
|
|
if role, _ := m["role"].(string); role == "assistant" {
|
|
if _, has := m["reasoning_content"]; !has {
|
|
t.Fatalf("DeepSeek must keep reasoning_content, got %v", m)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_KimiKeepsReasoningContent(t *testing.T) {
|
|
p := NewOpenAIProvider("kimi", "key", "https://api.moonshot.cn/v1", "")
|
|
|
|
req := ChatRequest{
|
|
Messages: []Message{
|
|
{Role: "assistant", Content: "ok", Thinking: "reasoning"},
|
|
{Role: "user", Content: "next"},
|
|
},
|
|
}
|
|
|
|
body := p.buildRequestBody("kimi-k2", req, false)
|
|
msgs := body["messages"].([]map[string]any)
|
|
for _, m := range msgs {
|
|
if role, _ := m["role"].(string); role == "assistant" {
|
|
if _, has := m["reasoning_content"]; !has {
|
|
t.Fatalf("Kimi must keep reasoning_content, got %v", m)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_DashScopePassthroughByProviderType(t *testing.T) {
|
|
// DashScope behind proxy — detected by providerType.
|
|
p := NewOpenAIProvider("my-qwen", "key", "https://proxy.internal/v1", "")
|
|
p.WithProviderType("dashscope")
|
|
|
|
req := ChatRequest{
|
|
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
Options: map[string]any{
|
|
OptEnableThinking: true,
|
|
OptThinkingBudget: 8192,
|
|
},
|
|
}
|
|
|
|
body := p.buildRequestBody("qwen3-max", req, false)
|
|
if _, ok := body[OptEnableThinking]; !ok {
|
|
t.Fatal("DashScope (via providerType) must pass through enable_thinking")
|
|
}
|
|
if _, ok := body[OptThinkingBudget]; !ok {
|
|
t.Fatal("DashScope (via providerType) must pass through thinking_budget")
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_PrefixedModelsUseCorrectTokenField(t *testing.T) {
|
|
p := NewOpenAIProvider("test", "key", "https://api.openai.com/v1", "gpt-4")
|
|
|
|
tests := []struct {
|
|
model string
|
|
wantCompletionKey bool
|
|
}{
|
|
{model: "openai/o3-mini", wantCompletionKey: true},
|
|
{model: "openai/gpt-5.4", wantCompletionKey: true},
|
|
{model: "openai/gpt-4o", wantCompletionKey: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.model, func(t *testing.T) {
|
|
req := ChatRequest{
|
|
Messages: []Message{{Role: "user", Content: "test"}},
|
|
Options: map[string]any{
|
|
OptMaxTokens: 123,
|
|
},
|
|
}
|
|
body := p.buildRequestBody(tt.model, req, false)
|
|
_, hasCompletionKey := body["max_completion_tokens"]
|
|
_, hasMaxTokens := body["max_tokens"]
|
|
if hasCompletionKey != tt.wantCompletionKey {
|
|
t.Fatalf("model %q: max_completion_tokens present = %v, want %v", tt.model, hasCompletionKey, tt.wantCompletionKey)
|
|
}
|
|
if hasMaxTokens == tt.wantCompletionKey {
|
|
t.Fatalf("model %q: max_tokens/max_completion_tokens routing incorrect: body=%v", tt.model, body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_ToolCallIDsTruncated(t *testing.T) {
|
|
p := NewOpenAIProvider("test", "key", "https://api.openai.com/v1", "gpt-4")
|
|
|
|
longID := "call_0123456789abcdef0123456789abcdef01234" // 42 chars
|
|
|
|
req := ChatRequest{
|
|
Messages: []Message{
|
|
{
|
|
Role: "assistant",
|
|
ToolCalls: []ToolCall{
|
|
{ID: longID, Name: "test_fn", Arguments: map[string]any{"arg": "val"}},
|
|
},
|
|
},
|
|
{
|
|
Role: "tool",
|
|
ToolCallID: longID,
|
|
Content: "result",
|
|
},
|
|
{Role: "user", Content: "continue"},
|
|
},
|
|
}
|
|
|
|
body := p.buildRequestBody("gpt-4", req, false)
|
|
msgs := body["messages"].([]map[string]any)
|
|
|
|
var assistantID, toolResultID string
|
|
for _, msg := range msgs {
|
|
if tcs, ok := msg["tool_calls"]; ok {
|
|
toolCalls := tcs.([]map[string]any)
|
|
assistantID = toolCalls[0]["id"].(string)
|
|
if len(assistantID) > 40 {
|
|
t.Errorf("tool_calls[0].id length = %d, want <= 40", len(assistantID))
|
|
}
|
|
}
|
|
if tcid, ok := msg["tool_call_id"]; ok {
|
|
toolResultID = tcid.(string)
|
|
if len(toolResultID) > 40 {
|
|
t.Errorf("tool_call_id length = %d, want <= 40", len(toolResultID))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Critical: truncated IDs must match for API correlation
|
|
if assistantID != toolResultID {
|
|
t.Errorf("ID correlation broken: tool_calls.id=%q != tool_call_id=%q", assistantID, toolResultID)
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_LegacyLongToolCallIDsStayUnique(t *testing.T) {
|
|
p := NewOpenAIProvider("test", "key", "https://api.openai.com/v1", "gpt-4")
|
|
|
|
prefix40 := "call_0123456789abcdef0123456789abcdef012"
|
|
id1 := prefix40 + "_0"
|
|
id2 := prefix40 + "_1"
|
|
|
|
req := ChatRequest{
|
|
Messages: []Message{
|
|
{
|
|
Role: "assistant",
|
|
ToolCalls: []ToolCall{
|
|
{ID: id1, Name: "fn1", Arguments: map[string]any{}},
|
|
{ID: id2, Name: "fn2", Arguments: map[string]any{}},
|
|
},
|
|
},
|
|
{Role: "tool", ToolCallID: id1, Content: "result-1"},
|
|
{Role: "tool", ToolCallID: id2, Content: "result-2"},
|
|
{Role: "user", Content: "continue"},
|
|
},
|
|
}
|
|
|
|
body := p.buildRequestBody("gpt-4", req, false)
|
|
msgs := body["messages"].([]map[string]any)
|
|
|
|
toolCalls := msgs[0]["tool_calls"].([]map[string]any)
|
|
assistantID1 := toolCalls[0]["id"].(string)
|
|
assistantID2 := toolCalls[1]["id"].(string)
|
|
if assistantID1 == assistantID2 {
|
|
t.Fatalf("legacy long IDs collided after shortening: %q", assistantID1)
|
|
}
|
|
if len(assistantID1) > maxToolCallIDLen || len(assistantID2) > maxToolCallIDLen {
|
|
t.Fatalf("assistant IDs should be <= %d chars: %q / %q", maxToolCallIDLen, assistantID1, assistantID2)
|
|
}
|
|
|
|
if got := msgs[1]["tool_call_id"].(string); got != assistantID1 {
|
|
t.Fatalf("first tool result ID = %q, want %q", got, assistantID1)
|
|
}
|
|
if got := msgs[2]["tool_call_id"].(string); got != assistantID2 {
|
|
t.Fatalf("second tool result ID = %q, want %q", got, assistantID2)
|
|
}
|
|
}
|
|
|
|
var mistralWireIDRe = regexp.MustCompile(`^[0-9a-f]{9}$`)
|
|
|
|
func TestNormalizeMistralToolCallID_DeterministicNineChars(t *testing.T) {
|
|
id := "call_85f419357e554e8983a7edb4d2317e93e15"
|
|
a := normalizeMistralToolCallID(id)
|
|
b := normalizeMistralToolCallID(id)
|
|
if a != b {
|
|
t.Fatalf("normalizeMistralToolCallID not deterministic: %q vs %q", a, b)
|
|
}
|
|
if !mistralWireIDRe.MatchString(a) {
|
|
t.Fatalf("got %q, want exactly 9 hex chars", a)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeMistralToolCallID_DistinctIDsStayUnique(t *testing.T) {
|
|
// IDs sharing a long prefix must not collide after normalization.
|
|
id1 := "call_a1b2c3d4e5f6a1b2c3d4e5f6_suffix1"
|
|
id2 := "call_a1b2c3d4e5f6a1b2c3d4e5f6_suffix2"
|
|
if normalizeMistralToolCallID(id1) == normalizeMistralToolCallID(id2) {
|
|
t.Fatal("distinct IDs must not collide after normalization")
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_MistralToolCallIDsWireFormat(t *testing.T) {
|
|
p := NewOpenAIProvider("mistral", "key", "https://api.mistral.ai/v1", "mistral-large-latest")
|
|
longID := "call_85f419357e554e8983a7edb4d2317e93e15"
|
|
|
|
req := ChatRequest{
|
|
Messages: []Message{
|
|
{
|
|
Role: "assistant",
|
|
ToolCalls: []ToolCall{
|
|
{ID: longID, Name: "test_fn", Arguments: map[string]any{"x": 1}},
|
|
},
|
|
},
|
|
{Role: "tool", ToolCallID: longID, Content: "ok"},
|
|
{Role: "user", Content: "next"},
|
|
},
|
|
}
|
|
|
|
body := p.buildRequestBody("mistral-large-latest", req, false)
|
|
msgs := body["messages"].([]map[string]any)
|
|
|
|
var assistantID, toolResultID string
|
|
for _, msg := range msgs {
|
|
if tcs, ok := msg["tool_calls"]; ok {
|
|
toolCalls := tcs.([]map[string]any)
|
|
assistantID = toolCalls[0]["id"].(string)
|
|
}
|
|
if tcid, ok := msg["tool_call_id"]; ok {
|
|
toolResultID = tcid.(string)
|
|
}
|
|
}
|
|
|
|
if assistantID != toolResultID {
|
|
t.Fatalf("IDs must match: tool_calls.id=%q tool_call_id=%q", assistantID, toolResultID)
|
|
}
|
|
if !mistralWireIDRe.MatchString(assistantID) {
|
|
t.Fatalf("mistral wire id %q must be 9 hex chars", assistantID)
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_MistralDBProviderTypeDetected(t *testing.T) {
|
|
// DB-loaded Mistral providers use providerType="mistral" with a user-chosen name.
|
|
p := NewOpenAIProvider("my-mistral", "key", "https://api.mistral.ai/v1", "mistral-large-latest")
|
|
p.WithProviderType("mistral")
|
|
|
|
id := "call_85f419357e554e8983a7edb4d2317e93e15"
|
|
got := p.wireToolCallID(id)
|
|
if !mistralWireIDRe.MatchString(got) {
|
|
t.Fatalf("DB provider with providerType=mistral: got %q, want 9 hex chars", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_OllamaDisablesThinkByDefault(t *testing.T) {
|
|
// Ollama thinking models (qwq, deepseek-r1) have thinking on by default.
|
|
// goclaw must send think=false unless the user explicitly enables thinking.
|
|
cases := []struct {
|
|
name string
|
|
provider *OpenAIProvider
|
|
}{
|
|
{
|
|
name: "ollama by name",
|
|
provider: NewOpenAIProvider("ollama", "", "http://localhost:11434/v1", ""),
|
|
},
|
|
{
|
|
name: "ollama by providerType",
|
|
provider: NewOpenAIProvider("local", "", "http://myhost:11434/v1", "").WithProviderType("ollama"),
|
|
},
|
|
{
|
|
name: "ollama_cloud by providerType",
|
|
provider: NewOpenAIProvider("local", "", "https://cloud.ollama.ai/v1", "").WithProviderType("ollama_cloud"),
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := ChatRequest{Messages: []Message{{Role: "user", Content: "hi"}}}
|
|
body := tc.provider.buildRequestBody("qwq:32b", req, false)
|
|
think, ok := body["think"]
|
|
if !ok {
|
|
t.Fatalf("%s: expected think field in request body", tc.name)
|
|
}
|
|
if think != false {
|
|
t.Fatalf("%s: expected think=false, got %v", tc.name, think)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_OllamaThinkNotDisabledWhenLevelSet(t *testing.T) {
|
|
// When OptThinkingLevel is explicitly set to a non-off value, think=false must NOT be sent
|
|
// so that thinking is allowed for users who opted in.
|
|
p := NewOpenAIProvider("ollama", "", "http://localhost:11434/v1", "")
|
|
req := ChatRequest{
|
|
Messages: []Message{{Role: "user", Content: "reason about this"}},
|
|
Options: map[string]any{OptThinkingLevel: "high"},
|
|
}
|
|
body := p.buildRequestBody("qwq:32b", req, false)
|
|
if think, ok := body["think"]; ok && think == false {
|
|
t.Fatalf("ollama with OptThinkingLevel=high must not send think=false, got think=%v", think)
|
|
}
|
|
}
|
|
|
|
func TestOllamaSupportsThinkingFalse(t *testing.T) {
|
|
p := NewOpenAIProvider("ollama", "", "http://localhost:11434/v1", "").WithProviderType("ollama")
|
|
if p.SupportsThinking() {
|
|
t.Fatal("SupportsThinking() must return false for Ollama endpoints")
|
|
}
|
|
if p.Capabilities().Thinking {
|
|
t.Fatal("Capabilities().Thinking must be false for Ollama endpoints")
|
|
}
|
|
}
|
|
|
|
func TestNonOllamaSupportsThinkingTrue(t *testing.T) {
|
|
p := NewOpenAIProvider("openai", "key", "https://api.openai.com/v1", "")
|
|
if !p.SupportsThinking() {
|
|
t.Fatal("SupportsThinking() must return true for non-Ollama OpenAI-compat providers")
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_OllamaThinkingEnabledOverride(t *testing.T) {
|
|
// Provider-level settings.thinking_enabled overrides the default off
|
|
// behavior for Ollama endpoints, regardless of OptThinkingLevel.
|
|
trueVal := true
|
|
falseVal := false
|
|
|
|
cases := []struct {
|
|
name string
|
|
override *bool
|
|
want bool
|
|
}{
|
|
{name: "override true enables thinking", override: &trueVal, want: true},
|
|
{name: "override false disables thinking", override: &falseVal, want: false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
p := NewOpenAIProvider("ollama", "", "http://localhost:11434/v1", "").
|
|
WithThinkingEnabled(tc.override)
|
|
req := ChatRequest{Messages: []Message{{Role: "user", Content: "hi"}}}
|
|
body := p.buildRequestBody("qwq:32b", req, false)
|
|
think, ok := body["think"]
|
|
if !ok {
|
|
t.Fatalf("expected think field in request body")
|
|
}
|
|
if think != tc.want {
|
|
t.Fatalf("think = %v, want %v", think, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildRequestBody_OllamaThinkingEnabledOverrideTakesPrecedenceOverLevel(t *testing.T) {
|
|
// Even when OptThinkingLevel requests non-off reasoning, an explicit
|
|
// provider-level thinking_enabled=false must still win.
|
|
falseVal := false
|
|
p := NewOpenAIProvider("ollama", "", "http://localhost:11434/v1", "").
|
|
WithThinkingEnabled(&falseVal)
|
|
req := ChatRequest{
|
|
Messages: []Message{{Role: "user", Content: "reason about this"}},
|
|
Options: map[string]any{OptThinkingLevel: "high"},
|
|
}
|
|
body := p.buildRequestBody("qwq:32b", req, false)
|
|
if think, ok := body["think"]; !ok || think != false {
|
|
t.Fatalf("expected think=false when provider override is false, got %v (present=%v)", think, ok)
|
|
}
|
|
}
|