Merge pull request #6995 from alfadb/fix/claude-mid-conversation-output-config

fix(claude): 修复对话中 output_config 的 beta 兼容问题
This commit is contained in:
Wesley Liddick
2026-09-12 19:27:21 +08:00
committed by GitHub
4 changed files with 453 additions and 6 deletions
+8 -6
View File
@@ -20,12 +20,13 @@ const (
BetaFastMode = "fast-mode-2026-02-01"
// 新增(对齐官方 CLI 2.1.9x 以来的流量)
BetaPromptCachingScope = "prompt-caching-scope-2026-01-05"
BetaEffort = "effort-2025-11-24"
BetaRedactThinking = "redact-thinking-2026-02-12"
BetaContextManagement = "context-management-2025-06-27"
BetaThinkingBindingControls = "thinking-binding-controls-2026-08-01"
BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11"
BetaPromptCachingScope = "prompt-caching-scope-2026-01-05"
BetaEffort = "effort-2025-11-24"
BetaRedactThinking = "redact-thinking-2026-02-12"
BetaContextManagement = "context-management-2025-06-27"
BetaThinkingBindingControls = "thinking-binding-controls-2026-08-01"
BetaMidConversationOutputConfig = "mid-conversation-output-config-2026-07-01"
BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11"
// server-side refusal fallback beta 字段族(beta Messages API 专有)。
// 客户端(Claude Code / SDK / OpenCode 等)会默认透传 body.fallbacks /
@@ -101,6 +102,7 @@ func FullClaudeCodeMimicryBetas() []string {
BetaEffort,
BetaContextManagement,
BetaThinkingBindingControls,
BetaMidConversationOutputConfig,
BetaExtendedCacheTTL,
}
}
@@ -186,6 +186,17 @@ func TestComputeFinalAnthropicBeta_OAuthMimic_IgnoresClientBeta(t *testing.T) {
require.True(t, ok)
require.False(t, strings.Contains(final, "custom-experimental-beta"),
"mimic 路径必须忽略客户端 anthropic-beta header")
require.True(t, anthropicBetaTokensContains(final, claude.BetaMidConversationOutputConfig),
"mimic 必须注入 mid-conversation output_config 控制所需的 beta")
// 显式 dropSet 仍能移除 mimic 注入的该 beta,且不会因此放行客户端未知 beta。
dropped, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-4-6", hdr, []byte(`{}`),
map[string]struct{}{claude.BetaMidConversationOutputConfig: {}})
require.True(t, ok)
require.False(t, anthropicBetaTokensContains(dropped, claude.BetaMidConversationOutputConfig),
"显式 dropSet 必须能移除新增的 mimic beta")
require.False(t, strings.Contains(dropped, "custom-experimental-beta"),
"dropSet 存在时 mimic 路径仍必须忽略客户端 anthropic-beta header")
}
func TestComputeFinalAnthropicBeta_OAuthTransparent_NonHaiku_PreservesClientContextManagement(t *testing.T) {
@@ -0,0 +1,318 @@
//go:build unit
package service
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// ============================================================================
// message-level output_config ↔ mid-conversation-output-config beta
// ============================================================================
//
// 背景:pi-ai(Harness 使用的 Anthropic provider)会为 opus5 生成形如
//
// {"role":"system","content":[],"output_config":{"effort":"high"}}
//
// 的空控制消息,并在 anthropic-beta 中请求
// mid-conversation-output-config-2026-07-01。OAuth mimic 会用
// FullClaudeCodeMimicryBetas 覆盖客户端 beta;若该 beta 不在固定列表内,
// body 消息级 output_config 与最终 header 不再对称 → 上游 400:
//
// "output_config: Extra inputs are not permitted"
//
// 修复策略(剥字段,不注入 beta):
// - header 缺该 token:仅为携带 message-level output_config 的消息剥此字段;
// role=system 且 content 无正文(缺失 / null / 空 string / 空 array / 仅空
// text 块)时整条删除;system 有正文保留正文与其余字段;user/assistant 只剥
// 字段,绝不整条删除。
// - header 含该 token:完全保留。
// - 无任何 message-level output_config:字节 no-op。
// - 顶层 output_config / effort 不受该 beta 约束,本增量不触碰。
//
// 本文件只覆盖 Anthropic 直连路径的该增量;header 侧固定列表(mimic betas)
// 的改动由并行增量负责。
// ============================================================================
// sanitizeAnthropicBodyForBetaTokens — message-level output_config
// ============================================================================
// ★ 主场景:缺 beta 时,多条空控制 system 消息整条删除,且用户顺序 / 顶层 effort
// 不受影响;有正文 system 与 assistant/user 只剥字段。
func TestSanitizeAnthropicBodyForBetaTokens_MidConversationOutputConfig_MultiMessage(t *testing.T) {
body := []byte(`{
"model":"claude-opus-5",
"output_config":{"effort":"high"},
"messages":[
{"role":"system","content":[],"output_config":{"effort":"high"}},
{"role":"user","content":"hello"},
{"role":"system","content":"You are helpful.","output_config":{"effort":"high"},"cache_control":{"type":"ephemeral"}},
{"role":"assistant","content":[{"type":"text","text":"hi"}],"output_config":{"effort":"high"}},
{"role":"system","content":[{"type":"text","text":""}],"output_config":{"effort":"high"}},
{"role":"system","content":null,"output_config":{"effort":"high"}},
{"role":"system","content":"","output_config":{"effort":"high"}},
{"role":"system","output_config":{"effort":"high"}}
]
}`)
// 模拟 OAuth mimic / 默认 API-key beta:无 mid-conversation-output-config beta
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "claude-code-20250219,oauth-2025-04-20")
require.True(t, changed, "存在 message-level output_config 且缺 beta → 必须发生净化")
msgs := gjson.GetBytes(out, "messages").Array()
require.Len(t, msgs, 3, "空控制 system 整条删除;仅保留 user + 有正文 system + assistant")
require.Equal(t, "user", gjson.GetBytes(out, "messages.0.role").String())
require.Equal(t, "hello", gjson.GetBytes(out, "messages.0.content").String(), "用户消息顺序不得改变")
require.Equal(t, "system", gjson.GetBytes(out, "messages.1.role").String())
require.Equal(t, "You are helpful.", gjson.GetBytes(out, "messages.1.content").String())
require.False(t, gjson.GetBytes(out, "messages.1.output_config").Exists(),
"有正文 system 只剥 output_config,不整条删除")
require.True(t, gjson.GetBytes(out, "messages.1.cache_control").Exists(),
"system 其余字段必须保留")
require.Equal(t, "assistant", gjson.GetBytes(out, "messages.2.role").String())
require.Equal(t, "hi", gjson.GetBytes(out, "messages.2.content.0.text").String())
require.False(t, gjson.GetBytes(out, "messages.2.output_config").Exists(),
"user/assistant 只剥字段,绝不整条删除")
// 顶层 output_config / effort 不属于该 beta 保护范围,必须原样保留
require.Equal(t, "high", gjson.GetBytes(out, "output_config.effort").String())
require.Equal(t, "claude-opus-5", gjson.GetBytes(out, "model").String())
}
// 表驱动:system 消息 content「无正文」的各种形态都必须整条删除。
func TestSanitizeAnthropicBodyForBetaTokens_MidConversationOutputConfig_EmptySystemVariants(t *testing.T) {
cases := []struct {
name string
msg string
}{
{"missing_content", `{"role":"system","output_config":{"effort":"high"}}`},
{"null_content", `{"role":"system","content":null,"output_config":{"effort":"high"}}`},
{"empty_string_content", `{"role":"system","content":"","output_config":{"effort":"high"}}`},
{"empty_array_content", `{"role":"system","content":[],"output_config":{"effort":"high"}}`},
{"only_empty_text_block", `{"role":"system","content":[{"type":"text","text":""}],"output_config":{"effort":"high"}}`},
{"only_empty_text_blocks", `{"role":"system","content":[{"type":"text","text":""},{"type":"text","text":""}],"output_config":{"effort":"high"}}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := []byte(`{"messages":[` + tc.msg + `,{"role":"user","content":"hi"}],"output_config":{"effort":"high"}}`)
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20")
require.True(t, changed)
msgs := gjson.GetBytes(out, "messages").Array()
require.Len(t, msgs, 1, "无正文的 system 控制消息必须整条删除")
require.Equal(t, "user", msgs[0].Get("role").String())
require.Equal(t, "hi", msgs[0].Get("content").String())
require.Equal(t, "high", gjson.GetBytes(out, "output_config.effort").String(),
"顶层 effort 不得被连带修改")
})
}
}
// 表驱动:system 有正文(含未来非 text 内容块 / 未知块)时不得整条删除,
// 只剥 message-level output_config,正文原样保留。
func TestSanitizeAnthropicBodyForBetaTokens_MidConversationOutputConfig_SystemWithBodyKept(t *testing.T) {
cases := []struct {
name string
content string
}{
{"non_empty_string", `"You are helpful."`},
{"text_block", `[{"type":"text","text":"hi"}]`},
{"text_with_empty_sibling", `[{"type":"text","text":""},{"type":"text","text":"hi"}]`},
{"future_non_text_block", `[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"AA=="}}]`},
{"unknown_block", `[{"foo":"bar"}]`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := []byte(`{"messages":[{"role":"system","content":` + tc.content + `,"output_config":{"effort":"high"}}]}`)
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20")
require.True(t, changed)
msgs := gjson.GetBytes(out, "messages").Array()
require.Len(t, msgs, 1, "有正文 system 不得整条删除")
require.Equal(t, "system", msgs[0].Get("role").String())
require.False(t, msgs[0].Get("output_config").Exists(),
"有正文 system 必须剥掉 message-level output_config")
require.Equal(t, tc.content, msgs[0].Get("content").Raw,
"正文必须原样保留;非 text / 未知内容块不得被误当空而删除")
})
}
}
// header 含该 beta → 完全保留,字节 no-op(即使是空控制 system 消息)。
func TestSanitizeAnthropicBodyForBetaTokens_MidConversationOutputConfig_ByteNoopWhenBetaPresent(t *testing.T) {
body := []byte(`{"model":"claude-opus-5","output_config":{"effort":"high"},"messages":[{"role":"system","content":[],"output_config":{"effort":"high"}},{"role":"user","content":"hi"}]}`)
out, changed := sanitizeAnthropicBodyForBetaTokens(body,
"claude-code-20250219,oauth-2025-04-20,"+claude.BetaMidConversationOutputConfig)
require.False(t, changed, "header 含 mid-conversation-output-config beta → 完全保留")
require.True(t, bytes.Equal(body, out), "含 beta 时必须字节 no-op")
}
// 无 message-level output_config(仅有顶层 output_config,或完全没有)→ 字节 no-op。
func TestSanitizeAnthropicBodyForBetaTokens_MidConversationOutputConfig_NoFieldByteNoop(t *testing.T) {
bodies := [][]byte{
// 只有顶层 output_config(effort),消息无该字段
[]byte(`{"model":"claude-opus-5","output_config":{"effort":"high"},"messages":[{"role":"system","content":[]},{"role":"user","content":"hi"}]}`),
// 完全没有 output_config
[]byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}]}`),
}
for i, body := range bodies {
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20")
require.Falsef(t, changed, "case %d: 无 message-level output_config → 不得改动", i)
require.Truef(t, bytes.Equal(body, out), "case %d: 必须字节 no-op", i)
}
}
// 幂等:净化后的 body 再跑一次必须是 no-op。
func TestSanitizeAnthropicBodyForBetaTokens_MidConversationOutputConfig_Idempotent(t *testing.T) {
body := []byte(`{"model":"claude-opus-5","output_config":{"effort":"high"},"messages":[` +
`{"role":"system","content":[],"output_config":{"effort":"high"}},` +
`{"role":"system","content":"sys","output_config":{"effort":"high"}},` +
`{"role":"user","content":"hi"}]}`)
first, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20")
require.True(t, changed)
second, changedAgain := sanitizeAnthropicBodyForBetaTokens(first, "oauth-2025-04-20")
require.False(t, changedAgain, "二次净化必须为 no-op(幂等)")
require.True(t, bytes.Equal(first, second))
}
// ============================================================================
// 真实 request 联动
// ============================================================================
// OAuth mimic 路径真实 request 联动,两 case 明确期望:
// - 默认:mimic 固定列表带该 beta → outgoing header 含 token,空控制 system 消息保留;
// - policy filter 命中(经 gin context 的 betaPolicyFilterSetKey 缓存注入该 token,
// 走真实 policy filter/dropSet 路径)→ outgoing header 无 token,空控制 system
// 消息整条删除。
//
// 两 case 都断言 user 文本、消息数、顶层 effort 原值;期望为显式常量,不引用
// FullClaudeCodeMimicryBetas(避免把实现列表当 expected)。
func TestBuildUpstreamRequestOAuthMimic_MidConversationOutputConfig(t *testing.T) {
cases := []struct {
name string
policyFilterDrops bool
wantHeaderHasBeta bool
wantMsgLen int
wantFieldOnFirst bool
}{
{"default_mimic_keeps_beta", false, true, 2, true},
{"policy_filter_drops_beta", true, false, 1, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
if tc.policyFilterDrops {
c.Set(betaPolicyFilterSetKey, map[string]struct{}{claude.BetaMidConversationOutputConfig: {}})
}
account := &Account{ID: 701, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
Credentials: map[string]any{"access_token": "oauth-tok"},
Status: StatusActive,
Schedulable: true,
}
body := []byte(`{"model":"claude-opus-5","output_config":{"effort":"high"},"messages":[` +
`{"role":"system","content":[],"output_config":{"effort":"high"}},` +
`{"role":"user","content":"hello"}]}`)
svc := &GatewayService{cfg: &config.Config{}}
req, _, err := svc.buildUpstreamRequest(
context.Background(), c, account, body,
"oauth-tok", "oauth", "claude-opus-5", false, true, // mimicClaudeCode=true
)
require.NoError(t, err)
outBody := readUpstreamBodyForTest(t, req)
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
require.Equalf(t, tc.wantHeaderHasBeta,
anthropicBetaTokensContains(outBeta, claude.BetaMidConversationOutputConfig),
"outgoing anthropic-beta 必须与 filter 结果一致(outgoing beta=%q)", outBeta)
msgs := gjson.GetBytes(outBody, "messages").Array()
require.Len(t, msgs, tc.wantMsgLen,
"空控制 system 消息:header 带 beta 保留 / 缺 beta 整条删除")
require.Equal(t, tc.wantFieldOnFirst, msgs[0].Get("output_config").Exists())
var userContent string
for _, m := range msgs {
if m.Get("role").String() == "user" {
userContent = m.Get("content").String()
}
}
require.Equal(t, "hello", userContent, "用户消息文本必须始终保留")
require.Equal(t, "high", gjson.GetBytes(outBody, "output_config.effort").String(),
"顶层 effort 不受该 beta 约束")
})
}
}
// API-key passthrough 透传路径:客户端 header 带/不带该 beta 时,
// 出站 header 与 body 的 message-level output_config 必须同进同退。
func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_MidConversationOutputConfigConsistentWithClientHeader(t *testing.T) {
cases := []struct {
name string
clientBeta string
wantField bool
wantMsgLen int
}{
{
name: "client_header_has_beta",
clientBeta: "oauth-2025-04-20," + claude.BetaMidConversationOutputConfig,
wantField: true,
wantMsgLen: 2,
},
{
name: "client_header_missing_beta",
clientBeta: "oauth-2025-04-20",
wantField: false,
wantMsgLen: 1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
c.Request.Header.Set("Anthropic-Beta", tc.clientBeta)
body := []byte(`{"model":"claude-opus-5","output_config":{"effort":"high"},"messages":[` +
`{"role":"system","content":[],"output_config":{"effort":"high"}},` +
`{"role":"user","content":"hello"}]}`)
svc := &GatewayService{cfg: &config.Config{}}
req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
)
require.NoError(t, err)
outBody := readUpstreamBodyForTest(t, req)
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
require.Equalf(t, tc.wantField, anthropicBetaTokensContains(outBeta, claude.BetaMidConversationOutputConfig),
"出站 header 必须与客户端传入的 beta 一致(outgoing beta=%q)", outBeta)
require.Equal(t, tc.wantField, gjson.GetBytes(outBody, "messages.0.output_config").Exists(),
"header/body 必须一致")
require.Len(t, gjson.GetBytes(outBody, "messages").Array(), tc.wantMsgLen,
"缺 beta 时空控制 system 消息整条删除;带 beta 时保留")
})
}
}
+116
View File
@@ -918,6 +918,16 @@ const anthropicBetaContextManagementToken = "context-management-2025-06-27"
// - 缺 token 时上游拒收:
// "thinking.adaptive.block_binding: Extra inputs are not permitted"
//
// message-level output_config 场景:
// - pi-ai(Harness 使用的 Anthropic provider)会为 opus5 生成形如
// `{"role":"system","content":[],"output_config":{"effort":"high"}}` 的控制消息,
// 并请求 `mid-conversation-output-config-2026-07-01` beta
// - 该 output_config 是 **message 级**字段,只有该 beta 保护;顶层 output_config/effort
// 不受它约束
// - OAuth mimic 用 FullClaudeCodeMimicryBetas 覆盖客户端 beta;固定列表漏该 beta 时
// body 字段与 header 不对称 → 上游报 "output_config: Extra inputs are not permitted"
// - 缺 token 时净化消息级 output_config(详见 stripAnthropicMessageOutputConfigUnlessBeta)
//
// 本函数按最终发送的 anthropic-beta header 决定是否保留 body 中的上述字段:
// 缺对应 beta token → strip;客户端 header 已带对应 beta → 保留(不过度删除)。
// 这将限制完全建立在 "能力维度" 上,与 model 名 / token type / mimicry 子路径无关。
@@ -964,6 +974,12 @@ func sanitizeAnthropicBodyForBetaTokens(body []byte, anthropicBetaHeader string)
body, changed = b, true
}
// messages[].output_config:mid-conversation-output-config beta 专属字段。
// 顶层 output_config / effort 不受该 beta 约束,本分支只净化消息内字段。
if b, deleted := stripAnthropicMessageOutputConfigUnlessBeta(body, anthropicBetaHeader); deleted {
body, changed = b, true
}
return body, changed
}
@@ -1006,6 +1022,106 @@ func anthropicBetaTokensContains(header, token string) bool {
return false
}
// stripAnthropicMessageOutputConfigUnlessBeta 在 anthropic-beta header 缺
// mid-conversation-output-config beta 时,净化 **messages[].output_config**:
// - 仅为携带 message-level output_config 的消息剥该字段;
// - 若该消息 role=system 且 content 无正文(缺失 / null / 空 string / 空 array /
// 仅空 text 块),整条删除(pi-ai 为 opus5 生成的空 system 控制消息即此形态);
// - system 有正文则保留正文与其余字段;user/assistant 只剥字段,绝不整条删除;
// - 无任何消息携带该字段时返回原 body(字节 no-op)。
//
// header 含该 beta 时完全保留。顶层 output_config / effort 不属于该 beta 保护范围,
// 本函数不做任何处理。多条删除用「稳健重建」实现,保留其余字段与消息先后顺序。
func stripAnthropicMessageOutputConfigUnlessBeta(body []byte, anthropicBetaHeader string) ([]byte, bool) {
if anthropicBetaTokensContains(anthropicBetaHeader, claude.BetaMidConversationOutputConfig) {
return body, false
}
// 快速路径:body 中不含 output_config 字面量时无需解析。
if !bytes.Contains(body, []byte("output_config")) {
return body, false
}
msgsRes := gjson.GetBytes(body, "messages")
if !msgsRes.Exists() || !msgsRes.IsArray() {
return body, false
}
hasMessageOutputConfig := false
for _, msg := range msgsRes.Array() {
if msg.Get("output_config").Exists() {
hasMessageOutputConfig = true
break
}
}
if !hasMessageOutputConfig {
return body, false
}
var messages []json.RawMessage
if err := json.Unmarshal([]byte(msgsRes.Raw), &messages); err != nil {
// gjson 的 IsArray 只做形态判断、不保证 JSON 完整合法;此分支保守返回原 body。
return body, false
}
changed := false
rebuilt := make([]json.RawMessage, 0, len(messages))
for _, msg := range messages {
if !gjson.GetBytes(msg, "output_config").Exists() {
rebuilt = append(rebuilt, msg)
continue
}
changed = true
// 空正文的 system 控制消息整条删除;其余消息只剥字段。
if gjson.GetBytes(msg, "role").String() == "system" &&
!anthropicMessageContentHasBody(gjson.GetBytes(msg, "content")) {
continue
}
stripped, err := sjson.DeleteBytes(msg, "output_config")
if err != nil {
// 不应发生:字段存在且 msg 是合法 JSON。保守整条保留,不产出半成品。
rebuilt = append(rebuilt, msg)
continue
}
rebuilt = append(rebuilt, json.RawMessage(stripped))
}
if !changed {
return body, false
}
rebuiltBytes, err := json.Marshal(rebuilt)
if err != nil {
return body, false
}
out, err := sjson.SetRawBytes(body, "messages", rebuiltBytes)
if err != nil {
return body, false
}
return out, true
}
// anthropicMessageContentHasBody 判断单条消息的 content 是否携带正文。
// 返回 false 表示「无正文」:content 缺失 / null / 空 string / 空 array /
// 仅由空 text 块构成。未知或非 text 内容块(image / tool_use / tool_result 等)
// 一律视为有正文,避免把未来新增内容块误判为空而连带删除整条 system 消息。
func anthropicMessageContentHasBody(content gjson.Result) bool {
switch {
case !content.Exists():
return false
case content.Type == gjson.String:
return content.String() != ""
case content.IsArray():
var blocks []any
if err := json.Unmarshal([]byte(content.Raw), &blocks); err != nil {
return true // 无法解析时保守视为有正文
}
cleaned, _ := stripEmptyTextBlocksFromSlice(blocks)
return len(cleaned) > 0
default:
// null 视为无正文;object / number / bool 等非标准形态保守保留。
return content.Type != gjson.Null
}
}
// FilterSignatureSensitiveBlocksForRetry is a stronger retry filter for cases where upstream errors indicate
// signature/thought_signature validation issues involving tool blocks.
//