mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-09 18:57:43 +08:00
224 lines
4.9 KiB
Go
224 lines
4.9 KiB
Go
package openai
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"ds2api/internal/util"
|
|
)
|
|
|
|
type toolStreamSieveState struct {
|
|
pending strings.Builder
|
|
capture strings.Builder
|
|
capturing bool
|
|
}
|
|
|
|
type toolStreamEvent struct {
|
|
Content string
|
|
ToolCalls []util.ParsedToolCall
|
|
}
|
|
|
|
func processToolSieveChunk(state *toolStreamSieveState, chunk string, toolNames []string) []toolStreamEvent {
|
|
if state == nil {
|
|
return nil
|
|
}
|
|
if chunk != "" {
|
|
state.pending.WriteString(chunk)
|
|
}
|
|
events := make([]toolStreamEvent, 0, 2)
|
|
|
|
for {
|
|
if state.capturing {
|
|
if state.pending.Len() > 0 {
|
|
state.capture.WriteString(state.pending.String())
|
|
state.pending.Reset()
|
|
}
|
|
prefix, calls, suffix, ready := consumeToolCapture(state.capture.String(), toolNames)
|
|
if !ready {
|
|
break
|
|
}
|
|
state.capture.Reset()
|
|
state.capturing = false
|
|
if prefix != "" {
|
|
events = append(events, toolStreamEvent{Content: prefix})
|
|
}
|
|
if len(calls) > 0 {
|
|
events = append(events, toolStreamEvent{ToolCalls: calls})
|
|
}
|
|
if suffix != "" {
|
|
state.pending.WriteString(suffix)
|
|
}
|
|
continue
|
|
}
|
|
|
|
pending := state.pending.String()
|
|
if pending == "" {
|
|
break
|
|
}
|
|
start := findToolSegmentStart(pending)
|
|
if start >= 0 {
|
|
prefix := pending[:start]
|
|
if prefix != "" {
|
|
events = append(events, toolStreamEvent{Content: prefix})
|
|
}
|
|
state.pending.Reset()
|
|
state.capture.WriteString(pending[start:])
|
|
state.capturing = true
|
|
continue
|
|
}
|
|
|
|
safe, hold := splitSafeContentForToolDetection(pending)
|
|
if safe == "" {
|
|
break
|
|
}
|
|
state.pending.Reset()
|
|
state.pending.WriteString(hold)
|
|
events = append(events, toolStreamEvent{Content: safe})
|
|
}
|
|
|
|
return events
|
|
}
|
|
|
|
func flushToolSieve(state *toolStreamSieveState, toolNames []string) []toolStreamEvent {
|
|
if state == nil {
|
|
return nil
|
|
}
|
|
events := processToolSieveChunk(state, "", toolNames)
|
|
if state.capturing {
|
|
consumedPrefix, consumedCalls, consumedSuffix, ready := consumeToolCapture(state.capture.String(), toolNames)
|
|
if ready {
|
|
if consumedPrefix != "" {
|
|
events = append(events, toolStreamEvent{Content: consumedPrefix})
|
|
}
|
|
if len(consumedCalls) > 0 {
|
|
events = append(events, toolStreamEvent{ToolCalls: consumedCalls})
|
|
}
|
|
if consumedSuffix != "" {
|
|
events = append(events, toolStreamEvent{Content: consumedSuffix})
|
|
}
|
|
} else {
|
|
// Incomplete captured tool JSON at stream end: suppress raw capture.
|
|
}
|
|
state.capture.Reset()
|
|
state.capturing = false
|
|
}
|
|
if state.pending.Len() > 0 {
|
|
events = append(events, toolStreamEvent{Content: state.pending.String()})
|
|
state.pending.Reset()
|
|
}
|
|
return events
|
|
}
|
|
|
|
func splitSafeContentForToolDetection(s string) (safe, hold string) {
|
|
if s == "" {
|
|
return "", ""
|
|
}
|
|
suspiciousStart := findSuspiciousPrefixStart(s)
|
|
if suspiciousStart < 0 {
|
|
return s, ""
|
|
}
|
|
if suspiciousStart > 0 {
|
|
return s[:suspiciousStart], s[suspiciousStart:]
|
|
}
|
|
// If suspicious content starts at position 0, keep holding until we can
|
|
// parse a complete tool JSON block or reach stream flush.
|
|
return "", s
|
|
}
|
|
|
|
func findSuspiciousPrefixStart(s string) int {
|
|
start := -1
|
|
indices := []int{
|
|
strings.LastIndex(s, "{"),
|
|
strings.LastIndex(s, "["),
|
|
strings.LastIndex(s, "```"),
|
|
}
|
|
for _, idx := range indices {
|
|
if idx > start {
|
|
start = idx
|
|
}
|
|
}
|
|
return start
|
|
}
|
|
|
|
func findToolSegmentStart(s string) int {
|
|
if s == "" {
|
|
return -1
|
|
}
|
|
lower := strings.ToLower(s)
|
|
keyIdx := strings.Index(lower, "tool_calls")
|
|
if keyIdx < 0 {
|
|
return -1
|
|
}
|
|
if start := strings.LastIndex(s[:keyIdx], "{"); start >= 0 {
|
|
return start
|
|
}
|
|
return keyIdx
|
|
}
|
|
|
|
func consumeToolCapture(captured string, toolNames []string) (prefix string, calls []util.ParsedToolCall, suffix string, ready bool) {
|
|
if captured == "" {
|
|
return "", nil, "", false
|
|
}
|
|
lower := strings.ToLower(captured)
|
|
keyIdx := strings.Index(lower, "tool_calls")
|
|
if keyIdx < 0 {
|
|
return "", nil, "", false
|
|
}
|
|
start := strings.LastIndex(captured[:keyIdx], "{")
|
|
if start < 0 {
|
|
return "", nil, "", false
|
|
}
|
|
obj, end, ok := extractJSONObjectFrom(captured, start)
|
|
if !ok {
|
|
return "", nil, "", false
|
|
}
|
|
parsed := util.ParseToolCalls(obj, toolNames)
|
|
if len(parsed) == 0 {
|
|
// `tool_calls` key exists but strict JSON parse failed.
|
|
// Drop the captured object body to avoid leaking raw tool JSON.
|
|
return captured[:start], nil, captured[end:], true
|
|
}
|
|
return captured[:start], parsed, captured[end:], true
|
|
}
|
|
|
|
func extractJSONObjectFrom(text string, start int) (string, int, bool) {
|
|
if start < 0 || start >= len(text) || text[start] != '{' {
|
|
return "", 0, false
|
|
}
|
|
depth := 0
|
|
quote := byte(0)
|
|
escaped := false
|
|
for i := start; i < len(text); i++ {
|
|
ch := text[i]
|
|
if quote != 0 {
|
|
if escaped {
|
|
escaped = false
|
|
continue
|
|
}
|
|
if ch == '\\' {
|
|
escaped = true
|
|
continue
|
|
}
|
|
if ch == quote {
|
|
quote = 0
|
|
}
|
|
continue
|
|
}
|
|
if ch == '"' || ch == '\'' {
|
|
quote = ch
|
|
continue
|
|
}
|
|
if ch == '{' {
|
|
depth++
|
|
continue
|
|
}
|
|
if ch == '}' {
|
|
depth--
|
|
if depth == 0 {
|
|
end := i + 1
|
|
return text[start:end], end, true
|
|
}
|
|
}
|
|
}
|
|
return "", 0, false
|
|
}
|