mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 10:13:16 +00:00
* 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
208 lines
7.2 KiB
Go
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")
|
|
}
|
|
}
|