Merge pull request #6702 from a1gaoshanlaiyige/fix/message-cache-json-escaping

fix(gateway): use JSON escaping for message cache promotion
This commit is contained in:
Wesley Liddick
2026-09-07 17:30:54 +08:00
committed by GitHub
2 changed files with 64 additions and 1 deletions
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"fmt"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
@@ -157,5 +158,8 @@ func injectCacheControlOnLastContentBlock(body []byte, idx int, msg *gjson.Resul
// mustJSONString 把一个 Go string 序列化为合法 JSON string(含引号),
// 用于 sjson.SetRawBytes 场景下手工拼 JSON。
func mustJSONString(s string) string {
return fmt.Sprintf("%q", s)
// Go string quoting can emit non-JSON escapes such as \x7f or \a.
// Marshaling a string cannot fail.
encoded, _ := json.Marshal(s)
return string(encoded)
}
@@ -0,0 +1,59 @@
//go:build unit
package service
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAddMessageCacheBreakpoints_JSONStringEscaping(t *testing.T) {
var controls strings.Builder
for r := rune(0); r <= 0x9f; r++ {
controls.WriteRune(r)
}
tests := []struct{ name, text string }{
{"delete_character", "before\x7fafter"},
{"bell_and_vertical_tab", "before\a\vafter"},
{"nul_and_escape", "before\x00\x1bafter"},
{"all_ascii_and_c1_controls", controls.String()},
{"supplementary_nonprinting_rune", "before\U000e0067after"},
{"unicode_and_json_metacharacters", "中文 😀 <>& \"quote\" \\slash\n\r\t\b\f"},
{"literal_backslash_sequences", `literal \x7f \a \v \U000e0067`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Both the final message and the second-to-last user turn are promoted
// from strings to text blocks by the cache rewrite.
body, err := json.Marshal(map[string]any{
"messages": []map[string]any{
{"role": "user", "content": tt.text},
{"role": "assistant", "content": "ack"},
{"role": "user", "content": "next"},
{"role": "assistant", "content": tt.text},
},
})
require.NoError(t, err)
out := addMessageCacheBreakpoints(body)
// gjson accepts some malformed input, so validate with encoding/json
// before inspecting the rewritten content and cache metadata.
var decoded any
require.NoError(t, json.Unmarshal(out, &decoded))
for _, path := range []string{"messages.0.content", "messages.3.content"} {
content := gjson.GetBytes(out, path)
require.True(t, content.IsArray())
require.Len(t, content.Array(), 1)
block := content.Array()[0]
require.Equal(t, "text", block.Get("type").String())
require.Equal(t, tt.text, block.Get("text").String())
require.Equal(t, "ephemeral", block.Get("cache_control.type").String())
require.Equal(t, "5m", block.Get("cache_control.ttl").String())
}
require.JSONEq(t, string(out), string(addMessageCacheBreakpoints(out)))
})
}
}