diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index ad663bfec4..ee2efcd29f 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -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, } } diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index 0e48f2cb91..60dc89489c 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -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) { diff --git a/backend/internal/service/gateway_mid_conversation_output_config_test.go b/backend/internal/service/gateway_mid_conversation_output_config_test.go new file mode 100644 index 0000000000..d551fae785 --- /dev/null +++ b/backend/internal/service/gateway_mid_conversation_output_config_test.go @@ -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 时保留") + }) + } +} diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index ab12f4ad22..a099f99463 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -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. //