refactor(gateway): remove parsed request object graphs

Keep large gateway payloads as raw body ranges and bind OpenAI parsed-body caches to the body bytes so failover and mapping do not reuse stale mutable state.
This commit is contained in:
name 2026-05-30 00:37:34 +08:00
parent d8cbf9ab5c
commit b1c4be4ac8
16 changed files with 699 additions and 1228 deletions

View File

@ -747,10 +747,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 应用渠道模型映射到请求
if channelMapping.Mapped {
parsedReq.Model = channelMapping.MappedModel
parsedReq.Body.Replace(h.gatewayService.ReplaceModelInBody(parsedReq.Body.Bytes(), channelMapping.MappedModel))
parsedReq.ReplaceBody(h.gatewayService.ReplaceModelInBody(parsedReq.Body.Bytes(), channelMapping.MappedModel))
}
// Bedrock CC 兼容:渠道模型映射后,清理 Anthropic API 专有字段、注入 Bedrock 必需字段
parsedReq.Body.Replace(h.gatewayService.ApplyBedrockCCCompat(c.Request.Context(), parsedReq.Body.Bytes(), parsedReq.Model, account, apiKey.GroupID))
parsedReq.ReplaceBody(h.gatewayService.ApplyBedrockCCCompat(c.Request.Context(), parsedReq.Body.Bytes(), parsedReq.Model, account, apiKey.GroupID))
body = parsedReq.Body.Bytes()
// 转发请求 - 根据账号平台分流

View File

