Files
goclaw/internal/providers/dashscope_cache_middleware_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

208 lines
7.2 KiB
Go

package providers
import (
"encoding/json"
"reflect"
"testing"
)
func TestWrapSystemForDashScopeCache_NoBoundary(t *testing.T) {
msg := map[string]any{
"role": "system",
"content": "You are a helpful assistant.",
}
out := wrapSystemForDashScopeCache(msg)
blocks, ok := out["content"].([]map[string]any)
if !ok {
t.Fatalf("expected []map content, got %T", out["content"])
}
if len(blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(blocks))
}
if blocks[0]["cache_control"] == nil {
t.Error("block[0] missing cache_control")
}
}
func TestWrapSystemForDashScopeCache_WithBoundary(t *testing.T) {
msg := map[string]any{
"role": "system",
"content": "Stable prefix\n" + CacheBoundaryMarker + "\nDynamic suffix",
}
out := wrapSystemForDashScopeCache(msg)
blocks := out["content"].([]map[string]any)
if len(blocks) != 2 {
t.Fatalf("expected 2 blocks, got %d", len(blocks))
}
if blocks[0]["cache_control"] == nil {
t.Error("stable block missing cache_control")
}
if blocks[1]["cache_control"] != nil {
t.Error("dynamic block should not have cache_control")
}
}
func TestWrapSystemForDashScopeCache_NonSystemUntouched(t *testing.T) {
msg := map[string]any{
"role": "user",
"content": "Hello",
}
out := wrapSystemForDashScopeCache(msg)
if !reflect.DeepEqual(out, msg) {
t.Error("user message should pass through unchanged")
}
}
func TestWrapSystemForDashScopeCache_NonStringContentUntouched(t *testing.T) {
blocks := []map[string]any{{"type": "text", "text": "x", "cache_control": map[string]any{"type": "ephemeral"}}}
msg := map[string]any{"role": "system", "content": blocks}
out := wrapSystemForDashScopeCache(msg)
got, _ := json.Marshal(out["content"])
want, _ := json.Marshal(blocks)
if string(got) != string(want) {
t.Errorf("idempotent fail: got %s want %s", got, want)
}
}
func TestBuildRequestBody_DashScopeEndpoint_WrapsSystem(t *testing.T) {
p := NewOpenAIProvider("test", "key", "https://coding-intl.dashscope.aliyuncs.com/v1", "qwen3.6-plus")
req := ChatRequest{
Messages: []Message{
{Role: "system", Content: "You are an assistant."},
{Role: "user", Content: "Hi"},
},
}
body := p.buildRequestBody("qwen3.6-plus", req, false)
msgs := body["messages"].([]map[string]any)
sysContent := msgs[0]["content"]
if _, ok := sysContent.([]map[string]any); !ok {
t.Fatalf("expected DashScope system content as []block, got %T", sysContent)
}
}
func TestBuildRequestBody_OpenAINative_DoesNotWrap(t *testing.T) {
p := NewOpenAIProvider("test", "key", "https://api.openai.com/v1", "gpt-4o")
req := ChatRequest{Messages: []Message{{Role: "system", Content: "..."}, {Role: "user", Content: "Hi"}}}
body := p.buildRequestBody("gpt-4o", req, false)
msgs := body["messages"].([]map[string]any)
if _, ok := msgs[0]["content"].(string); !ok {
t.Errorf("OpenAI native should keep string content, got %T", msgs[0]["content"])
}
}
func TestApplyDashScopeToolPrefixCache_AddMarkerOnLast(t *testing.T) {
tools := []map[string]any{
{"type": "function", "function": map[string]any{"name": "tool_a"}},
{"type": "function", "function": map[string]any{"name": "tool_b"}},
{"type": "function", "function": map[string]any{"name": "tool_c"}},
}
out := applyDashScopeToolPrefixCache(tools, 1)
if len(out) != 3 {
t.Fatalf("len changed: got %d", len(out))
}
if out[0]["cache_control"] != nil || out[1]["cache_control"] != nil {
t.Error("non-last tools should not have cache_control")
}
if out[2]["cache_control"] == nil {
t.Error("last tool missing cache_control")
}
}
func TestApplyDashScopeToolPrefixCache_EmptyArray(t *testing.T) {
out := applyDashScopeToolPrefixCache([]map[string]any{}, 0)
if len(out) != 0 {
t.Errorf("expected empty, got %d", len(out))
}
}
func TestApplyDashScopeToolPrefixCache_RespectsMarkerLimit(t *testing.T) {
tools := []map[string]any{{"type": "function", "function": map[string]any{"name": "x"}}}
out := applyDashScopeToolPrefixCache(tools, 4)
if out[0]["cache_control"] != nil {
t.Error("should skip tool marker when limit reached")
}
}
func TestCountCacheControlMarkers(t *testing.T) {
msg := map[string]any{
"role": "system",
"content": []map[string]any{
{"type": "text", "text": "x", "cache_control": map[string]any{"type": "ephemeral"}},
{"type": "text", "text": "y"},
},
}
if got := countCacheControlMarkers(msg); got != 1 {
t.Errorf("got %d, want 1", got)
}
}
func TestBuildRequestBody_DashScopeWithTools_AppliesToolCache(t *testing.T) {
p := NewOpenAIProvider("test", "key", "https://coding-intl.dashscope.aliyuncs.com/v1", "qwen3.6-plus")
req := ChatRequest{
Messages: []Message{{Role: "system", Content: "..."}, {Role: "user", Content: "Hi"}},
Tools: []ToolDefinition{
{Type: "function", Function: &ToolFunctionSchema{Name: "search", Description: "search docs", Parameters: map[string]any{}}},
{Type: "function", Function: &ToolFunctionSchema{Name: "fetch", Description: "fetch url", Parameters: map[string]any{}}},
},
}
body := p.buildRequestBody("qwen3.6-plus", req, false)
tools := body["tools"].([]map[string]any)
if tools[0]["cache_control"] != nil {
t.Error("first tool should not have cache_control")
}
if tools[len(tools)-1]["cache_control"] == nil {
t.Error("last tool should have cache_control")
}
}
func TestBuildRequestBody_OpenAINativeWithTools_NoToolCache(t *testing.T) {
p := NewOpenAIProvider("test", "key", "https://api.openai.com/v1", "gpt-4o")
req := ChatRequest{
Messages: []Message{{Role: "system", Content: "..."}, {Role: "user", Content: "Hi"}},
Tools: []ToolDefinition{
{Type: "function", Function: &ToolFunctionSchema{Name: "x", Description: "y", Parameters: map[string]any{}}},
},
}
body := p.buildRequestBody("gpt-4o", req, false)
tools, ok := body["tools"].([]map[string]any)
if ok && len(tools) > 0 && tools[0]["cache_control"] != nil {
t.Error("OpenAI native should not have tool cache_control")
}
}
func TestBuildRequestBody_DashScopeWithEnvDisable_DoesNotWrap(t *testing.T) {
t.Setenv("GOCLAW_DISABLE_DASHSCOPE_CACHE", "true")
p := NewOpenAIProvider("test", "key", "https://coding-intl.dashscope.aliyuncs.com/v1", "qwen3.6-plus")
req := ChatRequest{Messages: []Message{{Role: "system", Content: "..."}, {Role: "user", Content: "Hi"}}}
body := p.buildRequestBody("qwen3.6-plus", req, false)
msgs := body["messages"].([]map[string]any)
if _, ok := msgs[0]["content"].(string); !ok {
t.Errorf("env disable should keep string content, got %T", msgs[0]["content"])
}
}
func TestBuildRequestBody_BailianThinkingLevelMapsToDashScopeKeys(t *testing.T) {
p := NewOpenAIProvider("qwen-richard", "key", "https://proxy.internal/v1", "qwen3.6-plus").
WithProviderType("bailian")
req := ChatRequest{
Model: "qwen3.6-plus",
Messages: []Message{
{Role: "system", Content: "You are an assistant."},
{Role: "user", Content: "Hi"},
},
Options: map[string]any{OptThinkingLevel: "medium"},
}
body := p.buildRequestBody("qwen3.6-plus", req, false)
if body[OptEnableThinking] != true {
t.Fatalf("enable_thinking = %v, want true", body[OptEnableThinking])
}
if body[OptThinkingBudget] == nil {
t.Fatal("thinking_budget missing for Bailian Qwen thinking model")
}
if _, has := body[OptReasoningEffort]; has {
t.Fatal("Bailian must not receive OpenAI reasoning_effort")
}
}