Files
goclaw/internal/providers/dashscope_test.go
T
Plateau Nguyen 0e994f959c feat(providers): explicit prompt cache for DashScope/Qwen (#1127)
* feat(providers): explicit prompt cache for DashScope/Qwen

Extend OpenAI-compat path with Anthropic-style cache_control:ephemeral
inline blocks for Alibaba DashScope endpoints (provider_type=bailian or
URL contains "dashscope"). Verified live: qwen3.6-plus / qwen3.5-plus /
qwen3-coder-plus all return 99.7% cache hit rate on 6K-token prefix,
yielding ~89% token cost reduction on cached prefix (90% Alibaba discount).

- isDashScope(): 3-source detection (URL + providerType + name) handles
  reverse-proxied endpoints; covers both "dashscope" and "bailian"
- buildRequestBody wraps system content with SplitSystemPromptForCache
  (exported from anthropic_request.go) using <!-- GOCLAW_CACHE_BOUNDARY -->
- Tool prefix cache: cache_control on last tool definition, with 4-marker
  budget guard
- Parse cache_creation_input_tokens from prompt_tokens_details into
  Usage.CacheCreationTokens; propagates via existing span metadata writers
- Runtime escape hatch: GOCLAW_DISABLE_DASHSCOPE_CACHE=true
- Live integration smoke test (build tag integration, env-gated)

* chore(providers): polish per PR #1127 review

- Use strings.Repeat instead of custom repeat() helper in smoke test
- Clarify BuildRequestBodyForTest is test-only, not public API

* feat(providers): enable thinking for Qwen 3.7/3.6, observe cache in smoke test

Qwen3.7-plus and qwen3.6-plus support deep thinking but were missing from
dashscopeThinkingModels, so enable_thinking/thinking_budget was silently
skipped. Add both to the whitelist and test.

Add qwen3.7-plus to the cache smoke test as cache-optional: Alibaba's
context-cache doc does not yet list 3.7/3.6 for explicit cache, and the
cache wrap is a safe no-op when unsupported, so a no-cache result is logged
rather than failed to avoid a flaky live assertion.

Claude-Session: https://claude.ai/code/session_01X3jkrc7N3ar8ZGzUJWyNS5

* fix(providers): preserve DashScope detection for proxy routes
2026-06-19 10:59:02 +07:00

149 lines
4.9 KiB
Go

package providers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
// newDashScopeTestServer sets up a mock SSE server for ChatStream calls and
// returns both the server and a pointer that will hold the last captured request body.
func newDashScopeTestServer(t *testing.T) (*httptest.Server, *map[string]any) {
t.Helper()
captured := &map[string]any{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(captured); err != nil {
t.Errorf("failed to decode request body: %v", err)
}
// stream=false path (Chat): return JSON
if v, _ := (*captured)["stream"].(bool); !v {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)
return
}
// stream=true path (ChatStream): return SSE
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n")
fmt.Fprint(w, "data: [DONE]\n\n")
}))
t.Cleanup(server.Close)
return server, captured
}
// callDashScopeStream sends req through ChatStream and returns the captured body.
func callDashScopeStream(t *testing.T, req ChatRequest) map[string]any {
t.Helper()
server, captured := newDashScopeTestServer(t)
p := NewDashScopeProvider("dashscope-test", "test-key", server.URL, "")
p.retryConfig.Attempts = 1
p.ChatStream(context.Background(), req, nil) //nolint:errcheck
return *captured
}
// TestDashScopeModelSupportsThinking verifies the whitelist is correct.
func TestDashScopeModelSupportsThinking(t *testing.T) {
p := NewDashScopeProvider("dashscope", "key", "", "")
tests := []struct {
model string
want bool
}{
{"qwen3.7-plus", true},
{"qwen3.6-plus", true},
{"qwen3.5-plus", true},
{"qwen3.5-turbo", true},
{"qwen3-max", true},
{"qwen3-235b-a22b", true},
{"qwen3-32b", true},
{"qwen3-14b", true},
{"qwen3-8b", true},
// Models that do NOT support thinking:
{"qwen3-plus", false},
{"qwen3-turbo", false},
{"qwen2-72b-instruct", false},
}
for _, tt := range tests {
got := p.ModelSupportsThinking(tt.model)
if got != tt.want {
t.Errorf("ModelSupportsThinking(%q) = %v, want %v", tt.model, got, tt.want)
}
}
}
// TestDashScopeThinkingInjected_WhenModelSupports verifies enable_thinking IS sent
// for a model on the whitelist (qwen3.5-plus) when thinking_level is set.
// ChatStream is used because that's the path where thinking params are injected.
func TestDashScopeThinkingInjected_WhenModelSupports(t *testing.T) {
body := callDashScopeStream(t, ChatRequest{
Model: "qwen3.5-plus",
Messages: []Message{{Role: "user", Content: "hi"}},
Options: map[string]any{OptThinkingLevel: "medium"},
})
if body["enable_thinking"] != true {
t.Errorf("enable_thinking = %v, want true for qwen3.5-plus with thinking_level=medium", body["enable_thinking"])
}
budget, _ := body["thinking_budget"].(float64)
if budget <= 0 {
t.Errorf("thinking_budget = %v, want > 0", body["thinking_budget"])
}
if _, hasThinkingLevel := body["thinking_level"]; hasThinkingLevel {
t.Error("thinking_level should be removed from body (converted to enable_thinking)")
}
}
// TestDashScopeThinkingNotInjected_WhenModelDoesNotSupport verifies enable_thinking
// is NOT sent for qwen3-plus (not on whitelist), even when thinking_level is set.
// This is the root cause fix: previously this caused "model 'qwen3-plus' is not supported".
func TestDashScopeThinkingNotInjected_WhenModelDoesNotSupport(t *testing.T) {
body := callDashScopeStream(t, ChatRequest{
Model: "qwen3-plus",
Messages: []Message{{Role: "user", Content: "hi"}},
Options: map[string]any{OptThinkingLevel: "medium"},
})
if _, has := body["enable_thinking"]; has {
t.Errorf("enable_thinking should NOT be sent for qwen3-plus, got: %v", body["enable_thinking"])
}
if _, has := body["thinking_budget"]; has {
t.Errorf("thinking_budget should NOT be sent for qwen3-plus, got: %v", body["thinking_budget"])
}
}
// TestDashScopeNoThinkingLevel verifies that without thinking_level,
// enable_thinking is never injected regardless of model.
func TestDashScopeNoThinkingLevel(t *testing.T) {
body := callDashScopeStream(t, ChatRequest{
Model: "qwen3-max",
Messages: []Message{{Role: "user", Content: "hi"}},
Options: map[string]any{OptMaxTokens: 1},
})
if _, has := body["enable_thinking"]; has {
t.Error("enable_thinking should NOT be sent without thinking_level")
}
}
// TestDashScopeThinkingBudgetValues verifies thinking_budget level mapping.
func TestDashScopeThinkingBudgetValues(t *testing.T) {
tests := []struct {
level string
want int
}{
{"low", 4096},
{"medium", 16384},
{"high", 32768},
{"unknown", 16384}, // default
}
for _, tt := range tests {
got := dashscopeThinkingBudget(tt.level)
if got != tt.want {
t.Errorf("dashscopeThinkingBudget(%q) = %d, want %d", tt.level, got, tt.want)
}
}
}