@ -74,8 +74,12 @@ func claudeCodeBodyMapFromParsedRequest(parsedReq *service.ParsedRequest) map[st
bodyMap := map[string]any{
"model": parsedReq.Model,
}
if parsedReq.System != nil || parsedReq.HasSystem {
bodyMap["system"] = parsedReq.System
if parsedReq.HasSystem {
if system, ok := parsedReq.SystemValue(); ok {
bodyMap["system"] = system
} else {
bodyMap["system"] = nil
}
}
if parsedReq.MetadataUserID != "" {
bodyMap["metadata"] = map[string]any{"user_id": parsedReq.MetadataUserID}
@ -87,11 +91,9 @@ func claudeCodeBodyMapFromContextCache(c *gin.Context) map[string]any {
if c == nil {
return nil
}
if cached, ok := c.Get(service.OpenAIParsedRequestBodyKey); ok {
if bodyMap, ok := cached.(map[string]any); ok {
if bodyMap := service.CachedOpenAIParsedRequestBody(c); bodyMap != nil {
return bodyMap
}
}
if cached, ok := c.Get(claudeCodeParsedRequestContextKey); ok {
switch v := cached.(type) {
case *service.ParsedRequest:

View File

@ -185,13 +185,8 @@ func TestSetClaudeCodeClientContext_ReuseParsedRequestAndContextCache(t *testing
c.Request.Header.Set("anthropic-beta", "message-batches-2024-09-24")
c.Request.Header.Set("anthropic-version", "2023-06-01")
parsedReq := &service.ParsedRequest{
Model: "claude-3-5-sonnet-20241022",
System: []any{
map[string]any{"text": "You are Claude Code, Anthropic's official CLI for Claude."},
},
MetadataUserID: "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
}
parsedReq, err := service.ParseGatewayRequest(service.NewRequestBodyRef(validClaudeCodeBodyJSON()), "")
require.NoError(t, err)
// body 非法 JSON如果函数复用 parsedReq 成功则仍应判定为 Claude Code。
SetClaudeCodeClientContext(c, []byte(`{invalid`), parsedReq)
@ -204,7 +199,7 @@ func TestSetClaudeCodeClientContext_ReuseParsedRequestAndContextCache(t *testing
c.Request.Header.Set("X-App", "claude-code")
c.Request.Header.Set("anthropic-beta", "message-batches-2024-09-24")
c.Request.Header.Set("anthropic-version", "2023-06-01")
c.Set(service.OpenAIParsedRequestBodyKey, map[string]any{
service.CacheOpenAIParsedRequestBody(c, []byte(`{invalid`), map[string]any{
"model": "claude-3-5-sonnet-20241022",
"system": []any{
map[string]any{"text": "You are Claude Code, Anthropic's official CLI for Claude."},

View File

@ -954,7 +954,7 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context,
return true
}
c.Set(service.OpenAIParsedRequestBodyKey, reqBody)
service.CacheOpenAIParsedRequestBody(c, body, reqBody)
validation := service.ValidateFunctionCallOutputContext(reqBody)
if !validation.HasFunctionCallOutput {
return true

View File

@ -4,6 +4,8 @@ import (
"encoding/json"
"strings"
"time"
"github.com/tidwall/gjson"
)
// Anthropic 会话 Fallback 相关常量
@ -30,30 +32,39 @@ func BuildAnthropicDigestChain(parsed *ParsedRequest) string {
var parts []string
// 1. system prompt
if parsed.System != nil {
systemData, _ := json.Marshal(parsed.System)
if len(systemData) > 0 && string(systemData) != "null" {
parts = append(parts, "s:"+shortHash(systemData))
}
if systemRaw := parsed.SystemRaw(); len(systemRaw) > 0 && string(systemRaw) != "null" {
parts = append(parts, "s:"+shortHash(canonicalAnthropicDigestJSON(systemRaw)))
}
// 2. messages
for _, msg := range parsed.Messages {
msgMap, ok := msg.(map[string]any)
if !ok {
continue
}
role, _ := msgMap["role"].(string)
prefix := rolePrefix(role)
content := msgMap["content"]
contentData, _ := json.Marshal(content)
parts = append(parts, prefix+":"+shortHash(contentData))
messages := parsed.MessagesRaw()
if len(messages) > 0 {
gjson.ParseBytes(messages).ForEach(func(_, msg gjson.Result) bool {
prefix := rolePrefix(msg.Get("role").String())
content := msg.Get("content")
parts = append(parts, prefix+":"+shortHash(canonicalAnthropicDigestJSON([]byte(content.Raw))))
return true
})
}
return strings.Join(parts, "-")
}
// canonicalAnthropicDigestJSON 保持 digest 对 JSON key 顺序和空白不敏感。
func canonicalAnthropicDigestJSON(raw []byte) []byte {
if len(raw) == 0 {
return raw
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return raw
}
canonical, err := json.Marshal(value)
if err != nil {
return raw
}
return canonical
}
// rolePrefix 将 Anthropic 的 role 映射为单字符前缀
func rolePrefix(role string) string {
switch role {

View File

@ -1,3 +1,5 @@
//go:build unit
package service
import (
@ -5,6 +7,15 @@ import (
"testing"
)
func mustParseAnthropicDigestRequest(t *testing.T, body string) *ParsedRequest {
t.Helper()
parsed, err := ParseGatewayRequest(NewRequestBodyRef([]byte(body)), "")
if err != nil {
t.Fatalf("ParseGatewayRequest failed: %v", err)
}
return parsed
}
func TestBuildAnthropicDigestChain_NilRequest(t *testing.T) {
result := BuildAnthropicDigestChain(nil)
if result != "" {
@ -13,9 +24,7 @@ func TestBuildAnthropicDigestChain_NilRequest(t *testing.T) {
}
func TestBuildAnthropicDigestChain_EmptyMessages(t *testing.T) {
parsed := &ParsedRequest{
Messages: []any{},
}
parsed := mustParseAnthropicDigestRequest(t, `{"messages":[]}`)
result := BuildAnthropicDigestChain(parsed)
if result != "" {
t.Errorf("expected empty string for empty messages, got: %s", result)
@ -23,11 +32,7 @@ func TestBuildAnthropicDigestChain_EmptyMessages(t *testing.T) {
}
func TestBuildAnthropicDigestChain_SingleUserMessage(t *testing.T) {
parsed := &ParsedRequest{
Messages: []any{
map[string]any{"role": "user", "content": "hello"},
},
}
parsed := mustParseAnthropicDigestRequest(t, `{"messages":[{"role":"user","content":"hello"}]}`)
result := BuildAnthropicDigestChain(parsed)
parts := splitChain(result)
if len(parts) != 1 {
@ -39,12 +44,7 @@ func TestBuildAnthropicDigestChain_SingleUserMessage(t *testing.T) {
}
func TestBuildAnthropicDigestChain_UserAndAssistant(t *testing.T) {
parsed := &ParsedRequest{
Messages: []any{
map[string]any{"role": "user", "content": "hello"},
map[string]any{"role": "assistant", "content": "hi there"},
},
}
parsed := mustParseAnthropicDigestRequest(t, `{"messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"hi there"}]}`)
result := BuildAnthropicDigestChain(parsed)
parts := splitChain(result)
if len(parts) != 2 {
@ -59,12 +59,7 @@ func TestBuildAnthropicDigestChain_UserAndAssistant(t *testing.T) {
}
func TestBuildAnthropicDigestChain_WithSystemString(t *testing.T) {
parsed := &ParsedRequest{
System: "You are a helpful assistant",
Messages: []any{
map[string]any{"role": "user", "content": "hello"},
},
}
parsed := mustParseAnthropicDigestRequest(t, `{"system":"You are a helpful assistant","messages":[{"role":"user","content":"hello"}]}`)
result := BuildAnthropicDigestChain(parsed)
parts := splitChain(result)
if len(parts) != 2 {
@ -79,14 +74,7 @@ func TestBuildAnthropicDigestChain_WithSystemString(t *testing.T) {
}
func TestBuildAnthropicDigestChain_WithSystemContentBlocks(t *testing.T) {
parsed := &ParsedRequest{
System: []any{
map[string]any{"type": "text", "text": "You are a helpful assistant"},
},
Messages: []any{
map[string]any{"role": "user", "content": "hello"},
},
}
parsed := mustParseAnthropicDigestRequest(t, `{"system":[{"type":"text","text":"You are a helpful assistant"}],"messages":[{"role":"user","content":"hello"}]}`)
result := BuildAnthropicDigestChain(parsed)
parts := splitChain(result)
if len(parts) != 2 {
@ -100,74 +88,33 @@ func TestBuildAnthropicDigestChain_WithSystemContentBlocks(t *testing.T) {
func TestBuildAnthropicDigestChain_ConversationPrefixRelationship(t *testing.T) {
// 核心测试:验证对话增长时链的前缀关系
// 上一轮的完整链一定是下一轮链的前缀
system := "You are a helpful assistant"
// 第 1 轮: system + user
round1 := &ParsedRequest{
System: system,
Messages: []any{
map[string]any{"role": "user", "content": "hello"},
},
}
round1 := mustParseAnthropicDigestRequest(t, `{"system":"You are a helpful assistant","messages":[{"role":"user","content":"hello"}]}`)
chain1 := BuildAnthropicDigestChain(round1)
// 第 2 轮: system + user + assistant + user
round2 := &ParsedRequest{
System: system,
Messages: []any{
map[string]any{"role": "user", "content": "hello"},
map[string]any{"role": "assistant", "content": "hi there"},
map[string]any{"role": "user", "content": "how are you?"},
},
}
round2 := mustParseAnthropicDigestRequest(t, `{"system":"You are a helpful assistant","messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"hi there"},{"role":"user","content":"how are you?"}]}`)
chain2 := BuildAnthropicDigestChain(round2)
// 第 3 轮: system + user + assistant + user + assistant + user
round3 := &ParsedRequest{
System: system,
Messages: []any{
map[string]any{"role": "user", "content": "hello"},
map[string]any{"role": "assistant", "content": "hi there"},
map[string]any{"role": "user", "content": "how are you?"},
map[string]any{"role": "assistant", "content": "I'm doing well"},
map[string]any{"role": "user", "content": "great"},
},
}
round3 := mustParseAnthropicDigestRequest(t, `{"system":"You are a helpful assistant","messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"hi there"},{"role":"user","content":"how are you?"},{"role":"assistant","content":"I'm doing well"},{"role":"user","content":"great"}]}`)
chain3 := BuildAnthropicDigestChain(round3)
t.Logf("Chain1: %s", chain1)
t.Logf("Chain2: %s", chain2)
t.Logf("Chain3: %s", chain3)
// chain1 是 chain2 的前缀
if !strings.HasPrefix(chain2, chain1) {
t.Errorf("chain1 should be prefix of chain2:\n chain1: %s\n chain2: %s", chain1, chain2)
}
// chain2 是 chain3 的前缀
if !strings.HasPrefix(chain3, chain2) {
t.Errorf("chain2 should be prefix of chain3:\n chain2: %s\n chain3: %s", chain2, chain3)
}
// chain1 也是 chain3 的前缀(传递性)
if !strings.HasPrefix(chain3, chain1) {
t.Errorf("chain1 should be prefix of chain3:\n chain1: %s\n chain3: %s", chain1, chain3)
}
}
func TestBuildAnthropicDigestChain_DifferentSystemProducesDifferentChain(t *testing.T) {
parsed1 := &ParsedRequest{
System: "System A",
Messages: []any{
map[string]any{"role": "user", "content": "hello"},
},
}
parsed2 := &ParsedRequest{
System: "System B",
Messages: []any{
map[string]any{"role": "user", "content": "hello"},
},
}
parsed1 := mustParseAnthropicDigestRequest(t, `{"system":"System A","messages":[{"role":"user","content":"hello"}]}`)
parsed2 := mustParseAnthropicDigestRequest(t, `{"system":"System B","messages":[{"role":"user","content":"hello"}]}`)
chain1 := BuildAnthropicDigestChain(parsed1)
chain2 := BuildAnthropicDigestChain(parsed2)
@ -176,7 +123,6 @@ func TestBuildAnthropicDigestChain_DifferentSystemProducesDifferentChain(t *test
t.Error("Different system prompts should produce different chains")
}
// 但 user 部分的 hash 应该相同
parts1 := splitChain(chain1)
parts2 := splitChain(chain2)
if parts1[1] != parts2[1] {
@ -185,20 +131,8 @@ func TestBuildAnthropicDigestChain_DifferentSystemProducesDifferentChain(t *test
}
func TestBuildAnthropicDigestChain_DifferentContentProducesDifferentChain(t *testing.T) {
parsed1 := &ParsedRequest{
Messages: []any{
map[string]any{"role": "user", "content": "hello"},
map[string]any{"role": "assistant", "content": "ORIGINAL reply"},
map[string]any{"role": "user", "content": "next"},
},
}
parsed2 := &ParsedRequest{
Messages: []any{
map[string]any{"role": "user", "content": "hello"},
map[string]any{"role": "assistant", "content": "TAMPERED reply"},
map[string]any{"role": "user", "content": "next"},
},
}
parsed1 := mustParseAnthropicDigestRequest(t, `{"messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"ORIGINAL reply"},{"role":"user","content":"next"}]}`)
parsed2 := mustParseAnthropicDigestRequest(t, `{"messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"TAMPERED reply"},{"role":"user","content":"next"}]}`)
chain1 := BuildAnthropicDigestChain(parsed1)
chain2 := BuildAnthropicDigestChain(parsed2)
@ -209,24 +143,16 @@ func TestBuildAnthropicDigestChain_DifferentContentProducesDifferentChain(t *tes
parts1 := splitChain(chain1)
parts2 := splitChain(chain2)
// 第一个 user message hash 应该相同
if parts1[0] != parts2[0] {
t.Error("First user message hash should be the same")
}
// assistant reply hash 应该不同
if parts1[1] == parts2[1] {
t.Error("Assistant reply hash should differ")
}
}
func TestBuildAnthropicDigestChain_Deterministic(t *testing.T) {
parsed := &ParsedRequest{
System: "test system",
Messages: []any{
map[string]any{"role": "user", "content": "hello"},
map[string]any{"role": "assistant", "content": "hi"},
},
}
parsed := mustParseAnthropicDigestRequest(t, `{"system":"test system","messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"hi"}]}`)
chain1 := BuildAnthropicDigestChain(parsed)
chain2 := BuildAnthropicDigestChain(parsed)
@ -236,6 +162,18 @@ func TestBuildAnthropicDigestChain_Deterministic(t *testing.T) {
}
}
func TestBuildAnthropicDigestChain_CanonicalJSON(t *testing.T) {
parsed1 := mustParseAnthropicDigestRequest(t, `{"system":[{"type":"text","text":"system"}],"messages":[{"role":"user","content":{"type":"text","text":"hello"}}]}`)
parsed2 := mustParseAnthropicDigestRequest(t, `{"system":[{"text":"system","type":"text"}],"messages":[{"role":"user","content":{"text":"hello","type":"text"}}]}`)
chain1 := BuildAnthropicDigestChain(parsed1)
chain2 := BuildAnthropicDigestChain(parsed2)
if chain1 != chain2 {
t.Errorf("semantically equivalent JSON should produce same chain: %s vs %s", chain1, chain2)
}
}
func TestGenerateAnthropicDigestSessionKey(t *testing.T) {
tests := []struct {
name string
@ -278,7 +216,6 @@ func TestGenerateAnthropicDigestSessionKey(t *testing.T) {
})
}
// 验证不同 uuid 产生不同 sessionKey
t.Run("different uuid different key", func(t *testing.T) {
hash := "sameprefix123456"
result1 := GenerateAnthropicDigestSessionKey(hash, "uuid0001-session-a")
@ -297,18 +234,7 @@ func TestAnthropicSessionTTL(t *testing.T) {
}
func TestBuildAnthropicDigestChain_ContentBlocks(t *testing.T) {
// 测试 content 为 content blocks 数组的情况
parsed := &ParsedRequest{
Messages: []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "text", "text": "describe this image"},
map[string]any{"type": "image", "source": map[string]any{"type": "base64"}},
},
},
},
}
parsed := mustParseAnthropicDigestRequest(t, `{"messages":[{"role":"user","content":[{"type":"text","text":"describe this image"},{"type":"image","source":{"type":"base64"}}]}]}`)
result := BuildAnthropicDigestChain(parsed)
parts := splitChain(result)
if len(parts) != 1 {

View File

@ -14,8 +14,6 @@ func TestBuildOAuthMetadataUserID_FallbackWithoutAccountUUID(t *testing.T) {
Model: "claude-sonnet-4-5",
Stream: true,
MetadataUserID: "",
System: nil,
Messages: nil,
}
account := &Account{

View File

@ -51,6 +51,12 @@ type SessionContext struct {
APIKeyID int64
}
type jsonRange struct {
start int // 原始请求体中的起始偏移(闭区间)
end int // 原始请求体中的结束偏移(开区间)
kind gjson.Type // JSON 值类型,用于调用方做轻量分支
}
type RequestBodyRef struct {
data []byte
}
@ -80,6 +86,76 @@ func (b *RequestBodyRef) Replace(data []byte) {
b.data = data
}
func missingJSONRange() jsonRange {
return jsonRange{start: -1, end: -1}
}
func rangeFromResult(r gjson.Result) jsonRange {
if r.Raw == "" || r.Index <= 0 {
return missingJSONRange()
}
end := r.Index + len(r.Raw)
if end < r.Index {
return missingJSONRange()
}
return jsonRange{start: r.Index, end: end, kind: r.Type}
}
func (r jsonRange) exists() bool {
return r.start >= 0 && r.end >= r.start
}
func clearGatewayRequestRanges(parsed *ParsedRequest) {
if parsed == nil {
return
}
parsed.HasSystem = false
parsed.systemRange = missingJSONRange()
parsed.messagesRange = missingJSONRange()
}
func setGatewayRequestRanges(parsed *ParsedRequest, protocol string, jsonStr string) {
if parsed == nil {
return
}
switch protocol {
case domain.PlatformGemini:
if sysParts := gjson.Get(jsonStr, "systemInstruction.parts"); sysParts.Exists() && sysParts.IsArray() {
parsed.systemRange = rangeFromResult(sysParts)
}
if contents := gjson.Get(jsonStr, "contents"); contents.Exists() && contents.IsArray() {
parsed.messagesRange = rangeFromResult(contents)
}
default:
if sys := gjson.Get(jsonStr, "system"); sys.Exists() {
parsed.HasSystem = true
parsed.systemRange = rangeFromResult(sys)
}
if msgs := gjson.Get(jsonStr, "messages"); msgs.Exists() && msgs.IsArray() {
parsed.messagesRange = rangeFromResult(msgs)
}
}
}
func refreshGatewayRequestRanges(parsed *ParsedRequest, protocol string) error {
if parsed == nil {
return fmt.Errorf("empty request body")
}
clearGatewayRequestRanges(parsed)
if parsed.Body == nil {
return fmt.Errorf("empty request body")
}
bodyBytes := parsed.Body.Bytes()
if !gjson.ValidBytes(bodyBytes) {
return fmt.Errorf("invalid json")
}
jsonStr := *(*string)(unsafe.Pointer(&bodyBytes))
setGatewayRequestRanges(parsed, protocol, jsonStr)
return nil
}
// ParsedRequest 保存网关请求的预解析结果
//
// 性能优化说明:
@ -93,18 +169,20 @@ func (b *RequestBodyRef) Replace(data []byte) {
// 2. 将解析结果 ParsedRequest 传递给 Service 层
// 3. 避免重复 json.Unmarshal减少 CPU 和内存开销
type ParsedRequest struct {
Body *RequestBodyRef // 原始请求体引用(保留用于转发)
Body *RequestBodyRef // 原始请求体引用(保留用于转发);替换内容请走 ReplaceBody
Model string // 请求的模型名称
Stream bool // 是否为流式请求
MetadataUserID string // metadata.user_id用于会话亲和
System any // system 字段内容
Messages []any // messages 数组
HasSystem bool // 是否包含 system 字段(包含 null 也视为显式传入)
ThinkingEnabled bool // 是否开启 thinking部分平台会影响最终模型名
OutputEffort string // output_config.effortClaude API 的推理强度控制)
MaxTokens int // max_tokens 值(用于探测请求拦截)
SessionContext *SessionContext // 可选请求上下文区分因子nil 时行为不变)
protocol string // 当前 Body 的协议格式,用于 Body 替换后刷新 raw range
systemRange jsonRange // system/systemInstruction.parts 的 raw JSON 范围,绑定 Body 当前内容
messagesRange jsonRange // messages/contents 的 raw JSON 范围,绑定 Body 当前内容
// GroupID 请求所属分组 ID来自 API Key
GroupID *int64
@ -174,6 +252,9 @@ func ParseGatewayRequest(body *RequestBodyRef, protocol string) (*ParsedRequest,
parsed := &ParsedRequest{
Body: body,
protocol: protocol,
systemRange: missingJSONRange(),
messagesRange: missingJSONRange(),
}
// --- gjson 提取简单字段(避免完整 Unmarshal ---
@ -219,60 +300,73 @@ func ParseGatewayRequest(body *RequestBodyRef, protocol string) (*ParsedRequest,
}
// --- system/messages 提取 ---
// 避免把整个 body Unmarshal 到 map会产生大量 map/接口分配)。
// 使用 gjson 抽取目标字段的 Raw再对该子树进行 Unmarshal。
switch protocol {
case domain.PlatformGemini:
// Gemini 原生格式: systemInstruction.parts / contents
if sysParts := gjson.Get(jsonStr, "systemInstruction.parts"); sysParts.Exists() && sysParts.IsArray() {
var parts []any
if err := json.Unmarshal(sliceRawFromBody(bodyBytes, sysParts), &parts); err != nil {
return nil, err
}
parsed.System = parts
}
if contents := gjson.Get(jsonStr, "contents"); contents.Exists() && contents.IsArray() {
var msgs []any
if err := json.Unmarshal(sliceRawFromBody(bodyBytes, contents), &msgs); err != nil {
return nil, err
}
parsed.Messages = msgs
}
default:
// Anthropic / OpenAI 格式: system / messages
// system 字段只要存在就视为显式提供(即使为 null
// 以避免客户端传 null 时被默认 system 误注入。
if sys := gjson.Get(jsonStr, "system"); sys.Exists() {
parsed.HasSystem = true
switch sys.Type {
case gjson.Null:
parsed.System = nil
case gjson.String:
// 与 encoding/json 的 Unmarshal 行为一致:返回解码后的字符串。
parsed.System = sys.String()
default:
var system any
if err := json.Unmarshal(sliceRawFromBody(bodyBytes, sys), &system); err != nil {
return nil, err
}
parsed.System = system
}
}
if msgs := gjson.Get(jsonStr, "messages"); msgs.Exists() && msgs.IsArray() {
var messages []any
if err := json.Unmarshal(sliceRawFromBody(bodyBytes, msgs), &messages); err != nil {
return nil, err
}
parsed.Messages = messages
}
}
// 只保存大字段 raw range不默认反序列化成 []any/map[string]any 对象图。
setGatewayRequestRanges(parsed, protocol, jsonStr)
return parsed, nil
}
func (p *ParsedRequest) raw(r jsonRange) []byte {
if p == nil || p.Body == nil || !r.exists() {
return nil
}
body := p.Body.Bytes()
if r.end > len(body) {
return nil
}
return body[r.start:r.end]
}
func (p *ParsedRequest) SystemRaw() []byte {
return p.raw(p.systemRange)
}
func (p *ParsedRequest) MessagesRaw() []byte {
return p.raw(p.messagesRange)
}
func (p *ParsedRequest) DecodeSystem(dst any) error {
raw := p.SystemRaw()
if len(raw) == 0 {
return nil
}
return json.Unmarshal(raw, dst)
}
func (p *ParsedRequest) DecodeMessages(dst any) error {
raw := p.MessagesRaw()
if len(raw) == 0 {
return nil
}
return json.Unmarshal(raw, dst)
}
func (p *ParsedRequest) SystemValue() (any, bool) {
raw := p.SystemRaw()
if len(raw) == 0 {
return nil, false
}
var system any
if err := json.Unmarshal(raw, &system); err != nil {
return nil, false
}
return system, true
}
func (p *ParsedRequest) ReplaceBody(data []byte) {
if p == nil {
return
}
if p.Body == nil {
p.Body = NewRequestBodyRef(data)
} else {
p.Body.Replace(data)
}
if err := refreshGatewayRequestRanges(p, p.protocol); err != nil {
clearGatewayRequestRanges(p)
}
}
// sliceRawFromBody 返回 Result.Raw 对应的原始字节切片。
// 优先使用 Result.Index 直接从 body 切片,避免对大字段(如 messages产生额外拷贝。
// 当 Index 不可用时,退化为复制(理论上极少发生)。

View File

@ -10,6 +10,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestParseGatewayRequest(t *testing.T) {
@ -20,8 +21,8 @@ func TestParseGatewayRequest(t *testing.T) {
require.True(t, parsed.Stream)
require.Equal(t, "session_123e4567-e89b-12d3-a456-426614174000", parsed.MetadataUserID)
require.True(t, parsed.HasSystem)
require.NotNil(t, parsed.System)
require.Len(t, parsed.Messages, 1)
require.NotEmpty(t, parsed.SystemRaw())
require.NotEmpty(t, parsed.MessagesRaw())
require.False(t, parsed.ThinkingEnabled)
}
@ -61,7 +62,7 @@ func TestParseGatewayRequest_SystemNull(t *testing.T) {
require.NoError(t, err)
// 显式传入 system:null 也应视为“字段已存在”,避免默认 system 被注入。
require.True(t, parsed.HasSystem)
require.Nil(t, parsed.System)
require.Equal(t, []byte("null"), parsed.SystemRaw())
}
func TestParseGatewayRequest_InvalidModelType(t *testing.T) {
@ -88,9 +89,9 @@ func TestParseGatewayRequest_GeminiContents(t *testing.T) {
}`)
parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformGemini)
require.NoError(t, err)
require.Len(t, parsed.Messages, 3, "should parse contents as Messages")
require.Len(t, gjson.ParseBytes(parsed.MessagesRaw()).Array(), 3, "should parse contents as Messages")
require.False(t, parsed.HasSystem, "Gemini format should not set HasSystem")
require.Nil(t, parsed.System, "no systemInstruction means nil System")
require.Nil(t, parsed.SystemRaw(), "no systemInstruction means nil System")
}
func TestParseGatewayRequest_GeminiSystemInstruction(t *testing.T) {
@ -104,14 +105,11 @@ func TestParseGatewayRequest_GeminiSystemInstruction(t *testing.T) {
}`)
parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformGemini)
require.NoError(t, err)
require.NotNil(t, parsed.System, "should parse systemInstruction.parts as System")
parts, ok := parsed.System.([]any)
require.True(t, ok)
require.Len(t, parts, 1)
partMap, ok := parts[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "You are a helpful assistant.", partMap["text"])
require.Len(t, parsed.Messages, 1)
system := gjson.ParseBytes(parsed.SystemRaw())
require.True(t, system.IsArray(), "should parse systemInstruction.parts as System")
require.Len(t, system.Array(), 1)
require.Equal(t, "You are a helpful assistant.", system.Get("0.text").String())
require.Len(t, gjson.ParseBytes(parsed.MessagesRaw()).Array(), 1)
}
func TestParseGatewayRequest_GeminiWithModel(t *testing.T) {
@ -122,7 +120,7 @@ func TestParseGatewayRequest_GeminiWithModel(t *testing.T) {
parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformGemini)
require.NoError(t, err)
require.Equal(t, "gemini-2.5-pro", parsed.Model)
require.Len(t, parsed.Messages, 1)
require.Len(t, gjson.ParseBytes(parsed.MessagesRaw()).Array(), 1)
}
func TestParseGatewayRequest_GeminiIgnoresAnthropicFields(t *testing.T) {
@ -135,22 +133,22 @@ func TestParseGatewayRequest_GeminiIgnoresAnthropicFields(t *testing.T) {
parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformGemini)
require.NoError(t, err)
require.False(t, parsed.HasSystem, "Gemini protocol should not parse Anthropic system field")
require.Nil(t, parsed.System, "no systemInstruction = nil System")
require.Len(t, parsed.Messages, 1, "should use contents, not messages")
require.Nil(t, parsed.SystemRaw(), "no systemInstruction = nil System")
require.Len(t, gjson.ParseBytes(parsed.MessagesRaw()).Array(), 1, "should use contents, not messages")
}
func TestParseGatewayRequest_GeminiEmptyContents(t *testing.T) {
body := []byte(`{"contents": []}`)
parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformGemini)
require.NoError(t, err)
require.Empty(t, parsed.Messages)
require.Empty(t, gjson.ParseBytes(parsed.MessagesRaw()).Array())
}
func TestParseGatewayRequest_GeminiNoContents(t *testing.T) {
body := []byte(`{"model": "gemini-2.5-flash"}`)
parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformGemini)
require.NoError(t, err)
require.Nil(t, parsed.Messages)
require.Nil(t, parsed.MessagesRaw())
require.Equal(t, "gemini-2.5-flash", parsed.Model)
}
@ -165,11 +163,10 @@ func TestParseGatewayRequest_AnthropicIgnoresGeminiFields(t *testing.T) {
parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), domain.PlatformAnthropic)
require.NoError(t, err)
require.True(t, parsed.HasSystem)
require.Equal(t, "real system", parsed.System)
require.Len(t, parsed.Messages, 1)
msg, ok := parsed.Messages[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "real content", msg["content"])
require.Equal(t, "real system", gjson.ParseBytes(parsed.SystemRaw()).String())
messages := gjson.ParseBytes(parsed.MessagesRaw()).Array()
require.Len(t, messages, 1)
require.Equal(t, "real content", messages[0].Get("content").String())
}
func TestFilterThinkingBlocks(t *testing.T) {
@ -970,10 +967,10 @@ func TestParseGatewayRequest_OptionalFieldsMissing(t *testing.T) {
require.Equal(t, tt.wantMaxTokens, parsed.MaxTokens)
if tt.wantMessagesNil {
require.Nil(t, parsed.Messages)
require.Nil(t, parsed.MessagesRaw())
}
if tt.wantMessagesLen > 0 {
require.Len(t, parsed.Messages, tt.wantMessagesLen)
require.Len(t, gjson.ParseBytes(parsed.MessagesRaw()).Array(), tt.wantMessagesLen)
}
})
}
@ -1087,25 +1084,8 @@ func parseGatewayRequestOld(body []byte, protocol string) (*ParsedRequest, error
}
}
// system / messages按协议分支
switch protocol {
case domain.PlatformGemini:
if sysInst, ok := req["systemInstruction"].(map[string]any); ok {
if parts, ok := sysInst["parts"].([]any); ok {
parsed.System = parts
}
}
if contents, ok := req["contents"].([]any); ok {
parsed.Messages = contents
}
default:
if system, ok := req["system"]; ok {
parsed.HasSystem = true
parsed.System = system
}
if messages, ok := req["messages"].([]any); ok {
parsed.Messages = messages
}
if err := refreshGatewayRequestRanges(parsed, protocol); err != nil {
return nil, err
}
return parsed, nil

View File

@ -748,31 +748,10 @@ func (s *GatewayService) GenerateSessionHash(parsed *ParsedRequest) string {
_, _ = combined.WriteString(strconv.FormatInt(parsed.SessionContext.APIKeyID, 10))
_, _ = combined.WriteString("|")
}
if parsed.System != nil {
systemText := s.extractTextFromSystem(parsed.System)
if systemText != "" {
if systemText := extractTextFromSystemRaw(parsed.SystemRaw()); systemText != "" {
_, _ = combined.WriteString(systemText)
}
}
for _, msg := range parsed.Messages {
if m, ok := msg.(map[string]any); ok {
if content, exists := m["content"]; exists {
// Anthropic: messages[].content
if msgText := s.extractTextFromContent(content); msgText != "" {
_, _ = combined.WriteString(msgText)
}
} else if parts, ok := m["parts"].([]any); ok {
// Gemini: contents[].parts[].text
for _, part := range parts {
if partMap, ok := part.(map[string]any); ok {
if text, ok := partMap["text"].(string); ok {
_, _ = combined.WriteString(text)
}
}
}
}
}
}
appendMessageTextsFromRaw(&combined, parsed.MessagesRaw())
if combined.Len() > 0 {
hash := s.hashContent(combined.String())
slog.Info("sticky.hash_source",
@ -847,82 +826,127 @@ func (s *GatewayService) extractCacheableContent(parsed *ParsedRequest) string {
return ""
}
var builder strings.Builder
// 检查 system 中的 cacheable 内容
if system, ok := parsed.System.([]any); ok {
for _, part := range system {
if partMap, ok := part.(map[string]any); ok {
if cc, ok := partMap["cache_control"].(map[string]any); ok {
if cc["type"] == "ephemeral" {
if text, ok := partMap["text"].(string); ok {
_, _ = builder.WriteString(text)
systemText := extractCacheableTextFromSystemRaw(parsed.SystemRaw())
if messageText := extractCacheableTextFromMessagesRaw(parsed.MessagesRaw()); messageText != "" {
return messageText
}
}
}
}
}
}
systemText := builder.String()
// 检查 messages 中的 cacheable 内容
for _, msg := range parsed.Messages {
if msgMap, ok := msg.(map[string]any); ok {
if msgContent, ok := msgMap["content"].([]any); ok {
for _, part := range msgContent {
if partMap, ok := part.(map[string]any); ok {
if cc, ok := partMap["cache_control"].(map[string]any); ok {
if cc["type"] == "ephemeral" {
return s.extractTextFromContent(msgMap["content"])
}
}
}
}
}
}
}
return systemText
}
func (s *GatewayService) extractTextFromSystem(system any) string {
switch v := system.(type) {
case string:
return v
case []any:
var texts []string
for _, part := range v {
if partMap, ok := part.(map[string]any); ok {
if text, ok := partMap["text"].(string); ok {
texts = append(texts, text)
func extractTextFromSystemRaw(raw []byte) string {
system := gjson.ParseBytes(raw)
switch system.Type {
case gjson.String:
return system.String()
case gjson.JSON:
if !system.IsArray() {
return ""
}
var builder strings.Builder
system.ForEach(func(_, part gjson.Result) bool {
if text := part.Get("text").String(); text != "" {
_, _ = builder.WriteString(text)
}
}
return strings.Join(texts, "")
return true
})
return builder.String()
}
return ""
}
func (s *GatewayService) extractTextFromContent(content any) string {
switch v := content.(type) {
case string:
return v
case []any:
var texts []string
for _, part := range v {
if partMap, ok := part.(map[string]any); ok {
if partMap["type"] == "text" {
if text, ok := partMap["text"].(string); ok {
texts = append(texts, text)
func extractTextFromContentRaw(content gjson.Result) string {
switch content.Type {
case gjson.String:
return content.String()
case gjson.JSON:
if !content.IsArray() {
return ""
}
var builder strings.Builder
content.ForEach(func(_, part gjson.Result) bool {
if part.Get("type").String() == "text" {
if text := part.Get("text").String(); text != "" {
_, _ = builder.WriteString(text)
}
}
}
}
return strings.Join(texts, "")
return true
})
return builder.String()
}
return ""
}
func appendMessageTextsFromRaw(builder *strings.Builder, raw []byte) {
if builder == nil || len(raw) == 0 {
return
}
messages := gjson.ParseBytes(raw)
if !messages.IsArray() {
return
}
messages.ForEach(func(_, msg gjson.Result) bool {
if content := msg.Get("content"); content.Exists() {
_, _ = builder.WriteString(extractTextFromContentRaw(content))
return true
}
parts := msg.Get("parts")
if parts.IsArray() {
parts.ForEach(func(_, part gjson.Result) bool {
if text := part.Get("text").String(); text != "" {
_, _ = builder.WriteString(text)
}
return true
})
}
return true
})
}
func extractCacheableTextFromSystemRaw(raw []byte) string {
system := gjson.ParseBytes(raw)
if !system.IsArray() {
return ""
}
var builder strings.Builder
system.ForEach(func(_, part gjson.Result) bool {
if part.Get("cache_control.type").String() == "ephemeral" {
if text := part.Get("text").String(); text != "" {
_, _ = builder.WriteString(text)
}
}
return true
})
return builder.String()
}
func extractCacheableTextFromMessagesRaw(raw []byte) string {
messages := gjson.ParseBytes(raw)
if !messages.IsArray() {
return ""
}
var text string
messages.ForEach(func(_, msg gjson.Result) bool {
content := msg.Get("content")
if !content.IsArray() {
return true
}
found := false
content.ForEach(func(_, part gjson.Result) bool {
if part.Get("cache_control.type").String() == "ephemeral" {
found = true
return false
}
return true
})
if found {
text = extractTextFromContentRaw(content)
return false
}
return true
})
return text
}
func (s *GatewayService) hashContent(content string) string {
h := xxhash.Sum64String(content)
return strconv.FormatUint(h, 36)
@ -1284,7 +1308,7 @@ func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody(
systemRewritten := false
if !strings.Contains(strings.ToLower(model), "haiku") {
body = rewriteSystemForNonClaudeCode(body, systemRaw)
body = rewriteSystemForNonClaudeCode(body, normalizeSystemParam(systemRaw))
systemRewritten = true
}
@ -4474,7 +4498,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// Parrot 的 transform_request 从不检查客户端 system 内容,直接覆盖。
systemRewritten := false
if !strings.Contains(strings.ToLower(reqModel), "haiku") {
body = rewriteSystemForNonClaudeCode(body, parsed.System)
systemRaw, _ := parsed.SystemValue()
body = rewriteSystemForNonClaudeCode(body, systemRaw)
systemRewritten = true
}

View File

@ -2,6 +2,7 @@ package service
import (
"strconv"
"strings"
"testing"
)
@ -34,17 +35,20 @@ func BenchmarkExtractCacheableContent_System(b *testing.B) {
}
func buildSystemCacheableRequest(parts int) *ParsedRequest {
systemParts := make([]any, 0, parts)
var builder strings.Builder
builder.WriteString(`{"system":[`)
for i := 0; i < parts; i++ {
systemParts = append(systemParts, map[string]any{
"text": "system_part_" + strconv.Itoa(i),
"cache_control": map[string]any{
"type": "ephemeral",
},
})
if i > 0 {
builder.WriteByte(',')
}
return &ParsedRequest{
System: systemParts,
HasSystem: true,
builder.WriteString(`{"text":"system_part_`)
builder.WriteString(strconv.Itoa(i))
builder.WriteString(`","cache_control":{"type":"ephemeral"}}`)
}
builder.WriteString(`]}`)
parsed, err := ParseGatewayRequest(NewRequestBodyRef([]byte(builder.String())), "")
if err != nil {
panic(err)
}
return parsed
}

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,13 @@ import (
"go.uber.org/zap"
)
// openAIParsedRequestBodyCache 绑定 body 指纹,避免 handler 预解析的旧 body 污染后续 forwardBody。
type openAIParsedRequestBodyCache struct {
bodyHash uint64
bodyLen int
reqBody map[string]any
}
const (
// ChatGPT internal API for OAuth accounts
chatgptCodexURL = "https://chatgpt.com/backend-api/codex/responses"
@ -2284,6 +2291,20 @@ func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode i
return isOpenAITransientProcessingError(statusCode, upstreamMsg, upstreamBody)
}
func marshalOpenAIUpstreamJSON(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
out := buf.Bytes()
if len(out) > 0 && out[len(out)-1] == '\n' {
out = out[:len(out)-1]
}
return out, nil
}
func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, requestedModel ...string) {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if len(requestedModel) > 0 {
@ -2561,7 +2582,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
// 确保高版本模型向低版本模型映射不报错
if !SupportsVerbosity(upstreamModel) {
if text, ok := reqBody["text"].(map[string]any); ok {
if _, exists := text["verbosity"]; exists {
delete(text, "verbosity")
bodyModified = true
markPatchDelete("text.verbosity")
}
}
}
}
@ -2762,7 +2787,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
if !serializedByPatch {
var marshalErr error
body, marshalErr = json.Marshal(reqBody)
body, marshalErr = marshalOpenAIUpstreamJSON(reqBody)
if marshalErr != nil {
return nil, fmt.Errorf("serialize request body: %w", marshalErr)
}
@ -3043,7 +3068,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
upstreamCode := extractUpstreamErrorCode(respBody)
if !httpInvalidEncryptedContentRetryTried && resp.StatusCode == http.StatusBadRequest && upstreamCode == "invalid_encrypted_content" {
if trimOpenAIEncryptedReasoningItems(reqBody) {
body, err = json.Marshal(reqBody)
body, err = marshalOpenAIUpstreamJSON(reqBody)
if err != nil {
return nil, fmt.Errorf("serialize invalid_encrypted_content retry body: %w", err)
}
@ -3074,6 +3099,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
})
s.handleFailoverSideEffects(ctx, resp, account, upstreamModel)
// reqBody 会被本次账号尝试原地修改failover 前必须释放,避免下一账号复用脏 map。
releaseOpenAIParsedRequestBody(c)
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
@ -6711,7 +6738,7 @@ func sanitizeEmptyBase64InputImagesInOpenAIBody(body []byte) ([]byte, bool, erro
if !sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody) {
return body, false, nil
}
normalized, err := json.Marshal(reqBody)
normalized, err := marshalOpenAIUpstreamJSON(reqBody)
if err != nil {
return body, false, fmt.Errorf("serialize sanitized request body: %w", err)
}
@ -6817,10 +6844,13 @@ func isEmptyBase64DataURI(raw string) bool {
}
func getOpenAIRequestBodyMap(c *gin.Context, body []byte) (map[string]any, error) {
// 同一个 gin.Context 内 failover/渠道映射可能传入新 body缓存必须先校验 body 指纹。
bodyHash := xxhash.Sum64(body)
bodyLen := len(body)
if c != nil {
if cached, ok := c.Get(OpenAIParsedRequestBodyKey); ok {
if reqBody, ok := cached.(map[string]any); ok && reqBody != nil {
return reqBody, nil
if cache, ok := cached.(openAIParsedRequestBodyCache); ok && cache.reqBody != nil && cache.bodyLen == bodyLen && cache.bodyHash == bodyHash {
return cache.reqBody, nil
}
}
}
@ -6830,11 +6860,36 @@ func getOpenAIRequestBodyMap(c *gin.Context, body []byte) (map[string]any, error
return nil, fmt.Errorf("parse request: %w", err)
}
if c != nil {
c.Set(OpenAIParsedRequestBodyKey, reqBody)
c.Set(OpenAIParsedRequestBodyKey, openAIParsedRequestBodyCache{bodyHash: bodyHash, bodyLen: bodyLen, reqBody: reqBody})
}
return reqBody, nil
}
// CacheOpenAIParsedRequestBody 仅缓存与当前 body 绑定的解析结果。
func CacheOpenAIParsedRequestBody(c *gin.Context, body []byte, reqBody map[string]any) {
if c == nil || reqBody == nil {
return
}
c.Set(OpenAIParsedRequestBodyKey, openAIParsedRequestBodyCache{
bodyHash: xxhash.Sum64(body),
bodyLen: len(body),
reqBody: reqBody,
})
}
// CachedOpenAIParsedRequestBody 只给同请求内不关心 body 参数的轻量识别逻辑使用。
func CachedOpenAIParsedRequestBody(c *gin.Context) map[string]any {
if c == nil {
return nil
}
if cached, ok := c.Get(OpenAIParsedRequestBodyKey); ok {
if cache, ok := cached.(openAIParsedRequestBodyCache); ok {
return cache.reqBody
}
}
return nil
}
func releaseOpenAIParsedRequestBody(c *gin.Context) {
if c == nil {
return

View File

@ -112,7 +112,7 @@ func TestGetOpenAIRequestBodyMap_UsesContextCache(t *testing.T) {
c, _ := gin.CreateTestContext(rec)
cached := map[string]any{"model": "cached-model", "stream": true}
c.Set(OpenAIParsedRequestBodyKey, cached)
CacheOpenAIParsedRequestBody(c, []byte(`{invalid-json`), cached)
got, err := getOpenAIRequestBodyMap(c, []byte(`{invalid-json`))
require.NoError(t, err)
@ -134,11 +134,19 @@ func TestGetOpenAIRequestBodyMap_WriteBackContextCache(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "gpt-5", got["model"])
cached, ok := c.Get(OpenAIParsedRequestBodyKey)
require.True(t, ok)
cachedMap, ok := cached.(map[string]any)
require.True(t, ok)
require.Equal(t, got, cachedMap)
require.Equal(t, got, CachedOpenAIParsedRequestBody(c))
}
func TestGetOpenAIRequestBodyMap_IgnoresCacheForDifferentBody(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
CacheOpenAIParsedRequestBody(c, []byte(`{"model":"cached-model"}`), map[string]any{"model": "cached-model"})
got, err := getOpenAIRequestBodyMap(c, []byte(`{"model":"forward-model"}`))
require.NoError(t, err)
require.Equal(t, "forward-model", got["model"])
}
func TestSanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(t *testing.T) {

View File

@ -14,6 +14,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
@ -101,6 +102,57 @@ func TestOpenAIGatewayService_ResponsesUnknownModelDoesNotFallbackToGPT54(t *tes
require.True(t, rec.Code >= http.StatusBadRequest)
}
func TestOpenAIGatewayService_NativeResponsesBodyModificationPreservesHTMLChars(t *testing.T) {
gin.SetMode(gin.TestMode)
payloadText := strings.Repeat(`<tag>&value</tag>`, 128)
originalBody := []byte(fmt.Sprintf(`{"model":"gpt-5.5","stream":false,"max_output_tokens":100,"previous_response_id":"resp_prev","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":%q}]}]}`, payloadText))
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(originalBody))
c.Request.Header.Set("Content-Type", "application/json")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_native_reencode"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop after capture"}}`)),
}}
svc := &OpenAIGatewayService{
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{
Enabled: false,
AllowInsecureHTTP: true,
}}},
httpUpstream: upstream,
}
account := &Account{
ID: 456,
Name: "openai-apikey",
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-test",
"base_url": "http://upstream.example",
},
Extra: map[string]any{
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeAuto),
openai_compat.ExtraKeyResponsesSupported: true,
},
Status: StatusActive,
Schedulable: true,
}
result, err := svc.Forward(context.Background(), c, account, originalBody)
require.Error(t, err)
require.Nil(t, result)
require.NotNil(t, upstream.lastReq)
require.Equal(t, "http://upstream.example/v1/responses", upstream.lastReq.URL.String())
require.Contains(t, string(upstream.lastBody), payloadText)
require.NotContains(t, string(upstream.lastBody), `\\u003c`)
require.NotContains(t, string(upstream.lastBody), `\\u003e`)
require.NotContains(t, string(upstream.lastBody), `\\u0026`)
}
func TestOpenAIGatewayService_OAuthMessagesBridgeDoesNotInjectDefaultInstructions(t *testing.T) {
gin.SetMode(gin.TestMode)

View File

@ -12,6 +12,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/tidwall/gjson"
)
// UserMsgQueueCache 用户消息串行队列 Redis 缓存接口
@ -62,43 +63,48 @@ func NewUserMessageQueueService(cache UserMsgQueueCache, rpmCache RPMCache, cfg
// 2. 最后一条消息 role == "user"
// 3. 最后一条消息 content如果是数组中不含 type:"tool_result" / "tool_use_result"
func IsRealUserMessage(parsed *ParsedRequest) bool {
if parsed == nil || len(parsed.Messages) == 0 {
if parsed == nil {
return false
}
messagesRaw := parsed.MessagesRaw()
if len(messagesRaw) == 0 {
return false
}
lastMsg := parsed.Messages[len(parsed.Messages)-1]
msgMap, ok := lastMsg.(map[string]any)
if !ok {
messages := gjson.ParseBytes(messagesRaw)
if !messages.IsArray() {
return false
}
lastMsg := gjson.Result{}
messages.ForEach(func(_, msg gjson.Result) bool {
lastMsg = msg
return true
})
if !lastMsg.Exists() || !lastMsg.IsObject() {
return false
}
if lastMsg.Get("role").String() != "user" {
return false
}
role, _ := msgMap["role"].(string)
if role != "user" {
return false
content := lastMsg.Get("content")
if !content.Exists() {
return true
}
if !content.IsArray() {
return true
}
// 检查 content 是否包含 tool_result 类型
content, ok := msgMap["content"]
if !ok {
return true // 没有 content 字段,视为普通用户消息
}
contentArr, ok := content.([]any)
if !ok {
return true // content 不是数组(可能是 string视为普通用户消息
}
for _, item := range contentArr {
itemMap, ok := item.(map[string]any)
if !ok {
continue
}
itemType, _ := itemMap["type"].(string)
isReal := true
content.ForEach(func(_, item gjson.Result) bool {
itemType := item.Get("type").String()
if itemType == "tool_result" || itemType == "tool_use_result" {
isReal = false
return false
}
}
return true
})
return isReal
}
// TryAcquire 尝试立即获取串行锁