Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 36 additions & 19 deletions pkg/aiusechat/openai/openai-backend.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

package openai
Expand All @@ -11,6 +11,7 @@ import (
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -397,8 +398,15 @@ type openaiBlockState struct {
accumulatedText string // For text blocks: accumulated text content
}

type openaiBlockKey struct {
outputIndex int
contentIndex int
}

type openaiStreamingState struct {
blockMap map[string]*openaiBlockState // Use item_id as key for UI streaming
// Some Responses-compatible providers regenerate item IDs between events.
// Output and content indices identify the same block throughout the stream.
blockMap map[openaiBlockKey]*openaiBlockState
msgID string
model string
stepStarted bool
Expand Down Expand Up @@ -605,7 +613,7 @@ func parseOpenAIHTTPError(resp *http.Response) error {
func handleOpenAIStreamingResp(ctx context.Context, sse *sse.SSEHandlerCh, decoder *eventsource.Decoder, cont *uctypes.WaveContinueResponse, chatOpts uctypes.WaveChatOpts) (*uctypes.WaveStopReason, []*OpenAIChatMessage) {
// Per-response state
state := &openaiStreamingState{
blockMap: map[string]*openaiBlockState{},
blockMap: map[openaiBlockKey]*openaiBlockState{},
chatOpts: chatOpts,
}

Expand Down Expand Up @@ -678,14 +686,23 @@ func handleOpenAIStreamingResp(ctx context.Context, sse *sse.SSEHandlerCh, decod
// extractPartialTextFromState extracts accumulated text from streaming state when client disconnects
func extractPartialTextFromState(state *openaiStreamingState) []*OpenAIChatMessage {
var textContent []OpenAIMessageContent

for _, blockState := range state.blockMap {
keys := make([]openaiBlockKey, 0, len(state.blockMap))
for key, blockState := range state.blockMap {
if blockState.kind == openaiBlockText && blockState.accumulatedText != "" {
textContent = append(textContent, OpenAIMessageContent{
Type: "output_text",
Text: blockState.accumulatedText,
})
keys = append(keys, key)
}
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].outputIndex != keys[j].outputIndex {
return keys[i].outputIndex < keys[j].outputIndex
}
return keys[i].contentIndex < keys[j].contentIndex
})
for _, key := range keys {
textContent = append(textContent, OpenAIMessageContent{
Type: "output_text",
Text: state.blockMap[key].accumulatedText,
})
}

if len(textContent) == 0 {
Expand Down Expand Up @@ -759,7 +776,7 @@ func handleOpenAIEvent(
case "reasoning":
// Create reasoning block - emit start immediately
id := uuid.New().String()
state.blockMap[ev.Item.Id] = &openaiBlockState{
state.blockMap[openaiBlockKey{outputIndex: ev.OutputIndex}] = &openaiBlockState{
kind: openaiBlockReasoning,
localID: id,
summaryCount: 0,
Expand All @@ -770,7 +787,7 @@ func handleOpenAIEvent(
case "function_call":
// Track function call info and notify frontend
id := uuid.New().String()
state.blockMap[ev.Item.Id] = &openaiBlockState{
state.blockMap[openaiBlockKey{outputIndex: ev.OutputIndex}] = &openaiBlockState{
kind: openaiBlockToolUse,
localID: id,
toolCallID: ev.Item.CallId,
Expand All @@ -788,7 +805,7 @@ func handleOpenAIEvent(
return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil
}

if st := state.blockMap[ev.Item.Id]; st != nil {
if st := state.blockMap[openaiBlockKey{outputIndex: ev.OutputIndex}]; st != nil {
switch st.kind {
case openaiBlockReasoning:
_ = sse.AiMsgReasoningEnd(st.localID)
Expand All @@ -810,7 +827,7 @@ func handleOpenAIEvent(
case "output_text":
// Handle text content for UI streaming only
id := uuid.New().String()
state.blockMap[ev.ItemId] = &openaiBlockState{
state.blockMap[openaiBlockKey{outputIndex: ev.OutputIndex, contentIndex: ev.ContentIndex}] = &openaiBlockState{
kind: openaiBlockText,
localID: id,
}
Expand All @@ -825,7 +842,7 @@ func handleOpenAIEvent(
return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil
}

if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockText {
if st := state.blockMap[openaiBlockKey{outputIndex: ev.OutputIndex, contentIndex: ev.ContentIndex}]; st != nil && st.kind == openaiBlockText {
st.accumulatedText += ev.Delta
_ = sse.AiMsgTextDelta(st.localID, ev.Delta)
}
Expand All @@ -841,7 +858,7 @@ func handleOpenAIEvent(
return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil
}

if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockText {
if st := state.blockMap[openaiBlockKey{outputIndex: ev.OutputIndex, contentIndex: ev.ContentIndex}]; st != nil && st.kind == openaiBlockText {
_ = sse.AiMsgTextEnd(st.localID)
}
return nil, nil
Expand Down Expand Up @@ -916,7 +933,7 @@ func handleOpenAIEvent(
_ = sse.AiMsgError(err.Error())
return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil
}
if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockToolUse {
if st := state.blockMap[openaiBlockKey{outputIndex: ev.OutputIndex}]; st != nil && st.kind == openaiBlockToolUse {
st.partialJSON = append(st.partialJSON, []byte(ev.Delta)...)
aiutil.SendToolProgress(st.toolCallID, st.toolName, st.partialJSON, state.chatOpts, sse, true)
}
Expand All @@ -930,7 +947,7 @@ func handleOpenAIEvent(
}

// Get the function call info from the block state
if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockToolUse {
if st := state.blockMap[openaiBlockKey{outputIndex: ev.OutputIndex}]; st != nil && st.kind == openaiBlockToolUse {
aiutil.SendToolProgress(st.toolCallID, st.toolName, []byte(ev.Arguments), state.chatOpts, sse, false)
}
return nil, nil
Expand All @@ -955,7 +972,7 @@ func handleOpenAIEvent(
return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil
}

if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockReasoning {
if st := state.blockMap[openaiBlockKey{outputIndex: ev.OutputIndex}]; st != nil && st.kind == openaiBlockReasoning {
if st.summaryCount > 0 {
// Not the first summary part, emit separator
_ = sse.AiMsgReasoningDelta(st.localID, "\n\n")
Expand All @@ -974,7 +991,7 @@ func handleOpenAIEvent(
return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil
}

if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockReasoning {
if st := state.blockMap[openaiBlockKey{outputIndex: ev.OutputIndex}]; st != nil && st.kind == openaiBlockReasoning {
_ = sse.AiMsgReasoningDelta(st.localID, ev.Delta)
}
return nil, nil
Expand Down
Loading
Loading