- openai.go 216(适配器+Chat/ChatStream+共享助手); openai_encode.go 202(请求wire+build*); openai_response.go 79(响应wire+responseFromWire); openai_stream.go 174(scanSSE) - 字节级比对 body identical; 导出API不变(go doc diff 空); gofmt 干净 - 纯净基线(HEAD+本改)全仓 go build/vet/test 绿 + win/darwin 交叉编译绿 - 遗留: scanSSE 138行(>80, 拆分前既有,零逻辑改动未动), 另开步骤重构
175 lines
5.1 KiB
Go
175 lines
5.1 KiB
Go
package model
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
)
|
|
|
|
// openAIToolAcc accumulates the partial state of one streaming tool call (keyed
|
|
// by the provider's tool_calls[].index) so its arguments re-assemble across the
|
|
// delta fragments. started guards a single ToolCallStart emission per block.
|
|
type openAIToolAcc struct {
|
|
id string
|
|
name string
|
|
args strings.Builder
|
|
started bool
|
|
}
|
|
|
|
// idOrName resolves the block identifier used to correlate start/delta/end: the
|
|
// provider tool-call id when present, else the function name.
|
|
func (a *openAIToolAcc) idOrName() string {
|
|
if a.id != "" {
|
|
return a.id
|
|
}
|
|
return a.name
|
|
}
|
|
|
|
// scanSSE incrementally decodes stream chunks, accumulates the full body text
|
|
// with a strings.Builder (never reading the whole body at once) and captures
|
|
// usage from the final chunk. It splits each delta into its segment and brackets
|
|
// them: reasoning_content as thinking_start/delta/end, tool_calls fragments as
|
|
// tool_call_start/delta/end, and body text as text_start/delta/end. The
|
|
// accumulated tool-call arguments re-assemble across fragments into a structured
|
|
// Args map, and are attached to the returned Response alongside the text.
|
|
func scanSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) {
|
|
scanner := bufio.NewScanner(r)
|
|
scanner.Buffer(make([]byte, 0, sseBufInit), maxSSELineBytes)
|
|
agg := Response{}
|
|
var text strings.Builder
|
|
textStarted := false
|
|
thinkingStarted := false
|
|
toolCalls := map[int]*openAIToolAcc{}
|
|
var toolOrder []int
|
|
|
|
endThinking := func() {
|
|
if onEvent != nil && thinkingStarted {
|
|
thinkingStarted = false
|
|
onEvent(StreamEvent{Kind: StreamKindThinkingEnd})
|
|
}
|
|
}
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || !strings.HasPrefix(line, sseDataPrefix) {
|
|
continue
|
|
}
|
|
data := strings.TrimSpace(strings.TrimPrefix(line, sseDataPrefix))
|
|
if data == sseDone {
|
|
break
|
|
}
|
|
var chunk openAIChatResponse
|
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
|
return Response{}, fmt.Errorf("model: %w: decode stream chunk: %v", ErrUpstream, err)
|
|
}
|
|
if u := chunk.Usage; u != nil {
|
|
agg.PromptTokens = u.PromptTokens
|
|
agg.CompletionTokens = u.CompletionTokens
|
|
agg.TotalTokens = u.TotalTokens
|
|
}
|
|
if len(chunk.Choices) > 0 && chunk.Choices[0].FinishReason != "" {
|
|
agg.StopReason = chunk.Choices[0].FinishReason
|
|
}
|
|
if len(chunk.Choices) == 0 || chunk.Choices[0].Delta == nil {
|
|
continue
|
|
}
|
|
delta := chunk.Choices[0].Delta
|
|
|
|
// Reasoning / thinking segment (OpenAI o-series, DeepSeek, DashScope).
|
|
if delta.ReasoningContent != "" {
|
|
if onEvent != nil && !thinkingStarted {
|
|
thinkingStarted = true
|
|
onEvent(StreamEvent{Kind: StreamKindThinkingStart})
|
|
}
|
|
if onEvent != nil {
|
|
onEvent(StreamEvent{Kind: StreamKindThinkingDelta, Delta: delta.ReasoningContent})
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Streaming tool-call fragments.
|
|
if len(delta.ToolCalls) > 0 {
|
|
endThinking()
|
|
for _, tc := range delta.ToolCalls {
|
|
acc := toolCalls[tc.Index]
|
|
if acc == nil {
|
|
acc = &openAIToolAcc{}
|
|
toolCalls[tc.Index] = acc
|
|
toolOrder = append(toolOrder, tc.Index)
|
|
}
|
|
if acc.id == "" {
|
|
acc.id = tc.ID
|
|
}
|
|
if acc.name == "" {
|
|
acc.name = tc.Function.Name
|
|
}
|
|
if onEvent != nil && !acc.started && acc.idOrName() != "" {
|
|
acc.started = true
|
|
onEvent(StreamEvent{Kind: StreamKindToolCallStart, Index: tc.Index, ID: acc.idOrName()})
|
|
}
|
|
if tc.Function.Arguments != "" {
|
|
acc.args.WriteString(tc.Function.Arguments)
|
|
if onEvent != nil {
|
|
onEvent(StreamEvent{Kind: StreamKindToolCallDelta, Index: tc.Index, ID: acc.idOrName(), Delta: tc.Function.Arguments})
|
|
}
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Body-text segment.
|
|
if delta.Content == "" {
|
|
continue
|
|
}
|
|
endThinking()
|
|
text.WriteString(delta.Content)
|
|
if onEvent != nil {
|
|
if !textStarted {
|
|
textStarted = true
|
|
onEvent(StreamEvent{Kind: StreamKindTextStart})
|
|
}
|
|
onEvent(StreamEvent{Kind: StreamKindTextDelta, Delta: delta.Content})
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return Response{}, fmt.Errorf("model: %w: read stream: %v", ErrUpstream, err)
|
|
}
|
|
|
|
if onEvent != nil {
|
|
if thinkingStarted {
|
|
onEvent(StreamEvent{Kind: StreamKindThinkingEnd})
|
|
}
|
|
if textStarted {
|
|
onEvent(StreamEvent{Kind: StreamKindTextEnd})
|
|
}
|
|
// OpenAI has no per-block stop event, so each tool_call block is closed
|
|
// when the stream terminates.
|
|
for _, idx := range toolOrder {
|
|
acc := toolCalls[idx]
|
|
if acc == nil || !acc.started {
|
|
continue
|
|
}
|
|
onEvent(StreamEvent{Kind: StreamKindToolCallEnd, Index: idx, ID: acc.idOrName()})
|
|
}
|
|
}
|
|
|
|
for _, idx := range toolOrder {
|
|
acc := toolCalls[idx]
|
|
if acc == nil {
|
|
continue
|
|
}
|
|
args := map[string]any{}
|
|
if acc.args.Len() > 0 {
|
|
if err := json.Unmarshal([]byte(acc.args.String()), &args); err != nil {
|
|
args = map[string]any{}
|
|
}
|
|
}
|
|
agg.ToolCalls = append(agg.ToolCalls, ToolCall{Name: acc.name, ID: acc.id, Args: args})
|
|
}
|
|
agg.Content = text.String()
|
|
agg.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: agg.Content}}}
|
|
return agg, nil
|
|
}
|