Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
20 changes: 18 additions & 2 deletions llm/transformer/openai/responses/inbound_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -676,10 +676,23 @@ 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
itemID := storedToolCall.ID
if itemID == "" {
itemID = s.currentItemID
}
Expand Down Expand Up @@ -962,6 +975,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
92 changes: 81 additions & 11 deletions llm/transformer/openai/responses/outbound_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
"reflect"
"strings"

"github.com/samber/lo"
Expand Down Expand Up @@ -332,20 +333,75 @@ 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.
}

if streamEvent.Name != "" {
tc.Function.Name = streamEvent.Name
}
if streamEvent.Namespace != "" {
tc.Function.Namespace = streamEvent.Namespace
}

// 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
if finalArgs == "" {
return nil // An empty done event must not overwrite accumulated deltas.
}

forwardedArgs := tc.Function.Arguments
var missingArgs string
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 == "" {
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 +686,20 @@ func (s *responsesOutboundStream) transformStreamChunk(event *httpclient.StreamE
return nil
}

func equalJSONValues(left, right string) bool {
var leftValue any
if err := json.Unmarshal([]byte(left), &leftValue); err != nil {
return false
}

var rightValue any
if err := json.Unmarshal([]byte(right), &rightValue); err != nil {
return false
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

return reflect.DeepEqual(leftValue, rightValue)
}

func (s *responsesOutboundStream) Current() *llm.Response {
if s.queueIndex < len(s.eventQueue) {
event := s.eventQueue[s.queueIndex]
Expand Down
118 changes: 118 additions & 0 deletions llm/transformer/openai/responses/outbound_stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package responses

import (
"context"
"encoding/json"
"testing"

"github.com/google/go-cmp/cmp"
Expand Down Expand Up @@ -196,6 +197,123 @@ func TestOutboundTransformer_TransformStream_ResponseCancelledCompletes(t *testi
require.Equal(t, "cancelled", *responses[1].Choices[0].FinishReason)
}

func TestOutboundTransformer_TransformStream_EmitsArgumentsProvidedOnlyInDone(t *testing.T) {
trans, err := NewOutboundTransformer("https://api.openai.com", "test-api-key")
require.NoError(t, err)

events := []*httpclient.StreamEvent{
{Data: []byte(`{"type":"response.created","response":{"id":"resp_done_arguments","object":"response","created_at":1700000000,"model":"gpt-5","status":"in_progress","output":[]}}`)},
{Data: []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"fc_done_arguments","type":"function_call","call_id":"call_done_arguments","name":"collaboration.spawn_agent","arguments":""}}`)},
{Data: []byte(`{"type":"response.function_call_arguments.done","item_id":"fc_done_arguments","output_index":0,"name":"collaboration.spawn_agent","arguments":"{\"task\":\"delegate this task\"}"}`)},
{Data: []byte(`{"type":"response.completed","response":{"id":"resp_done_arguments","object":"response","created_at":1700000000,"model":"gpt-5","status":"completed","output":[]}}`)},
}

stream, err := trans.TransformStream(t.Context(), nil, streams.SliceStream(events))
require.NoError(t, err)

responses, err := streams.All(stream)
require.NoError(t, err)

var arguments string
for _, response := range responses {
if response == llm.DoneResponse || len(response.Choices) == 0 || response.Choices[0].Delta == nil {
continue
}

for _, toolCall := range response.Choices[0].Delta.ToolCalls {
arguments += toolCall.Function.Arguments
}
}

require.JSONEq(t, `{"task":"delegate this task"}`, arguments)
}

func TestOutboundTransformer_TransformStream_AcceptsEquivalentFinalArguments(t *testing.T) {
trans, err := NewOutboundTransformer("https://api.openai.com", "test-api-key")
require.NoError(t, err)

forwardedArguments := `{"task":"delegate this task","priority":1}`
finalArguments := `{
"priority": 1,
"task": "delegate this task"
}`
events := []*httpclient.StreamEvent{
{Data: []byte(`{"type":"response.created","response":{"id":"resp_equivalent_arguments","object":"response","created_at":1700000000,"model":"gpt-5","status":"in_progress","output":[]}}`)},
{Data: []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"fc_equivalent_arguments","type":"function_call","call_id":"call_equivalent_arguments","name":"spawn_agent","arguments":""}}`)},
{Data: []byte(`{"type":"response.function_call_arguments.delta","item_id":"fc_equivalent_arguments","output_index":0,"delta":"{\"task\":\"delegate this task\",\"priority\":1}"}`)},
{Data: []byte(`{"type":"response.function_call_arguments.done","item_id":"fc_equivalent_arguments","output_index":0,"arguments":"{\n \"priority\": 1,\n \"task\": \"delegate this task\"\n}"}`)},
{Data: []byte(`{"type":"response.completed","response":{"id":"resp_equivalent_arguments","object":"response","created_at":1700000000,"model":"gpt-5","status":"completed","output":[]}}`)},
}

stream, err := trans.TransformStream(t.Context(), nil, streams.SliceStream(events))
require.NoError(t, err)

responses, err := streams.All(stream)
require.NoError(t, err)

var arguments string
for _, response := range responses {
if response == llm.DoneResponse || len(response.Choices) == 0 || response.Choices[0].Delta == nil {
continue
}

for _, toolCall := range response.Choices[0].Delta.ToolCalls {
arguments += toolCall.Function.Arguments
}
}

require.Equal(t, forwardedArguments, arguments)
require.JSONEq(t, finalArguments, arguments)
}

func TestResponsesStream_RoundTrip_PreservesToolIdentityProvidedOnlyInDone(t *testing.T) {
outbound, err := NewOutboundTransformer("https://api.openai.com", "test-api-key")
require.NoError(t, err)

upstreamEvents := []*httpclient.StreamEvent{
{Data: []byte(`{"type":"response.created","response":{"id":"resp_done_identity","object":"response","created_at":1700000000,"model":"gpt-5","status":"in_progress","output":[]}}`)},
{Data: []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"fc_done_identity","type":"function_call","call_id":"call_done_identity","arguments":""}}`)},
{Data: []byte(`{"type":"response.function_call_arguments.done","item_id":"fc_done_identity","output_index":0,"name":"spawn_agent","namespace":"collaboration","arguments":"{\"task\":\"delegate this task\"}"}`)},
{Data: []byte(`{"type":"response.completed","response":{"id":"resp_done_identity","object":"response","created_at":1700000000,"model":"gpt-5","status":"completed","output":[]}}`)},
}

unifiedStream, err := outbound.TransformStream(t.Context(), nil, streams.SliceStream(upstreamEvents))
require.NoError(t, err)

clientStream, err := NewInboundTransformer().TransformStream(t.Context(), unifiedStream)
require.NoError(t, err)

clientEvents, err := streams.All(clientStream)
require.NoError(t, err)

var completedItem *Item
var completedArguments *StreamEvent
for _, clientEvent := range clientEvents {
if string(clientEvent.Data) == "[DONE]" {
continue
}

var event StreamEvent
require.NoError(t, json.Unmarshal(clientEvent.Data, &event))
if event.Type == StreamEventTypeOutputItemDone && event.Item != nil && event.Item.Type == "function_call" {
completedItem = event.Item
}
if event.Type == StreamEventTypeFunctionCallArgumentsDone {
completedArguments = &event
}
}

require.NotNil(t, completedItem)
require.NotNil(t, completedArguments)
require.Equal(t, "call_done_identity", completedArguments.CallID)
require.Equal(t, "spawn_agent", completedArguments.Name)
require.Equal(t, "collaboration", completedArguments.Namespace)
require.JSONEq(t, `{"task":"delegate this task"}`, completedArguments.Arguments)
require.Equal(t, "spawn_agent", completedItem.Name)
require.Equal(t, "collaboration", completedItem.Namespace)
require.JSONEq(t, `{"task":"delegate this task"}`, completedItem.Arguments)
}

func TestOutboundTransformer_TransformStream_PreservesFinalItemAnnotations(t *testing.T) {
trans, err := NewOutboundTransformer("https://api.openai.com", "test-api-key")
require.NoError(t, err)
Expand Down