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
56 changes: 52 additions & 4 deletions llm/transformer/openai/responses/inbound_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,23 @@ func (s *responsesInboundStream) initToolCall(tc llm.ToolCall) error {
},
}

// A Responses function_call must include its name in output_item.added for
// clients to route it. Some upstreams provide that identity only in a later
// arguments delta or done event, so retain the call until it is known.
if tc.ResponseCustomToolCall == nil && tc.Function.Name == "" {
return nil
}

return s.startToolCallItem(toolCallIndex)
}

func (s *responsesInboundStream) startToolCallItem(toolCallIndex int) error {
if s.toolCallItemStarted[toolCallIndex] {
return nil
}

tc := s.toolCalls[toolCallIndex]

itemID := tc.ID
if itemID == "" {
itemID = generateItemID()
Expand Down Expand Up @@ -676,10 +693,38 @@ func (s *responsesInboundStream) initToolCall(tc llm.ToolCall) error {

func (s *responsesInboundStream) handleFunctionCallDelta(tc llm.ToolCall) error {
toolCallIndex := tc.Index
s.toolCalls[toolCallIndex].Function.Arguments += tc.Function.Arguments
storedToolCall := s.toolCalls[toolCallIndex]
if tc.ID != "" {
storedToolCall.ID = tc.ID
}
if tc.Type != "" {
storedToolCall.Type = tc.Type
}
if tc.Function.Name != "" {
storedToolCall.Function.Name = tc.Function.Name
}
if tc.Function.Namespace != "" {
storedToolCall.Function.Namespace = tc.Function.Namespace
}
storedToolCall.Function.Arguments += tc.Function.Arguments

if tc.Function.Arguments != "" {
itemID := s.toolCalls[toolCallIndex].ID
argumentsToEmit := tc.Function.Arguments
if !s.toolCallItemStarted[toolCallIndex] {
if storedToolCall.Function.Name == "" {
return nil
}

if err := s.startToolCallItem(toolCallIndex); err != nil {
return err
}

// Arguments received before the identity became available have not been
// emitted. Replay the complete buffered payload after output_item.added.
argumentsToEmit = storedToolCall.Function.Arguments
}

if argumentsToEmit != "" {
itemID := storedToolCall.ID
if itemID == "" {
itemID = s.currentItemID
}
Expand All @@ -689,7 +734,7 @@ func (s *responsesInboundStream) handleFunctionCallDelta(tc llm.ToolCall) error
ItemID: &itemID,
OutputIndex: s.toolCallOutputIndex[toolCallIndex],
ContentIndex: lo.ToPtr(0),
Delta: tc.Function.Arguments,
Delta: argumentsToEmit,
})
if err != nil {
return fmt.Errorf("failed to enqueue function_call_arguments.delta event: %w", err)
Expand Down Expand Up @@ -962,6 +1007,9 @@ func (s *responsesInboundStream) closeCurrentOutputItem() error {
Type: StreamEventTypeFunctionCallArgumentsDone,
ItemID: &itemID,
OutputIndex: s.toolCallOutputIndex[idx],
CallID: tc.ID,
Name: tc.Function.Name,
Namespace: tc.Function.Namespace,
Arguments: tc.Function.Arguments,
})
if err != nil {
Expand Down
117 changes: 106 additions & 11 deletions llm/transformer/openai/responses/outbound_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"reflect"
"strings"

"github.com/samber/lo"
Expand Down Expand Up @@ -332,20 +334,80 @@ func (s *responsesOutboundStream) transformStreamChunk(event *httpclient.StreamE
}

case StreamEventTypeFunctionCallArgumentsDone:
// Function call completed - update state but don't emit an event
if streamEvent.CallID != "" {
if tc, ok := s.state.toolCalls[streamEvent.CallID]; ok {
if streamEvent.Name != "" {
tc.Function.Name = streamEvent.Name
}
if streamEvent.Namespace != "" {
tc.Function.Namespace = streamEvent.Namespace
}
tc.Function.Arguments = streamEvent.Arguments
callID := streamEvent.CallID
if callID == "" && streamEvent.ItemID != nil {
callID = s.state.itemToCallID[*streamEvent.ItemID]
if callID == "" {
// Fallback: item_id might be the call_id itself.
callID = *streamEvent.ItemID
}
}

return nil // Intentionally skip this event
tc, ok := s.state.toolCalls[callID]
if !ok {
return nil // Intentionally skip an unknown tool call.
}

identityChanged := false
if streamEvent.Name != "" && streamEvent.Name != tc.Function.Name {
tc.Function.Name = streamEvent.Name
identityChanged = true
}
if streamEvent.Namespace != "" && streamEvent.Namespace != tc.Function.Namespace {
tc.Function.Namespace = streamEvent.Namespace
identityChanged = true
}

// Some upstreams provide the complete JSON arguments only in the done event.
// Preserve arguments already emitted through delta events and forward only the
// missing suffix so downstream Responses streams receive the full value once.
finalArgs := streamEvent.Arguments
missingArgs := ""
if finalArgs == "" {
if !identityChanged {
return nil // An empty done event must not overwrite accumulated deltas.
}
} else {
forwardedArgs := tc.Function.Arguments
switch {
case forwardedArgs == "":
missingArgs = finalArgs
case strings.HasPrefix(finalArgs, forwardedArgs):
missingArgs = strings.TrimPrefix(finalArgs, forwardedArgs)
case equalJSONValues(forwardedArgs, finalArgs):
// The final payload may be reformatted without changing its meaning.
// The complete arguments were already forwarded, so do not emit a duplicate.
missingArgs = ""
default:
return fmt.Errorf("function call arguments mismatch for call_id %q", callID)
}

tc.Function.Arguments = finalArgs
if missingArgs == "" && !identityChanged {
return nil
}
}

toolCallIdx := s.state.toolCallIndex[callID]
resp.Choices = []llm.Choice{
{
Index: 0,
Delta: &llm.Message{
ToolCalls: []llm.ToolCall{
{
ID: tc.ID,
Type: tc.Type,
Index: toolCallIdx,
Function: llm.FunctionCall{
Name: tc.Function.Name,
Namespace: tc.Function.Namespace,
Arguments: missingArgs,
},
},
},
},
},
}

case StreamEventTypeCustomToolCallInputDelta:
// Custom tool call input delta - accumulate and emit as tool call delta
Expand Down Expand Up @@ -630,6 +692,39 @@ func (s *responsesOutboundStream) transformStreamChunk(event *httpclient.StreamE
return nil
}

func equalJSONValues(left, right string) bool {
leftValue, err := decodeJSONValue(left)
if err != nil {
return false
}

rightValue, err := decodeJSONValue(right)
if err != nil {
return false
}

return reflect.DeepEqual(leftValue, rightValue)
}

// decodeJSONValue preserves numeric lexemes so semantic comparisons do not
// lose precision for integers that cannot be represented exactly as float64.
func decodeJSONValue(value string) (any, error) {
decoder := json.NewDecoder(strings.NewReader(value))
decoder.UseNumber()

var decoded any
if err := decoder.Decode(&decoded); err != nil {
return nil, err
}

var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("unexpected trailing JSON value: %w", err)
}

return decoded, nil
}

func (s *responsesOutboundStream) Current() *llm.Response {
if s.queueIndex < len(s.eventQueue) {
event := s.eventQueue[s.queueIndex]
Expand Down
Loading
Loading