175 lines
4.2 KiB
Go
175 lines
4.2 KiB
Go
package openai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// forceCloseJSON repairs a tool-call document the channel cap cut off so
|
|
// badly that suffix balancing cannot fix it — e.g. a raw quote leaked
|
|
// mid-string and collapsed the structure after it. The fast path reuses
|
|
// balanceJSON (append-only damage). Otherwise the rebuild re-enters the last
|
|
// opened string literal, escapes every raw quote, stray backslash and
|
|
// control character in the dangling tail, then appends the closers the cut
|
|
// chopped off. The result is always structurally valid JSON carrying
|
|
// whatever content survived to the truncation point, so callers deliver a
|
|
// complete tool call with a partial payload instead of raw text the client
|
|
// cannot parse. ok=false means no repair validated.
|
|
func forceCloseJSON(s string) (string, bool) {
|
|
if b := balanceJSON(s); b != s {
|
|
return b, true
|
|
}
|
|
|
|
type anchor struct {
|
|
pos int
|
|
stack []byte
|
|
}
|
|
var anchors []anchor
|
|
var stack []byte
|
|
inString, escaped := false, false
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if !inString {
|
|
switch c {
|
|
case '"':
|
|
inString = true
|
|
anchors = append(anchors, anchor{pos: i, stack: append([]byte(nil), stack...)})
|
|
case '{', '[':
|
|
stack = append(stack, c)
|
|
case '}', ']':
|
|
if len(stack) > 0 {
|
|
stack = stack[:len(stack)-1]
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if escaped {
|
|
escaped = false
|
|
continue
|
|
}
|
|
switch c {
|
|
case '\\':
|
|
escaped = true
|
|
case '"':
|
|
inString = false
|
|
}
|
|
}
|
|
if len(anchors) == 0 {
|
|
return "", false
|
|
}
|
|
|
|
// Re-anchor on the most recent string literal first: it keeps the most
|
|
// surrounding structure verbatim, and anything further out folds into
|
|
// the escaped tail. Each anchor gets both closing forms.
|
|
const maxAnchors = 512
|
|
start := len(anchors) - maxAnchors
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
const quoteByte = byte(34)
|
|
for a := len(anchors) - 1; a >= start; a-- {
|
|
an := anchors[a]
|
|
body := escapeStringBody(s[an.pos+1:])
|
|
for _, isKey := range []bool{false, true} {
|
|
// The key form requires a bare key: a quote in the dangling
|
|
// text means this anchor is garbage (e.g. an invalid value
|
|
// such as 12.), and inventing a nonsense key would silently
|
|
// discard real data instead of rejecting the reply.
|
|
if isKey && strings.IndexByte(body, quoteByte) >= 0 {
|
|
continue
|
|
}
|
|
b := strings.Builder{}
|
|
b.Grow(len(s) + len(body) + len(an.stack) + 8)
|
|
b.WriteString(s[:an.pos+1])
|
|
b.WriteString(body)
|
|
if isKey {
|
|
b.WriteString("\": \"\"")
|
|
} else {
|
|
b.WriteString("\"")
|
|
}
|
|
writeClosers(&b, an.stack)
|
|
if json.Valid([]byte(b.String())) {
|
|
return b.String(), true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func writeClosers(b *strings.Builder, stack []byte) {
|
|
for i := len(stack) - 1; i >= 0; i-- {
|
|
if stack[i] == '{' {
|
|
b.WriteByte('}')
|
|
} else {
|
|
b.WriteByte(']')
|
|
}
|
|
}
|
|
}
|
|
|
|
// escapeStringBody makes dangling text safe inside a JSON string literal:
|
|
// raw quotes become escaped quotes, backslashes that are not valid JSON
|
|
// escapes get doubled, and control characters become the usual two-character
|
|
// escape sequences.
|
|
func escapeStringBody(s string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(s) + 8)
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
switch {
|
|
case c == '"':
|
|
b.WriteString(`\"`)
|
|
case c == '\\':
|
|
if j := escapeEndAt(s, i); j > i {
|
|
b.WriteString(s[i : j+1])
|
|
i = j
|
|
} else {
|
|
b.WriteString(`\\`)
|
|
}
|
|
case c < 0x20:
|
|
switch c {
|
|
case '\n':
|
|
b.WriteString(`\n`)
|
|
case '\r':
|
|
b.WriteString(`\r`)
|
|
case '\t':
|
|
b.WriteString(`\t`)
|
|
default:
|
|
fmt.Fprintf(&b, `\u%04x`, c)
|
|
}
|
|
default:
|
|
b.WriteByte(c)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// escapeEndAt reports the index of the last byte of the JSON escape sequence
|
|
// starting at the backslash s[i], or i when the sequence is missing,
|
|
// incomplete or invalid.
|
|
func escapeEndAt(s string, i int) int {
|
|
if i+1 >= len(s) {
|
|
return i
|
|
}
|
|
switch s[i+1] {
|
|
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':
|
|
return i + 1
|
|
case 'u':
|
|
if i+5 < len(s) && isHex4(s[i+2:i+6]) {
|
|
return i + 5
|
|
}
|
|
}
|
|
return i
|
|
}
|
|
|
|
func isHex4(s string) bool {
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|