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.
This commit is contained in:
keh4l
2026-04-24 20:47:12 +08:00
parent 5862e2d8d9
commit a25faecadd
4 changed files with 54 additions and 10 deletions

View File

@@ -762,8 +762,14 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(
system := gjson.GetBytes(upstream.lastBody, "system") system := gjson.GetBytes(upstream.lastBody, "system")
require.True(t, system.Exists()) require.True(t, system.Exists())
require.True(t, system.IsArray(), "system should be an array") require.True(t, system.IsArray(), "system should be an array")
require.Equal(t, claudeCodeSystemPrompt, system.Array()[0].Get("text").String()) arr := system.Array()
require.Equal(t, "ephemeral", system.Array()[0].Get("cache_control.type").String()) 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 中 // 原始 system prompt 应迁移至 messages 中
messages := gjson.GetBytes(upstream.lastBody, "messages") messages := gjson.GetBytes(upstream.lastBody, "messages")

View File

@@ -41,12 +41,13 @@ func TestNormalizeClaudeOAuthRequestBody_PreservesTopLevelFieldOrder(t *testing.
resultStr := string(result) resultStr := string(result)
require.Equal(t, claude.NormalizeModelID("claude-3-5-sonnet-latest"), modelID) require.Equal(t, claude.NormalizeModelID("claude-3-5-sonnet-latest"), modelID)
assertJSONTokenOrder(t, resultStr, `"alpha"`, `"model"`, `"system"`, `"messages"`, `"omega"`, `"tools"`, `"metadata"`) assertJSONTokenOrder(t, resultStr, `"alpha"`, `"model"`, `"temperature"`, `"system"`, `"messages"`, `"omega"`, `"tools"`, `"metadata"`, `"max_tokens"`)
require.NotContains(t, resultStr, `"temperature"`) require.Contains(t, resultStr, `"temperature":0.2`)
require.NotContains(t, resultStr, `"tool_choice"`) require.NotContains(t, resultStr, `"tool_choice"`)
require.Contains(t, resultStr, `"system":"`+claudeCodeSystemPrompt+`"`) require.Contains(t, resultStr, `"system":"`+claudeCodeSystemPrompt+`"`)
require.Contains(t, resultStr, `"tools":[]`) require.Contains(t, resultStr, `"tools":[]`)
require.Contains(t, resultStr, `"metadata":{"user_id":"user-1"}`) require.Contains(t, resultStr, `"metadata":{"user_id":"user-1"}`)
require.Contains(t, resultStr, `"max_tokens":128000`)
} }
func TestInjectClaudeCodePrompt_PreservesFieldOrder(t *testing.T) { func TestInjectClaudeCodePrompt_PreservesFieldOrder(t *testing.T) {

View File

@@ -378,16 +378,27 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) {
err := json.Unmarshal(result, &parsed) err := json.Unmarshal(result, &parsed)
require.NoError(t, err) 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) systemArr, ok := parsed["system"].([]any)
require.True(t, ok, "system should be an array, got %T", parsed["system"]) 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") require.Len(t, systemArr, 2, "system array should have exactly 2 blocks (billing + cc prompt)")
systemBlock, ok := systemArr[0].(map[string]any)
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.True(t, ok)
require.Equal(t, "text", systemBlock["type"]) require.Equal(t, "text", systemBlock["type"])
require.Equal(t, tt.wantSystemText, systemBlock["text"]) require.Equal(t, tt.wantSystemText, systemBlock["text"])
cc, ok := systemBlock["cache_control"].(map[string]any) 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"]) require.Equal(t, "ephemeral", cc["type"])
// 检查 messages // 检查 messages

View File

@@ -1078,12 +1078,38 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu
} }
} }
if gjson.GetBytes(out, "temperature").Exists() { // temperature真实 Claude Code CLI 总是发送 temperature默认 1客户端可覆盖
if next, ok := deleteJSONPathBytes(out, "temperature"); ok { // 之前的实现直接 delete 会导致 payload 缺字段,与真实 CLI 字节级不一致。
// 策略:客户端传了什么就透传;没传则补默认 1。
if !gjson.GetBytes(out, "temperature").Exists() {
if next, ok := setJSONValueBytes(out, "temperature", 1); ok {
out = next out = next
modified = true 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_managementthinking.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 gjson.GetBytes(out, "tool_choice").Exists() {
if next, ok := deleteJSONPathBytes(out, "tool_choice"); ok { if next, ok := deleteJSONPathBytes(out, "tool_choice"); ok {
out = next out = next