Files
dsfree2api/internal/openai/argstream.go
T
nyoungo 86492460b0 feat: 工具调用解析、Responses 状态缓存、幂等重放与模型别名
- 重写工具调用解析:XML、DSML 未加引号键、fence 内跳过、参数外溢等形状,并按请求携带的 tools 过滤工具名
- 新增 GET /v1/responses/{id} 与 previous_response_id 有界状态缓存(进程内、TTL、store=false 不写入)
- /v1/chat/completions 支持 Idempotency-Key 幂等回放(1 MiB 上限、24h TTL、并发去重)
- 模型别名与 default_model 降级;错误体统一输出 param/code
- 补 e2e 修复场景与 docs/compat-audit.md 兼容性审计
2026-10-09 00:50:01 +08:00

445 lines
12 KiB
Go

package openai
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
// ToolArgDelta is one incremental tool_calls delta produced by ArgStreamer.
// Empty fields are omitted from the wire chunk.
type ToolArgDelta struct {
Index int
ID string
Name string
Args string
}
// ArgStreamer turns raw tool-call JSON that grows over time into OpenAI-style
// incremental tool_calls deltas so tool requests stream instead of buffering.
//
// The raw document is re-scanned on every Feed; the decoded arguments text is
// a prefix-monotonic function of the document, so each delta is exactly the
// new suffix. Finalize reconciles the stream with the repaired parse result
// (balanceJSON / EscapeControlChars / forceCloseJSON) and reports any call
// that could not be streamed.
type ArgStreamer struct {
sent map[int]string // args text already written downstream
derived map[int]string // latest full decode (incl. hold-back tail)
idSent map[int]string
nameSent map[int]string
seen map[int]bool
nostream map[int]bool // arguments delivered as object/number — resend whole
diverge string // first divergence context, set by Finalize
}
// NewArgStreamer creates a streamer for one request.
func NewArgStreamer() *ArgStreamer {
return &ArgStreamer{
sent: map[int]string{},
derived: map[int]string{},
idSent: map[int]string{},
nameSent: map[int]string{},
seen: map[int]bool{},
nostream: map[int]bool{},
}
}
// Sent reports whether any delta was written downstream.
func (a *ArgStreamer) Sent() bool { return len(a.seen) > 0 }
// Diverge returns a short diagnostic of where Finalize found the repaired
// arguments diverging from the streamed text (empty when clean).
func (a *ArgStreamer) Diverge() string { return a.diverge }
func (a *ArgStreamer) noteDiverge(idx int, target, emit, sent string) {
if a.diverge != "" {
return
}
at := firstDiffIndex(target, emit)
if d := firstDiffIndex(target, sent); d < at {
at = d
}
a.diverge = fmt.Sprintf("idx=%d tLen=%d eLen=%d sLen=%d diff@%d target=%q emit=%q",
idx, len(target), len(emit), len(sent), at, ctxWindow(target, at), ctxWindow(emit, at))
}
func firstDiffIndex(a, b string) int {
n := len(a)
if len(b) < n {
n = len(b)
}
for i := 0; i < n; i++ {
if a[i] != b[i] {
return i
}
}
return n
}
func ctxWindow(s string, at int) string {
const win = 60
start := at - win
if start < 0 {
start = 0
}
end := at + win
if end > len(s) {
end = len(s)
}
return s[start:end]
}
type argCallState struct {
derived string
id string
name string
nostream bool
}
// Feed scans the full accumulated document and returns the deltas to send.
func (a *ArgStreamer) Feed(text string) []ToolArgDelta {
states := a.scan(text)
var out []ToolArgDelta
for i, st := range states {
if st.nostream {
a.nostream[i] = true
continue
}
if a.nostream[i] {
continue
}
emit := trimHold(st.derived)
prev := a.sent[i]
if !strings.HasPrefix(emit, prev) {
continue // defensive: decode regressed (should be monotonic)
}
d := ToolArgDelta{Index: i}
send := false
if a.idSent[i] == "" && st.id != "" {
d.ID = st.id
a.idSent[i] = st.id
send = true
}
if st.name != "" && a.nameSent[i] != st.name {
d.Name = st.name
a.nameSent[i] = st.name
send = true
}
if args := emit[len(prev):]; args != "" {
d.Args = args
send = true
}
if send && a.idSent[i] == "" {
// The model's payload carries no id (yet): the first delta for an
// index must still carry a non-empty one — clients assemble
// tool_calls from deltas and echo the id back as tool_call_id on
// the next turn, where an empty id fails strict validation with
// "tool messages must include a non-empty string tool_call_id".
d.ID = st.id
if d.ID == "" {
d.ID = "call_" + randomHex(8)
}
a.idSent[i] = d.ID
}
a.sent[i] = emit
a.derived[i] = st.derived
if send {
a.seen[i] = true
out = append(out, d)
}
}
return out
}
// Finalize reconciles the stream against the final parsed tool calls.
// deltas must be sent before the finish chunk; resend holds calls that were
// never streamed (send them in full, with their index). diverged signals that
// the repaired arguments are not an extension of what was already streamed —
// the client keeps the streamed text in that case.
func (a *ArgStreamer) Finalize(calls []ToolCall) (deltas []ToolArgDelta, resend []ToolCall, diverged bool) {
for i, c := range calls {
if a.nostream[i] || !a.seen[i] {
idx := i
c.Index = &idx
resend = append(resend, c)
continue
}
target := c.Function.Arguments
emit := trimHold(a.derived[i])
sent := a.sent[i]
d := ToolArgDelta{Index: i}
flush := ""
switch {
case strings.HasPrefix(target, emit):
// append-only repair (closers etc.): send the rest incl. held tail
flush = target[len(sent):]
case strings.HasPrefix(emit, target):
// repair dropped the held tail (dangling comma/whitespace). If the
// client has not been sent beyond the new end, flush up to it;
// otherwise it keeps extra text and we can only report it.
if strings.HasPrefix(target, sent) {
flush = target[len(sent):]
} else {
diverged = true
a.noteDiverge(i, target, emit, sent)
}
case strings.HasPrefix(target, sent):
// repair only rewrote the unsent held region: catch the client up
flush = target[len(sent):]
default:
// mid-document cut (forceCloseJSON anchor rebuild): already sent
// text can differ from the repair — report and keep the stream.
diverged = true
a.noteDiverge(i, target, emit, sent)
}
if flush != "" {
d.Args = flush
}
if a.idSent[i] == "" && c.ID != "" {
d.ID = c.ID
}
if c.Function.Name != "" && a.nameSent[i] != c.Function.Name {
d.Name = c.Function.Name
}
if d.Args != "" || d.ID != "" || d.Name != "" {
deltas = append(deltas, d)
}
}
return deltas, resend, diverged
}
var (
argsKeyWord = []byte(`"arguments"`)
fenceRe = regexp.MustCompile("```")
tcNameRe = regexp.MustCompile(`"name"\s*:\s*"((?:[^"\\]|\\.)*)"`)
tcIDRe = regexp.MustCompile(`"id"\s*:\s*"((?:[^"\\]|\\.)*)"`)
)
func (a *ArgStreamer) scan(text string) []argCallState {
var states []argCallState
if !extractableToolJSON(text) {
return states
}
// Only scan the payload: with prose in front the arguments keys before
// it must not count as tool calls. A fenced payload starts after its
// fence; fences later than the payload live inside a string value and
// must not move the base.
base := 0
if !strings.HasPrefix(strings.TrimSpace(text), "{") {
if tc := strings.Index(text, `{"tool_calls"`); tc > 0 {
base = tc
} else if f := fenceRe.FindStringIndex(text); f != nil {
base = f[1]
}
}
prevEnd := base
off := base
for {
j := strings.Index(text[off:], string(argsKeyWord))
if j < 0 {
break
}
pos := off + j
off = pos + len(argsKeyWord)
winStart := prevEnd
if pos-winStart > 8192 {
winStart = pos - 8192
}
if winStart > pos {
winStart = pos
}
id, name := lookbackIDName(text[winStart:pos])
k := skipSpace(text, pos+len(argsKeyWord))
if k >= len(text) || text[k] != ':' {
continue // the word without key syntax
}
st := argCallState{id: id, name: name}
v := skipSpace(text, k+1)
if v >= len(text) {
prevEnd = len(text)
} else {
switch text[v] {
case '"':
decoded, end := decodeArgsValue(text, v+1)
st.derived = innerEscape(decoded)
prevEnd = end
case '{', '[':
st.nostream = true
prevEnd = v + 1
default:
st.nostream = true
prevEnd = v + 1
}
}
states = append(states, st)
}
return states
}
// extractableToolJSON mirrors the candidate gate in TryParseToolCalls: the
// document must look like it could become a tool_calls payload — starting
// with the JSON, fenced, or with prose in front of the payload.
func extractableToolJSON(text string) bool {
t := strings.TrimSpace(text)
if strings.HasPrefix(t, "{") {
return strings.Contains(t, `"tool_calls"`)
}
return strings.Contains(t, `{"tool_calls"`)
}
func skipSpace(s string, i int) int {
for i < len(s) {
switch s[i] {
case ' ', '\t', '\r', '\n':
i++
default:
return i
}
}
return i
}
// decodeArgsValue decodes the outer-JSON string value starting after the
// opening quote at start. It returns the decoded inner text and the index
// after the closing quote (or len(text) when the value is still open). An
// incomplete escape pair at the end of text is withheld for the next scan.
func decodeArgsValue(text string, start int) (string, int) {
var b strings.Builder
i := start
for i < len(text) {
c := text[i]
if c == '\\' {
if i+1 >= len(text) {
break
}
c2 := text[i+1]
switch c2 {
case '"', '\\', '/':
b.WriteByte(c2)
case 'b':
b.WriteByte('\b')
case 'f':
b.WriteByte('\f')
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 't':
b.WriteByte('\t')
case 'u':
r, n := decodeUnicodeEscape(text, i)
if n == 0 {
if i+6 > len(text) {
// incomplete at end: withhold for the next scan
return b.String(), i
}
// malformed but complete (\uZZZZ): emit raw — the
// inner heal doubles the backslash, like the parse path
b.WriteString(`\u`)
i += 2
continue
}
b.WriteRune(r)
i += n
continue
default:
// invalid outer escape (…\d): keep the backslash as inner
// text so the inner heal matches the repaired parse
b.WriteByte('\\')
b.WriteByte(c2)
}
i += 2
continue
}
if c == '"' {
return b.String(), i + 1
}
b.WriteByte(c)
i++
}
return b.String(), len(text)
}
// decodeUnicodeEscape decodes \uXXXX (and surrogate pairs) at text[i].
// It returns the rune and total sequence length, or (0, 0) when incomplete.
func decodeUnicodeEscape(text string, i int) (rune, int) {
if i+6 > len(text) || text[i+1] != 'u' || !isHex4(text[i+2:i+6]) {
return 0, 0
}
var r rune
if _, err := fmt.Sscanf(text[i+2:i+6], "%x", &r); err != nil {
return 0, 0
}
if r >= 0xD800 && r <= 0xDBFF {
// high surrogate — needs a low surrogate to follow
if i+12 <= len(text) && text[i+6] == '\\' && text[i+7] == 'u' && isHex4(text[i+8:i+12]) {
var lo rune
if _, err := fmt.Sscanf(text[i+8:i+12], "%x", &lo); err == nil && lo >= 0xDC00 && lo <= 0xDFFF {
return 0x10000 + (r-0xD800)<<10 + (lo - 0xDC00), 12
}
}
return r, 6 // lone surrogate — emit as-is, clients tolerate
}
return r, 6
}
// innerEscape applies EscapeControlChars semantics to decoded inner text.
func innerEscape(decoded string) string {
if !needsEscape(decoded) {
return decoded
}
var st escapeState
var out strings.Builder
out.Grow(len(decoded) + 16)
st.feed(decoded, &out)
return out.String()
}
// trimHold strips the suffix that balanceJSON would trim or drop when the
// channel cap stops the reply there: whitespace, a dangling comma/colon, or a
// lone backslash. Held text is released by a later Feed or Finalize.
func trimHold(s string) string {
i := len(s)
for i > 0 {
switch s[i-1] {
case ' ', '\t', '\r', '\n', ',', ':':
i--
default:
goto backslash
}
}
backslash:
if i > 0 && s[i-1] == '\\' {
run := 0
for j := i - 1; j >= 0 && s[j] == '\\'; j-- {
run++
}
if run%2 == 1 {
i--
}
}
return s[:i]
}
// lookbackIDName finds the id / name of the tool call that owns the upcoming
// arguments key: the last occurrence before it within the local window.
func lookbackIDName(window string) (id, name string) {
if m := tcIDRe.FindAllStringSubmatch(window, -1); len(m) > 0 {
id = decodeJSONString(m[len(m)-1][1])
}
if m := tcNameRe.FindAllStringSubmatch(window, -1); len(m) > 0 {
name = decodeJSONString(m[len(m)-1][1])
}
return id, name
}
func decodeJSONString(s string) string {
var out string
if err := json.Unmarshal([]byte(`"`+s+`"`), &out); err == nil {
return out
}
return s
}