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

74 lines
2.5 KiB
Go

package providers
import (
"testing"
)
// TestAnthropicSystemBlocksSplit verifies that a system prompt with
// the cache boundary marker is split into 2 blocks: stable (cached) + dynamic.
func TestAnthropicSystemBlocksSplit(t *testing.T) {
prompt := "stable content\n" + CacheBoundaryMarker + "\ndynamic content"
blocks := SplitSystemPromptForCache(prompt)
if len(blocks) != 2 {
t.Fatalf("expected 2 blocks, got %d", len(blocks))
}
if blocks[0]["text"] != "stable content" {
t.Errorf("block[0] text = %q, want %q", blocks[0]["text"], "stable content")
}
if blocks[0]["cache_control"] == nil {
t.Error("block[0] missing cache_control")
}
if blocks[1]["text"] != "dynamic content" {
t.Errorf("block[1] text = %q, want %q", blocks[1]["text"], "dynamic content")
}
if blocks[1]["cache_control"] != nil {
t.Error("block[1] should NOT have cache_control")
}
}
// TestAnthropicSingleBlockFallback verifies backward compat: no boundary
// marker → single block with cache_control.
func TestAnthropicSingleBlockFallback(t *testing.T) {
prompt := "no boundary here"
blocks := SplitSystemPromptForCache(prompt)
if len(blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(blocks))
}
if blocks[0]["text"] != "no boundary here" {
t.Errorf("block text = %q", blocks[0]["text"])
}
if blocks[0]["cache_control"] == nil {
t.Error("single block missing cache_control")
}
}
// TestAnthropicEmptyDynamic verifies that an empty dynamic section
// after the boundary produces only 1 block (no empty block appended).
func TestAnthropicEmptyDynamic(t *testing.T) {
prompt := "stable only\n" + CacheBoundaryMarker + "\n"
blocks := SplitSystemPromptForCache(prompt)
if len(blocks) != 1 {
t.Fatalf("expected 1 block for empty dynamic, got %d", len(blocks))
}
if blocks[0]["text"] != "stable only" {
t.Errorf("block text = %q", blocks[0]["text"])
}
}
// TestAnthropicEmptyStable verifies that a boundary at the very start
// (empty stable section) still produces valid blocks without empty text.
func TestAnthropicEmptyStable(t *testing.T) {
prompt := CacheBoundaryMarker + "\ndynamic only"
blocks := SplitSystemPromptForCache(prompt)
// Stable is empty string after TrimSpace — should still produce a block
// (Anthropic API handles empty text blocks gracefully).
if len(blocks) < 1 {
t.Fatal("expected at least 1 block")
}
// The dynamic block should have the actual content.
last := blocks[len(blocks)-1]
if last["text"] != "dynamic only" {
t.Errorf("dynamic block text = %q, want %q", last["text"], "dynamic only")
}
}