Files
Agent-Native/runtime/llm/sse.go
T
agent-exec-transport 899fd70d27
ci / docker-checkout-hostgateway (pull_request) Successful in 12s
governance / governance (pull_request) Successful in 35s
ci / vet-and-test (pull_request) Successful in 1m24s
agent-exec transport e38bcf1cbe415c5d6a3e91773d811b0a/445c377b4484
2026-10-20 04:03:18 +00:00

536 lines
15 KiB
Go

package llm
import (
"bufio"
"context"
"encoding/json"
"errors"
"io"
"strings"
)
type sseChoice struct {
Delta struct {
Content string `json:"content"`
Reasoning string `json:"reasoning_content"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
}
type sseChunk struct {
Choices []sseChoice `json:"choices"`
Usage *usageWire `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
type usageWire struct {
Usage
Details struct {
Cached int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
}
const (
maxObservedToolFragments = 10000
maxObservedToolArgumentBytes = 4*1024*1024 + 1
maxObservedTailEvents = 10000
)
// sseFacts are structural stream facts the route contract needs. They are
// written by the parser goroutine before it sends the terminal event.
type sseFacts struct {
// finishChunks counts chunks with a non-empty finish_reason.
finishChunks int
finishReason string
// multiChoice is set when any chunk carried more than one choice.
multiChoice bool
// fragmentAfterFinish is set when a tool-call fragment arrived in a chunk
// after the one carrying finish_reason.
fragmentAfterFinish bool
}
// sseHooks are internal parser options. The zero value is ParseSSE.
type sseHooks struct {
facts *sseFacts
// decideEOF, when set, receives a tool stream whose terminal would be
// "tool call stream ended before [DONE]": success is the message_end the
// stream would end with had [DONE] arrived, failure is the event emitted
// today. It returns the event to send.
decideEOF func(success, failure Event) Event
}
// ParseSSE reads an OpenAI-compatible server-sent event body and emits
// deltas plus one terminal message_end or error event.
func ParseSSE(ctx context.Context, r io.Reader) <-chan Event {
return parseSSE(ctx, r, sseHooks{})
}
func parseSSE(ctx context.Context, r io.Reader, hooks sseHooks) <-chan Event {
out := make(chan Event)
go func() {
defer close(out)
emitParsed(ctx, r, out, hooks)
}()
return out
}
func emitParsed(ctx context.Context, r io.Reader, out chan<- Event, hooks sseHooks) {
facts := hooks.facts
if facts == nil {
facts = &sseFacts{}
}
var content strings.Builder
var reasoning strings.Builder
calls := map[int]*ToolCall{}
order := []int{}
var usage Usage
finished := ""
sawDone := false
sawUsage := false
readOutcome := "clean_eof"
lastFrameJSONComplete := false
toolFragments := 0
toolArgumentBytes := 0
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
reader := &sseEventReader{sc: sc}
observation := func() *TerminalObservation {
tailKind := reader.tailKind
if tailKind == "" {
tailKind = "unknown"
}
argumentsValid := len(order) > 0
for _, idx := range order {
var args map[string]json.RawMessage
if err := json.Unmarshal([]byte(calls[idx].Arguments), &args); err != nil || args == nil {
argumentsValid = false
break
}
}
return &TerminalObservation{
ReadOutcome: readOutcome, LastFrameJSONComplete: lastFrameJSONComplete,
UsagePresent: sawUsage, ToolFragments: toolFragments,
ToolArgumentBytes: toolArgumentBytes, ToolArgumentsValid: argumentsValid,
TailEventKind: tailKind, TailEventCount: reader.tailCount,
}
}
buildTerminalFailure := func(why string) Event {
message := Message{Role: RoleAssistant, Content: content.String(), StopReason: StopError}
if reasoning.Len() > 0 {
message.Thinking = []ThinkingBlock{{Thinking: reasoning.String()}}
}
failed := terminalStreamFailure(message, usage, finished, sawDone, why)
failed.TerminalObservation = observation()
return failed
}
sendTerminal := func(ev Event) {
select {
case <-ctx.Done():
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
case out <- ev:
}
}
sendTerminalFailure := func(why string) {
sendTerminal(buildTerminalFailure(why))
}
buildMessageEnd := func() Event {
msg := Message{Role: RoleAssistant, Content: content.String(), StopReason: mapFinish(finished, len(order) > 0)}
if reasoning.Len() > 0 {
msg.Thinking = []ThinkingBlock{{Thinking: reasoning.String()}}
}
for _, idx := range order {
msg.ToolCalls = append(msg.ToolCalls, *calls[idx])
}
if msg.StopReason == StopToolCalls && len(msg.ToolCalls) == 0 {
msg.StopReason = StopEnd
}
return Event{Kind: KindMessageEnd, Message: msg, Usage: usage, FinishReason: finished, StreamComplete: sawDone, TerminalObservation: observation()}
}
for {
data, ok := reader.next()
if !ok {
break
}
if ctx.Err() != nil {
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return
}
if data == "[DONE]" {
reader.noteTail("done_literal")
sawDone = true
readOutcome = "done_marker"
break
}
var chunk sseChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
reader.noteTail(badFrameKind(data, err))
readOutcome = "invalid_frame"
lastFrameJSONComplete = false
if sawUsage {
sendTerminalFailure("malformed stream chunk after reported usage")
return
}
emitError(out, content.String(), false, "malformed stream chunk: "+err.Error(), observation())
return
}
reader.noteTail("json_frame")
lastFrameJSONComplete = true
if chunk.Error != nil && chunk.Error.Message != "" {
readOutcome = "provider_error"
msg := chunk.Error.Message
if sawUsage {
sendTerminalFailure("provider error after reported usage: " + msg)
return
}
retryable := CapacityLimited(msg) && !ContextOverflow(msg)
emitError(out, content.String(), retryable, msg, observation())
return
}
if chunk.Usage != nil {
usage = chunk.Usage.Usage
sawUsage = true
if chunk.Usage.Details.Cached > 0 {
usage.CacheReadTokens = chunk.Usage.Details.Cached
}
}
if len(chunk.Choices) > 1 {
facts.multiChoice = true
}
finishedBefore := facts.finishChunks > 0
for _, c := range chunk.Choices {
if c.FinishReason != "" {
if facts.finishChunks < maxObservedTailEvents {
facts.finishChunks++
}
break
}
}
if len(chunk.Choices) == 0 {
continue
}
choice := chunk.Choices[0]
if choice.FinishReason != "" {
finished = choice.FinishReason
facts.finishReason = finished
}
if finishedBefore && len(choice.Delta.ToolCalls) > 0 {
facts.fragmentAfterFinish = true
}
if choice.Delta.Content != "" {
content.WriteString(choice.Delta.Content)
select {
case <-ctx.Done():
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return
case out <- Event{Kind: KindTextDelta, Text: choice.Delta.Content}:
}
}
// reasoning_content(国产 OpenAI 兼容模型的思考链)与正文分流:
// 只作为 reasoning_delta 事件外发,不计入 Message.Content(与 runner
// stripThinkBlocks"正文无 think"语义对齐)。同时累计进 reasoning——
// 交错思考模型(MiniMax/DeepSeek R1 等)要求历史 assistant 原样带回
// 思考内容,丢弃会降低后续轮质量;捕获进 Message.Thinking 随会话
// 转录持久化,请求序列化侧按需回放。
if choice.Delta.Reasoning != "" {
reasoning.WriteString(choice.Delta.Reasoning)
select {
case <-ctx.Done():
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return
case out <- Event{Kind: KindReasoningDelta, ReasoningDelta: choice.Delta.Reasoning}:
}
}
for _, tc := range choice.Delta.ToolCalls {
if toolFragments < maxObservedToolFragments {
toolFragments++
}
if n := len(tc.Function.Arguments); n > 0 && toolArgumentBytes < maxObservedToolArgumentBytes {
if n > maxObservedToolArgumentBytes-toolArgumentBytes {
toolArgumentBytes = maxObservedToolArgumentBytes
} else {
toolArgumentBytes += n
}
}
cur, ok := calls[tc.Index]
if !ok {
cur = &ToolCall{}
calls[tc.Index] = cur
order = append(order, tc.Index)
}
if tc.ID != "" {
cur.ID = tc.ID
}
if tc.Function.Name != "" {
cur.Name += tc.Function.Name
}
if tc.Function.Arguments != "" {
cur.Arguments += tc.Function.Arguments
}
select {
case <-ctx.Done():
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return
case out <- Event{
Kind: KindToolCallDelta,
ToolIndex: tc.Index,
ToolID: tc.ID,
ToolName: tc.Function.Name,
ArgumentsDelta: tc.Function.Arguments,
}:
}
}
}
if err := sc.Err(); err != nil {
readOutcome = "read_error"
if ctx.Err() != nil {
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return
}
if sawUsage {
sendTerminalFailure("stream read failed after reported usage")
return
}
emitError(out, content.String(), true, err.Error(), observation())
return
}
// 取消必须压过正常结束。select 在两个分支都就绪时随机挑选,
// 竞态下会把 message_end 发给已经取消的调用方。
if ctx.Err() != nil {
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return
}
// A finish reason alone does not prove the tool arguments arrived intact.
// Preserve returned usage when rejecting a partial terminal, so the host
// can charge this call and stop rather than treating it as a free retry.
if (finished == "tool_calls" || finished == "function_call") && len(order) == 0 {
sendTerminalFailure("tool call finish reason without a tool call")
return
}
if len(order) > 0 {
problem := ""
switch {
case !sawDone:
problem = "tool call stream ended before [DONE]"
case !toolFinishExecutable(finished, sawDone):
problem = "tool call ended without a supported finish reason"
default:
for _, idx := range order {
call := calls[idx]
var args map[string]json.RawMessage
if call.ID == "" || call.Name == "" {
problem = "tool call identity is incomplete"
break
}
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil || args == nil {
problem = "tool call arguments are not a JSON object"
break
}
}
}
if problem != "" {
if !sawDone && hooks.decideEOF != nil {
sendTerminal(hooks.decideEOF(buildMessageEnd(), buildTerminalFailure(problem)))
return
}
sendTerminalFailure(problem)
return
}
}
sendTerminal(buildMessageEnd())
}
func terminalStreamFailure(message Message, usage Usage, reason string, complete bool, why string) Event {
err := &StreamError{Message: why, Retryable: false}
return Event{Kind: KindMessageEnd, Message: message, Usage: usage,
FinishReason: reason, StreamComplete: complete, Err: err,
ErrorMessage: why, Retryable: false}
}
// sseEventReader 按空行切事件,并把同一事件里的多行 data: 用换行拼回。
// 已经是完整 JSON 的 data 行单独成事件,这样单行流不用空行也能解析。
// tailKind/tailCount 只记最后完成的结构事件类别与饱和计数,不存帧内容。
type sseEventReader struct {
sc *bufio.Scanner
carry string
tailKind string
tailCount int
}
func (r *sseEventReader) noteTail(kind string) {
if r.tailCount < maxObservedTailEvents {
r.tailCount++
}
r.tailKind = kind
}
// badFrameKind classifies a data event that did not decode as a chunk using
// only the decoder's error category and position, never its text or bytes.
func badFrameKind(data string, err error) string {
if strings.TrimSpace(data) == "" {
return "empty_data"
}
var syntax *json.SyntaxError
if !errors.As(err, &syntax) {
return "unknown"
}
if syntax.Offset == int64(len(data)) {
var probe json.RawMessage
if errors.Is(json.NewDecoder(strings.NewReader(data)).Decode(&probe), io.ErrUnexpectedEOF) {
return "partial_frame"
}
}
return "unknown_non_json"
}
func (r *sseEventReader) next() (string, bool) {
var parts []string
nextPiece := func() (string, bool) {
if r.carry != "" {
piece := r.carry
r.carry = ""
return piece, true
}
if !r.sc.Scan() {
return "", false
}
return r.sc.Text(), true
}
for {
line, ok := nextPiece()
if !ok {
break
}
if strings.TrimSpace(line) == "" {
if len(parts) == 0 {
continue
}
return strings.Join(parts, "\n"), true
}
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, ":") {
r.noteTail("comment")
continue
}
if !strings.HasPrefix(trimmed, "data:") {
continue
}
piece := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
if len(parts) > 0 && json.Valid([]byte(strings.Join(parts, "\n"))) {
r.carry = line
return strings.Join(parts, "\n"), true
}
parts = append(parts, piece)
}
if len(parts) > 0 {
return strings.Join(parts, "\n"), true
}
return "", false
}
// toolFinishExecutable 只在供应商明确以工具调用或正常 stop 结束时执行。
// length、缺失的结束原因,以及没有 [DONE] 的断开,都视为未完成。
func toolFinishExecutable(reason string, sawDone bool) bool {
if !sawDone {
return false
}
switch reason {
case "tool_calls", "function_call":
return true
case "stop":
return true
default:
return false
}
}
func mapFinish(reason string, hasTools bool) StopReason {
switch reason {
case "tool_calls", "function_call":
return StopToolCalls
case "content_filter", "error":
return StopError
case "length":
return StopEnd
case "stop", "":
if hasTools {
return StopToolCalls
}
return StopEnd
default:
if hasTools {
return StopToolCalls
}
return StopEnd
}
}
func emitError(out chan<- Event, partial string, retryable bool, message string, observed ...*TerminalObservation) {
event := Event{
Kind: KindError,
Retryable: retryable,
ErrorMessage: message,
Err: &StreamError{Message: message, Retryable: retryable},
Message: Message{
Role: RoleAssistant,
Content: partial,
StopReason: StopError,
},
}
if len(observed) > 0 {
event.TerminalObservation = observed[0]
}
out <- event
}
func emitAbort(out chan<- Event, partial string, err error, observed ...*TerminalObservation) {
msg := "aborted"
if err != nil {
msg = err.Error()
}
event := Event{
Kind: KindError,
Retryable: false,
ErrorMessage: msg,
Err: err,
Message: Message{
Role: RoleAssistant,
Content: partial,
StopReason: StopAborted,
},
}
if len(observed) > 0 {
event.TerminalObservation = observed[0]
}
out <- event
}
// StreamError is a provider or transport failure encoded on the event stream.
type StreamError struct {
Message string
Retryable bool
}
func (e *StreamError) Error() string {
if e == nil {
return "stream error"
}
return e.Message
}