From a25faecaddf5150f88adbdf642d7ff935bd99167 Mon Sep 17 00:00:00 2001 From: keh4l <2461454684@qq.com> Date: Fri, 24 Apr 2026 20:47:12 +0800 Subject: [PATCH] feat(gateway): align body shape with real Claude Code CLI defaults Three field-level alignments in normalizeClaudeOAuthRequestBody to match real Claude Code CLI traffic byte-for-byte: 1. temperature: previously deleted unconditionally; now passes through client value, defaults to 1 when absent (real CLI always sends temperature, default 1). 2. max_tokens: defaults to 128000 when absent (real CLI default). 3. context_management: when thinking.type is enabled/adaptive and the client did not provide context_management, inject {"edits":[{"type":"clear_thinking_20251015","keep":"all"}]} to mirror real CLI behavior. tool_choice removal is unchanged (Claude Code OAuth credentials do not allow client-supplied tool_choice). Tests updated: - gateway_body_order_test.go: temperature/max_tokens are now expected in output; tool_choice still removed. - gateway_prompt_test.go: system array is now 2 blocks (billing + cc prompt), assertions adjusted. - gateway_anthropic_apikey_passthrough_test.go: same 2-block assertion. --- ...teway_anthropic_apikey_passthrough_test.go | 10 +++++-- .../service/gateway_body_order_test.go | 5 ++-- .../internal/service/gateway_prompt_test.go | 19 +++++++++--- backend/internal/service/gateway_service.go | 30 +++++++++++++++++-- 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 5be1f733..428231ee 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -762,8 +762,14 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( system := gjson.GetBytes(upstream.lastBody, "system") require.True(t, system.Exists()) require.True(t, system.IsArray(), "system should be an array") - require.Equal(t, claudeCodeSystemPrompt, system.Array()[0].Get("text").String()) - require.Equal(t, "ephemeral", system.Array()[0].Get("cache_control.type").String()) + arr := system.Array() + require.Len(t, arr, 2, "system array should have billing block + cc prompt block") + + require.Contains(t, arr[0].Get("text").String(), "x-anthropic-billing-header:") + require.Contains(t, arr[0].Get("text").String(), "cc_version=") + + require.Equal(t, claudeCodeSystemPrompt, arr[1].Get("text").String()) + require.Equal(t, "ephemeral", arr[1].Get("cache_control.type").String()) // 原始 system prompt 应迁移至 messages 中 messages := gjson.GetBytes(upstream.lastBody, "messages") diff --git a/backend/internal/service/gateway_body_order_test.go b/backend/internal/service/gateway_body_order_test.go index 641522f0..e6c9de7d 100644 --- a/backend/internal/service/gateway_body_order_test.go +++ b/backend/internal/service/gateway_body_order_test.go @@ -41,12 +41,13 @@ func TestNormalizeClaudeOAuthRequestBody_PreservesTopLevelFieldOrder(t *testing. resultStr := string(result) require.Equal(t, claude.NormalizeModelID("claude-3-5-sonnet-latest"), modelID) - assertJSONTokenOrder(t, resultStr, `"alpha"`, `"model"`, `"system"`, `"messages"`, `"omega"`, `"tools"`, `"metadata"`) - require.NotContains(t, resultStr, `"temperature"`) + assertJSONTokenOrder(t, resultStr, `"alpha"`, `"model"`, `"temperature"`, `"system"`, `"messages"`, `"omega"`, `"tools"`, `"metadata"`, `"max_tokens"`) + require.Contains(t, resultStr, `"temperature":0.2`) require.NotContains(t, resultStr, `"tool_choice"`) require.Contains(t, resultStr, `"system":"`+claudeCodeSystemPrompt+`"`) require.Contains(t, resultStr, `"tools":[]`) require.Contains(t, resultStr, `"metadata":{"user_id":"user-1"}`) + require.Contains(t, resultStr, `"max_tokens":128000`) } func TestInjectClaudeCodePrompt_PreservesFieldOrder(t *testing.T) { diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index e27e18aa..443486ab 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -378,16 +378,27 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { err := json.Unmarshal(result, &parsed) require.NoError(t, err) - // system 应为 array 格式: [{type: "text", text: "...", cache_control: {type: "ephemeral"}}] + // system 应为 array 格式,对齐真实 Claude Code CLI 的 2-block 形态: + // [0] billing attribution block (x-anthropic-billing-header: cc_version=...;) + // [1] Claude Code prompt block (带 cache_control) systemArr, ok := parsed["system"].([]any) require.True(t, ok, "system should be an array, got %T", parsed["system"]) - require.Len(t, systemArr, 1, "system array should have exactly 1 block") - systemBlock, ok := systemArr[0].(map[string]any) + require.Len(t, systemArr, 2, "system array should have exactly 2 blocks (billing + cc prompt)") + + billingBlock, ok := systemArr[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "text", billingBlock["type"]) + require.Contains(t, billingBlock["text"], "x-anthropic-billing-header:") + require.Contains(t, billingBlock["text"], "cc_version=") + require.Contains(t, billingBlock["text"], "cc_entrypoint=cli") + require.Contains(t, billingBlock["text"], "cch=00000") + + systemBlock, ok := systemArr[1].(map[string]any) require.True(t, ok) require.Equal(t, "text", systemBlock["type"]) require.Equal(t, tt.wantSystemText, systemBlock["text"]) cc, ok := systemBlock["cache_control"].(map[string]any) - require.True(t, ok, "system block should have cache_control") + require.True(t, ok, "cc prompt block should have cache_control") require.Equal(t, "ephemeral", cc["type"]) // 检查 messages diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index ce9967de..c5c196a0 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1078,12 +1078,38 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu } } - if gjson.GetBytes(out, "temperature").Exists() { - if next, ok := deleteJSONPathBytes(out, "temperature"); ok { + // temperature:真实 Claude Code CLI 总是发送 temperature(默认 1,客户端可覆盖)。 + // 之前的实现直接 delete 会导致 payload 缺字段,与真实 CLI 字节级不一致。 + // 策略:客户端传了什么就透传;没传则补默认 1。 + if !gjson.GetBytes(out, "temperature").Exists() { + if next, ok := setJSONValueBytes(out, "temperature", 1); ok { out = next modified = true } } + + // max_tokens:真实 CLI 的默认值是 128000。缺失时补齐以对齐指纹。 + if !gjson.GetBytes(out, "max_tokens").Exists() { + if next, ok := setJSONValueBytes(out, "max_tokens", 128000); ok { + out = next + modified = true + } + } + + // context_management:thinking.type 为 enabled/adaptive 时,真实 CLI 会自动 + // 附带 {"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}。 + // 客户端显式传了就透传;否则按 CLI 行为补齐。 + if !gjson.GetBytes(out, "context_management").Exists() { + thinkingType := gjson.GetBytes(out, "thinking.type").String() + if thinkingType == "enabled" || thinkingType == "adaptive" { + const cmDefault = `{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}` + if next, ok := setJSONRawBytes(out, "context_management", []byte(cmDefault)); ok { + out = next + modified = true + } + } + } + if gjson.GetBytes(out, "tool_choice").Exists() { if next, ok := deleteJSONPathBytes(out, "tool_choice"); ok { out = next