- server.go 204 + 新 routes.go(193,路由注册/Handler/withMiddleware)/deps.go(108)/contracts.go(231,17依赖接口)/broadcast.go(78,BroadcastPersisted/CapacityAwaken/Mood); 导出面不变; 74条路由pattern+RBAC门逐条比对一致 - model/openai_stream.go: scanSSE 138→25(parse/apply/finalize 对齐 anthropic_stream); openaiStreamState结构体 - channelbridge/inbound.go: processFrom 112→46(blacklistDrop/inboundTargetFor/runInboundTurn/deliverReply) - 纯行为等价; build/vet/test绿(56包)+win/darwin交叉编译
231 lines
7.6 KiB
Go
231 lines
7.6 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
|
|
}
|
|
|
|
// openAIStreamState is the running aggregation of one streamed
|
|
// /chat/completions response: the assembled Response, the bracketing flags of the
|
|
// currently open segment, and the per-index tool-call accumulators. It is the
|
|
// OpenAI counterpart of anthropic's streamBlock bookkeeping, so both adapters
|
|
// share one parse → apply → finalize shape. emit is the nil-tolerant event sink
|
|
// (a nil onEvent simply drops every event).
|
|
type openAIStreamState struct {
|
|
agg Response
|
|
text strings.Builder
|
|
textStarted bool
|
|
thinkingStarted bool
|
|
toolCalls map[int]*openAIToolAcc
|
|
toolOrder []int
|
|
onEvent func(StreamEvent)
|
|
}
|
|
|
|
// 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)
|
|
st := &openAIStreamState{toolCalls: map[int]*openAIToolAcc{}, onEvent: onEvent}
|
|
|
|
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)
|
|
}
|
|
st.applyChunk(chunk)
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return Response{}, fmt.Errorf("model: %w: read stream: %v", ErrUpstream, err)
|
|
}
|
|
return finalizeOpenAIStream(st), nil
|
|
}
|
|
|
|
// emit forwards one structured event, tolerating a nil sink.
|
|
func (st *openAIStreamState) emit(ev StreamEvent) {
|
|
if st.onEvent != nil {
|
|
st.onEvent(ev)
|
|
}
|
|
}
|
|
|
|
// applyChunk folds a single decoded stream chunk into the running aggregation:
|
|
// usage and stop reason are recorded even on a choice-less terminal chunk, and
|
|
// the segment delta (if any) is routed to its handler.
|
|
func (st *openAIStreamState) applyChunk(chunk openAIChatResponse) {
|
|
if u := chunk.Usage; u != nil {
|
|
st.agg.PromptTokens = u.PromptTokens
|
|
st.agg.CompletionTokens = u.CompletionTokens
|
|
st.agg.TotalTokens = u.TotalTokens
|
|
}
|
|
if len(chunk.Choices) == 0 {
|
|
return
|
|
}
|
|
if chunk.Choices[0].FinishReason != "" {
|
|
st.agg.StopReason = chunk.Choices[0].FinishReason
|
|
}
|
|
if chunk.Choices[0].Delta == nil {
|
|
return
|
|
}
|
|
st.applyDelta(chunk.Choices[0].Delta)
|
|
}
|
|
|
|
// applyDelta routes one delta fragment to its segment handler. The precedence is
|
|
// the provider's: a reasoning fragment wins over a tool-call fragment, which wins
|
|
// over body text, so a delta never contributes to two segments at once.
|
|
func (st *openAIStreamState) applyDelta(delta *openAIDelta) {
|
|
switch {
|
|
case delta.ReasoningContent != "":
|
|
st.applyThinking(delta.ReasoningContent)
|
|
case len(delta.ToolCalls) > 0:
|
|
st.endThinking()
|
|
for _, tc := range delta.ToolCalls {
|
|
st.applyToolCall(tc)
|
|
}
|
|
case delta.Content != "":
|
|
st.endThinking()
|
|
st.applyText(delta.Content)
|
|
}
|
|
}
|
|
|
|
// applyThinking forwards one reasoning fragment, opening the thinking segment on
|
|
// the first fragment only.
|
|
func (st *openAIStreamState) applyThinking(fragment string) {
|
|
if !st.thinkingStarted {
|
|
st.thinkingStarted = true
|
|
st.emit(StreamEvent{Kind: StreamKindThinkingStart})
|
|
}
|
|
st.emit(StreamEvent{Kind: StreamKindThinkingDelta, Delta: fragment})
|
|
}
|
|
|
|
// endThinking closes the open thinking segment (no-op when none is open), so a
|
|
// following tool-call or text segment starts a fresh one.
|
|
func (st *openAIStreamState) endThinking() {
|
|
if !st.thinkingStarted {
|
|
return
|
|
}
|
|
st.thinkingStarted = false
|
|
st.emit(StreamEvent{Kind: StreamKindThinkingEnd})
|
|
}
|
|
|
|
// applyToolCall folds one streaming tool-call fragment into its per-index
|
|
// accumulator, opening the block's start event once its id/name is known and
|
|
// forwarding the argument fragments as they arrive.
|
|
func (st *openAIStreamState) applyToolCall(tc openAIToolCallDelta) {
|
|
acc := st.toolCalls[tc.Index]
|
|
if acc == nil {
|
|
acc = &openAIToolAcc{}
|
|
st.toolCalls[tc.Index] = acc
|
|
st.toolOrder = append(st.toolOrder, tc.Index)
|
|
}
|
|
if acc.id == "" {
|
|
acc.id = tc.ID
|
|
}
|
|
if acc.name == "" {
|
|
acc.name = tc.Function.Name
|
|
}
|
|
if !acc.started && acc.idOrName() != "" {
|
|
acc.started = true
|
|
st.emit(StreamEvent{Kind: StreamKindToolCallStart, Index: tc.Index, ID: acc.idOrName()})
|
|
}
|
|
if tc.Function.Arguments == "" {
|
|
return
|
|
}
|
|
acc.args.WriteString(tc.Function.Arguments)
|
|
st.emit(StreamEvent{Kind: StreamKindToolCallDelta, Index: tc.Index, ID: acc.idOrName(), Delta: tc.Function.Arguments})
|
|
}
|
|
|
|
// applyText appends one body-text fragment, opening the text segment on the first
|
|
// fragment only.
|
|
func (st *openAIStreamState) applyText(fragment string) {
|
|
st.text.WriteString(fragment)
|
|
if !st.textStarted {
|
|
st.textStarted = true
|
|
st.emit(StreamEvent{Kind: StreamKindTextStart})
|
|
}
|
|
st.emit(StreamEvent{Kind: StreamKindTextDelta, Delta: fragment})
|
|
}
|
|
|
|
// closeSegments emits the end event of every segment still open at stream
|
|
// termination. OpenAI has no per-block stop event, so each started tool_call
|
|
// block is closed here.
|
|
func (st *openAIStreamState) closeSegments() {
|
|
if st.thinkingStarted {
|
|
st.emit(StreamEvent{Kind: StreamKindThinkingEnd})
|
|
}
|
|
if st.textStarted {
|
|
st.emit(StreamEvent{Kind: StreamKindTextEnd})
|
|
}
|
|
for _, idx := range st.toolOrder {
|
|
acc := st.toolCalls[idx]
|
|
if acc == nil || !acc.started {
|
|
continue
|
|
}
|
|
st.emit(StreamEvent{Kind: StreamKindToolCallEnd, Index: idx, ID: acc.idOrName()})
|
|
}
|
|
}
|
|
|
|
// assembleToolCalls decodes each accumulated tool-call argument buffer into its
|
|
// structured Args map, in provider order. Unparseable fragments degrade to an
|
|
// empty map (the call is still surfaced).
|
|
func (st *openAIStreamState) assembleToolCalls() {
|
|
for _, idx := range st.toolOrder {
|
|
acc := st.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{}
|
|
}
|
|
}
|
|
st.agg.ToolCalls = append(st.agg.ToolCalls, ToolCall{Name: acc.name, ID: acc.id, Args: args})
|
|
}
|
|
}
|
|
|
|
// finalizeOpenAIStream closes the open segments and assembles the aggregated
|
|
// Response: the concatenated body text (reasoning never leaks into it) plus the
|
|
// re-assembled tool calls.
|
|
func finalizeOpenAIStream(st *openAIStreamState) Response {
|
|
st.closeSegments()
|
|
st.assembleToolCalls()
|
|
st.agg.Content = st.text.String()
|
|
st.agg.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: st.agg.Content}}}
|
|
return st.agg
|
|
}
|