Files
magpie/internal/redact/json.go
T

221 lines
5.3 KiB
Go

package redact
import (
"bytes"
"encoding/json"
"strconv"
"strings"
)
// kept are the keys whose strings are left alone: ids, names and kinds the
// vendor needs as they are, and what is sealed or encoded (a thinking
// block's signature, reasoning's encrypted content, an image's data).
var kept = map[string]bool{
"signature": true, "encrypted_content": true, "data": true, "thoughtSignature": true, "thought_signature": true,
"model": true, "id": true, "tool_use_id": true, "call_id": true, "item_id": true, "type": true, "role": true,
"name": true, "previous_response_id": true, "prompt_cache_key": true, "media_type": true, "mime_type": true,
"mimeType": true, "url": true, "image_url": true, "file_id": true, "reasoning_effort": true, "effort": true,
"stop_reason": true, "finish_reason": true, "status": true, "object": true, "event": true,
}
func keep(key, s string) bool {
return kept[key] || strings.HasPrefix(s, "data:")
}
// walk calls fn with every string value in the JSON document b — its path
// (keys and indexes joined by dots) and the key it is under — and writes
// what fn returns in its place. Everything else is kept byte for byte, and
// b comes back as it is if nothing changed; if it isn't JSON, ok is false.
func walk(b []byte, fn func(path, key, s string) string) (out []byte, ok bool) {
w := walker{in: b, fn: fn}
i, good := w.value(w.ws(0), "", "")
if !good || w.ws(i) != len(b) {
return b, false
}
if w.last == 0 {
return b, true
}
w.out.Write(b[w.last:])
return w.out.Bytes(), true
}
type walker struct {
in []byte
out bytes.Buffer
last int
fn func(path, key, s string) string
}
func (w *walker) ws(i int) int {
for i < len(w.in) && (w.in[i] == ' ' || w.in[i] == '\t' || w.in[i] == '\n' || w.in[i] == '\r') {
i++
}
return i
}
func join(path, k string) string {
if path == "" {
return k
}
return path + "." + k
}
// str is the end of the string literal that starts at i.
func (w *walker) str(i int) (end int, escaped, ok bool) {
for j := i + 1; j < len(w.in); j++ {
switch w.in[j] {
case '\\':
escaped = true
j++
case '"':
return j + 1, escaped, true
}
}
return 0, false, false
}
func (w *walker) value(i int, path, key string) (int, bool) {
if i >= len(w.in) {
return i, false
}
switch c := w.in[i]; c {
case '{':
i = w.ws(i + 1)
if i < len(w.in) && w.in[i] == '}' {
return i + 1, true
}
for {
if i >= len(w.in) || w.in[i] != '"' {
return i, false
}
end, esc, ok := w.str(i)
if !ok {
return i, false
}
k := string(w.in[i+1 : end-1])
if esc {
if json.Unmarshal(w.in[i:end], &k) != nil {
return i, false
}
}
i = w.ws(end)
if i >= len(w.in) || w.in[i] != ':' {
return i, false
}
if i, ok = w.value(w.ws(i+1), join(path, k), k); !ok {
return i, false
}
i = w.ws(i)
if i < len(w.in) && w.in[i] == ',' {
i = w.ws(i + 1)
continue
}
if i < len(w.in) && w.in[i] == '}' {
return i + 1, true
}
return i, false
}
case '[':
i = w.ws(i + 1)
if i < len(w.in) && w.in[i] == ']' {
return i + 1, true
}
for n := 0; ; n++ {
var ok bool
// an array's strings are under the key the array is
if i, ok = w.value(i, join(path, strconv.Itoa(n)), key); !ok {
return i, false
}
i = w.ws(i)
if i < len(w.in) && w.in[i] == ',' {
i = w.ws(i + 1)
continue
}
if i < len(w.in) && w.in[i] == ']' {
return i + 1, true
}
return i, false
}
case '"':
end, esc, ok := w.str(i)
if !ok {
return i, false
}
s := string(w.in[i+1 : end-1])
if esc && json.Unmarshal(w.in[i:end], &s) != nil {
return i, false
}
if t := w.fn(path, key, s); t != s {
w.out.Write(w.in[w.last:i])
w.out.WriteByte('"')
w.out.WriteString(jsonEscape(t))
w.out.WriteByte('"')
w.last = end
}
return end, true
default:
j := i
for j < len(w.in) && !strings.ContainsRune(",]} \t\r\n", rune(w.in[j])) {
j++
}
if j == i {
return i, false
}
return j, true
}
}
// jsonEscape is s as it goes between the quotes of a JSON string.
func jsonEscape(s string) string {
var b bytes.Buffer
e := json.NewEncoder(&b)
e.SetEscapeHTML(false)
_ = e.Encode(s)
out := b.Bytes()
return string(out[1 : len(out)-2]) // the quotes and the newline
}
// MaskJSON masks the strings of a request body, and says how many values
// it masked. A body that isn't JSON is masked as text.
func MaskJSON(body []byte, o Options) ([]byte, int) {
total := 0
out, ok := walk(body, func(_, key, s string) string {
if keep(key, s) {
return s
}
t, n := Mask(s, o)
total += n
return t
})
if !ok {
t, n := Mask(string(body), o)
return []byte(t), n
}
return out, total
}
// asJSON says a string under key holds JSON text of its own, so a value
// put back in it goes in escaped: a tool call's arguments, as a whole or
// streamed in pieces.
func asJSON(key, eventType string) bool {
return key == "arguments" || key == "partial_json" || key == "delta" && strings.Contains(eventType, "arguments")
}
// RestoreJSON puts the values back in a response body.
func RestoreJSON(body []byte) []byte {
if !bytes.Contains(body, []byte("{{")) {
return body
}
typ := ""
out, ok := walk(body, func(path, key, s string) string {
if path == "type" {
typ = s
}
return Restore(s, asJSON(key, typ))
})
if !ok {
return []byte(Restore(string(body), false))
}
return out
}