mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 11:25:03 +00:00
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:
@@ -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)))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user