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
This commit is contained in:
Plateau Nguyen authored and GitHub committed 2026-06-19 10:59:02 +07:00
1 parent c70324f032
commit 0e994f959c
18 files changed
+689 -22

No files matched your search

+2 -1
View File
@@ -307,7 +307,8 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) providerRu
if base == "" {
base = "https://coding-intl.dashscope.aliyuncs.com/v1"
}
h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus"))
h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus").
WithProviderType(p.ProviderType))
case store.ProviderNovita:
base := apiBase
if base == "" {
+68
View File
@@ -201,6 +201,74 @@ func TestProvidersHandlerRegisterInMemoryAnthropicUsesModelRegistry(t *testing.T
}
}
func TestProvidersHandlerRegisterInMemoryDashScopeProxyKeepsCacheDetection(t *testing.T) {
providerReg := providers.NewRegistry(nil)
handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "")
provider := &store.LLMProviderData{
BaseModel: store.BaseModel{ID: uuid.New()},
TenantID: uuid.New(),
Name: "qwen-richard",
ProviderType: store.ProviderDashScope,
APIBase: "https://proxy.internal/v1",
APIKey: "sk-test",
Enabled: true,
}
handler.registerInMemory(provider)
got, err := providerReg.GetForTenant(provider.TenantID, provider.Name)
if err != nil {
t.Fatalf("GetForTenant() error = %v", err)
}
assertRuntimeProviderWrapsDashScopeSystem(t, got, "qwen3.6-plus")
}
func TestProvidersHandlerRegisterInMemoryBailianProxyKeepsCacheDetection(t *testing.T) {
providerReg := providers.NewRegistry(nil)
handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "")
provider := &store.LLMProviderData{
BaseModel: store.BaseModel{ID: uuid.New()},
TenantID: uuid.New(),
Name: "qwen-richard",
ProviderType: store.ProviderBailian,
APIBase: "https://proxy.internal/v1",
APIKey: "sk-test",
Enabled: true,
}
handler.registerInMemory(provider)
got, err := providerReg.GetForTenant(provider.TenantID, provider.Name)
if err != nil {
t.Fatalf("GetForTenant() error = %v", err)
}
assertRuntimeProviderWrapsDashScopeSystem(t, got, "qwen3.6-plus")
}
func assertRuntimeProviderWrapsDashScopeSystem(t *testing.T, runtimeProvider providers.Provider, model string) {
t.Helper()
bodyBuilder, ok := runtimeProvider.(interface {
BuildRequestBodyForTest(string, providers.ChatRequest, bool) map[string]any
})
if !ok {
t.Fatalf("runtime provider = %T, want BuildRequestBodyForTest", runtimeProvider)
}
body := bodyBuilder.BuildRequestBodyForTest(model, providers.ChatRequest{
Messages: []providers.Message{
{Role: "system", Content: "Stable prefix\n" + providers.CacheBoundaryMarker + "\nDynamic suffix"},
{Role: "user", Content: "Hi"},
},
}, false)
msgs := body["messages"].([]map[string]any)
if _, ok := msgs[0]["content"].([]map[string]any); !ok {
t.Fatalf("system content = %T, want DashScope cache blocks", msgs[0]["content"])
}
}
// writeFakeClaudeBinary creates an executable stub in a temp dir so exec.LookPath resolves it.
// Returns the absolute path suitable for use as APIBase on a Claude CLI provider record.
func writeFakeClaudeBinary(t *testing.T) string {