mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #6995 from alfadb/fix/claude-mid-conversation-output-config
fix(claude): 修复对话中 output_config 的 beta 兼容问题
This commit is contained in:
@@ -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 时保留")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user