From 1631b19f840a1ca5bc7fdf435fbcec20e8477041 Mon Sep 17 00:00:00 2001 From: "jy.liu" Date: Mon, 27 Jul 2026 19:28:45 +0800 Subject: [PATCH] fix: preserve system cache breakpoint in OAuth mimic --- ...teway_anthropic_apikey_passthrough_test.go | 28 +++++---- .../service/gateway_claude_oauth_body.go | 57 +++++++++++++------ .../internal/service/gateway_prompt_test.go | 28 +++++++++ 3 files changed, 86 insertions(+), 27 deletions(-) diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 3722ec5189..902a7b0f91 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -798,11 +798,12 @@ func TestGatewayService_AnthropicOAuthMimic_RewritesSystemWithBillingBlock(t *te gin.SetMode(gin.TestMode) tests := []struct { - name string - body string - wantModel string - wantOriginalSystem string - wantMetadataUserID string + name string + body string + wantModel string + wantOriginalSystem string + wantOriginalSystemCacheTTL string + wantMetadataUserID string }{ { name: "sonnet system array", @@ -817,11 +818,12 @@ func TestGatewayService_AnthropicOAuthMimic_RewritesSystemWithBillingBlock(t *te wantOriginalSystem: "x-anthropic-billing-header keep", }, { - name: "haiku full mimicry", - body: `{"model":"claude-haiku-4-5","metadata":{"user_id":"pi-session-metadata"},"system":[{"type":"text","text":"Pi project instructions","cache_control":{"type":"ephemeral"}}],"thinking":{"type":"enabled","budget_tokens":1024},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, - wantModel: "claude-haiku-4-5-20251001", - wantOriginalSystem: "Pi project instructions", - wantMetadataUserID: "pi-session-metadata", + name: "haiku full mimicry", + body: `{"model":"claude-haiku-4-5","metadata":{"user_id":"pi-session-metadata"},"system":[{"type":"text","text":"Pi project instructions","cache_control":{"type":"ephemeral","ttl":"1h"}}],"thinking":{"type":"enabled","budget_tokens":1024},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, + wantModel: "claude-haiku-4-5-20251001", + wantOriginalSystem: "Pi project instructions", + wantOriginalSystemCacheTTL: "1h", + wantMetadataUserID: "pi-session-metadata", }, } @@ -912,6 +914,12 @@ func TestGatewayService_AnthropicOAuthMimic_RewritesSystemWithBillingBlock(t *te firstMsg := messages.Array()[0] require.Equal(t, "user", firstMsg.Get("role").String()) require.Contains(t, firstMsg.Get("content.0.text").String(), tt.wantOriginalSystem) + if tt.wantOriginalSystemCacheTTL != "" { + require.Equal(t, "ephemeral", firstMsg.Get("content.0.cache_control.type").String()) + require.Equal(t, tt.wantOriginalSystemCacheTTL, firstMsg.Get("content.0.cache_control.ttl").String()) + } else { + require.False(t, firstMsg.Get("content.0.cache_control").Exists()) + } if tt.wantMetadataUserID != "" { require.Equal(t, tt.wantMetadataUserID, gjson.GetBytes(upstream.lastBody, "metadata.user_id").String()) diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index 9450ddbb11..472968f523 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -854,26 +854,42 @@ func ValidateClaudeOAuthSystemPromptBlocksConfig(raw string) error { return nil } +func extractSystemTextAndCacheControl(system any) (string, any) { + switch v := system.(type) { + case string: + return strings.TrimSpace(v), nil + case []any: + var parts []string + var cacheControl any + for _, item := range v { + m, ok := item.(map[string]any) + if !ok { + continue + } + text, ok := m["text"].(string) + if !ok || strings.TrimSpace(text) == "" { + continue + } + parts = append(parts, text) + // system blocks are collapsed into one messages text block below. + // Preserve the last original breakpoint as the closest equivalent + // boundary, including its client-selected TTL. + if cc, exists := m["cache_control"]; exists && cc != nil { + cacheControl = cc + } + } + return strings.Join(parts, "\n\n"), cacheControl + default: + return "", nil + } +} + func rewriteSystemForNonClaudeCodeWithPromptBlocks(body []byte, system any, expansionPrompt string, blocksConfig string) []byte { system = normalizeSystemParam(system) expansionPrompt = defaultClaudeOAuthExpansionPrompt(expansionPrompt) - // 1. 提取原始 system prompt 文本 - var originalSystemText string - switch v := system.(type) { - case string: - originalSystemText = strings.TrimSpace(v) - case []any: - var parts []string - for _, item := range v { - if m, ok := item.(map[string]any); ok { - if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" { - parts = append(parts, text) - } - } - } - originalSystemText = strings.Join(parts, "\n\n") - } + // 1. 提取原始 system prompt 文本及其缓存断点 + originalSystemText, originalSystemCacheControl := extractSystemTextAndCacheControl(system) // 2. 构造 system 数组,对齐真实 Claude Code CLI 的 3-block 形态: // [0] billing attribution block(cc_version={cliVer}.{fp}; cc_entrypoint=cli;) @@ -906,10 +922,17 @@ func rewriteSystemForNonClaudeCodeWithPromptBlocks(body []byte, system any, expa // 模型仍通过 messages 接收完整指令,保留客户端功能 ccPromptTrimmed := strings.TrimSpace(claudeCodeSystemPrompt) if originalSystemText != "" && originalSystemText != ccPromptTrimmed && !hasClaudeCodePrefix(originalSystemText) { + instructionBlock := map[string]any{ + "type": "text", + "text": "[System Instructions]\n" + originalSystemText, + } + if originalSystemCacheControl != nil { + instructionBlock["cache_control"] = originalSystemCacheControl + } instrMsg, err1 := json.Marshal(map[string]any{ "role": "user", "content": []map[string]any{ - {"type": "text", "text": "[System Instructions]\n" + originalSystemText}, + instructionBlock, }, }) ackMsg, err2 := json.Marshal(map[string]any{ diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index bbdb8a1458..431618815a 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -482,6 +482,34 @@ func TestRewriteSystemForNonClaudeCodeWithPrompt_UsesCustomExpansionPrompt(t *te require.Equal(t, "ephemeral", system.Array()[2].Get("cache_control.type").String()) } +func TestRewriteSystemForNonClaudeCode_PreservesSystemCacheControlOnMigratedMessage(t *testing.T) { + body := []byte(`{"model":"claude-3","system":[{"type":"text","text":"Stable project instructions","cache_control":{"type":"ephemeral","ttl":"1h"}}],"messages":[{"role":"user","content":"hello"}]}`) + system := []any{ + map[string]any{ + "type": "text", + "text": "Stable project instructions", + "cache_control": map[string]any{"type": "ephemeral", "ttl": "1h"}, + }, + } + + result := rewriteSystemForNonClaudeCode(body, system) + + require.Equal(t, "[System Instructions]\nStable project instructions", gjson.GetBytes(result, "messages.0.content.0.text").String()) + require.Equal(t, "ephemeral", gjson.GetBytes(result, "messages.0.content.0.cache_control.type").String()) + require.Equal(t, "1h", gjson.GetBytes(result, "messages.0.content.0.cache_control.ttl").String()) +} + +func TestRewriteSystemForNonClaudeCode_LeavesMigratedMessageUncachedWithoutSystemBreakpoint(t *testing.T) { + body := []byte(`{"model":"claude-3","system":[{"type":"text","text":"Project instructions"}],"messages":[{"role":"user","content":"hello"}]}`) + system := []any{ + map[string]any{"type": "text", "text": "Project instructions"}, + } + + result := rewriteSystemForNonClaudeCode(body, system) + + require.False(t, gjson.GetBytes(result, "messages.0.content.0.cache_control").Exists()) +} + func TestRewriteSystemForNonClaudeCodeWithPromptBlocks_UsesConfiguredBlocks(t *testing.T) { body := []byte(`{"model":"claude-3","system":"Project instructions","messages":[{"role":"user","content":"hello"}]}`) blocks := `{