fix(gateway): use JSON escaping for message cache promotion

Use encoding/json instead of Go string quoting when promoting message content to cached text blocks. Add regression coverage for control characters, Unicode, literal backslashes, text preservation, and idempotence.
This commit is contained in:
a1gaoshanlaiyige
2026-09-06 16:42:57 +08:00
committed by GitHub
parent ab99d56e96
commit 0b061c5fa1
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)))
})
}
}