diff --git a/internal/transformer/inbound/openai/response.go b/internal/transformer/inbound/openai/response.go index 42e4e2bea..39e3c9eb4 100644 --- a/internal/transformer/inbound/openai/response.go +++ b/internal/transformer/inbound/openai/response.go @@ -952,7 +952,8 @@ type ResponsesItem struct { Arguments string `json:"arguments,omitempty"` // Function call output - Output *ResponsesInput `json:"output,omitempty"` + Output *ResponsesInput `json:"output,omitempty"` + ItemReference *string `json:"item_reference,omitempty"` // Image generation fields Result *string `json:"result,omitempty"` diff --git a/internal/transformer/outbound/openai/response.go b/internal/transformer/outbound/openai/response.go index 6660316f6..8f7f000eb 100644 --- a/internal/transformer/outbound/openai/response.go +++ b/internal/transformer/outbound/openai/response.go @@ -3,6 +3,7 @@ package openai import ( "bytes" "context" + "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" @@ -11,6 +12,8 @@ import ( "net/http" "net/url" "strings" + "sync/atomic" + "time" "github.com/samber/lo" @@ -416,7 +419,8 @@ type ResponsesItem struct { Arguments string `json:"arguments,omitempty"` // Function call output - Output *ResponsesInput `json:"output,omitempty"` + Output *ResponsesInput `json:"output,omitempty"` + ItemReference *string `json:"item_reference,omitempty"` // Image generation fields Result *string `json:"result,omitempty"` @@ -1038,6 +1042,8 @@ func convertInputFromMessages(msgs []model.Message, transformOptions model.Trans return ResponsesInput{Text: nonSystemMsgs[0].Content.Content} } + // Build call_id -> item_id mapping for function_call_output reference + callIDToItemID := make(map[string]string) var items []ResponsesItem for _, msg := range msgs { switch msg.Role { @@ -1046,9 +1052,15 @@ func convertInputFromMessages(msgs []model.Message, transformOptions model.Trans case "user": items = append(items, convertUserMessageToResponses(msg)) case "assistant": - items = append(items, convertAssistantMessageToResponses(msg)...) + assistantItems := convertAssistantMessageToResponses(msg) + for _, item := range assistantItems { + if item.Type == "function_call" && item.ID != "" && item.CallID != "" { + callIDToItemID[item.CallID] = item.ID + } + } + items = append(items, assistantItems...) case "tool": - items = append(items, convertToolMessageToResponses(msg)) + items = append(items, convertToolMessageToResponses(msg, callIDToItemID)) } } @@ -1148,6 +1160,7 @@ func convertAssistantMessageToResponses(msg model.Message) []ResponsesItem { // Handle tool calls for _, tc := range msg.ToolCalls { items = append(items, ResponsesItem{ + ID: generateResponsesItemID(), Type: "function_call", CallID: tc.ID, Name: tc.Function.Name, @@ -1185,7 +1198,7 @@ func convertAssistantMessageToResponses(msg model.Message) []ResponsesItem { return sanitizeResponsesItems(items) } -func convertToolMessageToResponses(msg model.Message) ResponsesItem { +func convertToolMessageToResponses(msg model.Message, callIDToItemID map[string]string) ResponsesItem { var output ResponsesInput if msg.Content.Content != nil { @@ -1205,11 +1218,20 @@ func convertToolMessageToResponses(msg model.Message) ResponsesItem { output.Text = lo.ToPtr("") } - return ResponsesItem{ + item := ResponsesItem{ Type: "function_call_output", CallID: lo.FromPtr(msg.ToolCallID), Output: &output, } + + // Set item_reference to the corresponding function_call's ID + if msg.ToolCallID != nil { + if itemID, ok := callIDToItemID[*msg.ToolCallID]; ok { + item.ItemReference = lo.ToPtr(itemID) + } + } + + return item } func convertToolsToResponses(tools []model.Tool) []ResponsesTool { @@ -1664,8 +1686,54 @@ func sanitizeResponsesRawItems(raw json.RawMessage) json.RawMessage { } changed := false + + // Build call_id -> item_id mapping from function_call items. + // Generate an id for any function_call that has call_id but no id, + // so the function_call_output backfill can always resolve item_reference. + callIDToItemID := make(map[string]string) for _, item := range items { - if decodeRawString(item["type"]) != "reasoning" { + if decodeRawString(item["type"]) == "function_call" { + callID := decodeRawString(item["call_id"]) + if callID == "" { + continue + } + itemID := decodeRawString(item["id"]) + if itemID == "" { + itemID = generateResponsesItemID() + if b, err := json.Marshal(itemID); err == nil { + item["id"] = b + changed = true + } + } + if itemID != "" { + callIDToItemID[callID] = itemID + } + } + } + + for _, item := range items { + itemType := decodeRawString(item["type"]) + + // Sanitize function_call_output: add missing item_reference + if itemType == "function_call_output" { + refRaw, hasRef := item["item_reference"] + refMissing := !hasRef || len(bytes.TrimSpace(refRaw)) == 0 || + bytes.Equal(bytes.TrimSpace(refRaw), []byte("null")) || + bytes.Equal(bytes.TrimSpace(refRaw), []byte(`""`)) + if refMissing { + callID := decodeRawString(item["call_id"]) + if callID != "" { + if itemID, ok := callIDToItemID[callID]; ok { + if b, err := json.Marshal(itemID); err == nil { + item["item_reference"] = b + changed = true + } + } + } + } + } + + if itemType != "reasoning" { continue } @@ -1866,3 +1934,20 @@ func (o *ResponseOutbound) PassthroughConfig() model.PassthroughConfig { CollectMetrics: false, // OpenAI Responses uses different metrics semantics } } + +// generateResponsesItemID generates a unique ID for Responses API items (function_call, etc.). +// Format matches OpenAI's pattern: item_ +func generateResponsesItemID() string { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + // fallback: use timestamp + counter + return fmt.Sprintf("item_%016x%08x", time.Now().UnixNano(), itemIDCounter.Add(1)) + } + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + for i := range b { + b[i] = charset[b[i]%byte(len(charset))] + } + return "item_" + string(b) +} + +var itemIDCounter atomic.Uint64 diff --git a/internal/transformer/outbound/openai/response_function_call_test.go b/internal/transformer/outbound/openai/response_function_call_test.go new file mode 100644 index 000000000..a0ccd2372 --- /dev/null +++ b/internal/transformer/outbound/openai/response_function_call_test.go @@ -0,0 +1,241 @@ +package openai + +import ( + "encoding/json" + "testing" + + "github.com/bestruirui/octopus/internal/transformer/model" + "github.com/samber/lo" +) + +func TestConvertInputFromMessagesGeneratesFunctionCallIDAndItemReference(t *testing.T) { + // Test that function_call items get unique IDs and function_call_output items get item_reference + msgs := []model.Message{ + { + Role: "assistant", + ToolCalls: []model.ToolCall{ + { + ID: "call_abc123", + Type: "function", + Function: model.FunctionCall{ + Name: "get_weather", + Arguments: `{"location":"Beijing"}`, + }, + }, + }, + }, + { + Role: "tool", + ToolCallID: lo.ToPtr("call_abc123"), + Content: model.MessageContent{ + Content: lo.ToPtr("Sunny, 25°C"), + }, + }, + } + + input := convertInputFromMessages(msgs, model.TransformOptions{ArrayInputs: lo.ToPtr(true)}) + + if len(input.Items) != 2 { + t.Fatalf("expected 2 items, got %d", len(input.Items)) + } + + // Check function_call has ID + functionCall := input.Items[0] + if functionCall.Type != "function_call" { + t.Fatalf("expected first item to be function_call, got %s", functionCall.Type) + } + if functionCall.ID == "" { + t.Error("function_call item missing ID") + } + if functionCall.CallID != "call_abc123" { + t.Errorf("expected call_id=call_abc123, got %s", functionCall.CallID) + } + + // Check function_call_output has item_reference + functionCallOutput := input.Items[1] + if functionCallOutput.Type != "function_call_output" { + t.Fatalf("expected second item to be function_call_output, got %s", functionCallOutput.Type) + } + if functionCallOutput.ItemReference == nil { + t.Fatal("function_call_output item missing item_reference") + } + if *functionCallOutput.ItemReference != functionCall.ID { + t.Errorf("item_reference=%s doesn't match function_call ID=%s", *functionCallOutput.ItemReference, functionCall.ID) + } +} + +func TestSanitizeResponsesRawItemsAddsItemReference(t *testing.T) { + // Test that sanitizeResponsesRawItems automatically adds missing item_reference + rawItems := json.RawMessage(`[ + { + "id": "item_xyz789", + "type": "function_call", + "call_id": "call_abc123", + "name": "get_weather", + "arguments": "{\"location\":\"Beijing\"}" + }, + { + "type": "function_call_output", + "call_id": "call_abc123", + "output": {"text": "Sunny, 25°C"} + } + ]`) + + sanitized := sanitizeResponsesRawItems(rawItems) + + var items []map[string]interface{} + if err := json.Unmarshal(sanitized, &items); err != nil { + t.Fatalf("failed to unmarshal sanitized items: %v", err) + } + + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + + // Check function_call_output now has item_reference + functionCallOutput := items[1] + if functionCallOutput["type"] != "function_call_output" { + t.Fatalf("expected second item to be function_call_output, got %v", functionCallOutput["type"]) + } + + itemRef, ok := functionCallOutput["item_reference"].(string) + if !ok { + t.Fatal("function_call_output missing item_reference after sanitization") + } + if itemRef != "item_xyz789" { + t.Errorf("expected item_reference=item_xyz789, got %s", itemRef) + } +} + +func TestSanitizeResponsesRawItemsFixesNullItemReference(t *testing.T) { + tests := []struct { + name string + raw string + }{ + {"null value", `[ + {"id":"item_xyz","type":"function_call","call_id":"call_1","name":"f","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_1","item_reference":null,"output":{"text":"ok"}} + ]`}, + {"empty string", `[ + {"id":"item_xyz","type":"function_call","call_id":"call_1","name":"f","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_1","item_reference":"","output":{"text":"ok"}} + ]`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sanitized := sanitizeResponsesRawItems(json.RawMessage(tt.raw)) + var items []map[string]interface{} + if err := json.Unmarshal(sanitized, &items); err != nil { + t.Fatalf("unmarshal: %v", err) + } + ref, ok := items[1]["item_reference"].(string) + if !ok || ref != "item_xyz" { + t.Errorf("expected item_reference=item_xyz, got %v", items[1]["item_reference"]) + } + }) + } +} + +func TestSanitizeResponsesRawItemsBackfillsMissingFunctionCallID(t *testing.T) { + rawItems := json.RawMessage(`[ + { + "type": "function_call", + "call_id": "call_noid", + "name": "do_thing", + "arguments": "{}" + }, + { + "type": "function_call_output", + "call_id": "call_noid", + "output": {"text": "done"} + } + ]`) + + sanitized := sanitizeResponsesRawItems(rawItems) + + var items []map[string]interface{} + if err := json.Unmarshal(sanitized, &items); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + generatedID, ok := items[0]["id"].(string) + if !ok || generatedID == "" { + t.Fatal("function_call missing generated id") + } + + ref, ok := items[1]["item_reference"].(string) + if !ok || ref == "" { + t.Fatal("function_call_output missing item_reference") + } + if ref != generatedID { + t.Errorf("item_reference=%s doesn't match generated id=%s", ref, generatedID) + } +} + +func TestMarshalResponsesInputItemsPreservesItemReference(t *testing.T) { + // Test end-to-end: Messages -> Items -> JSON preserves item_reference + msgs := []model.Message{ + { + Role: "assistant", + ToolCalls: []model.ToolCall{ + { + ID: "call_test123", + Type: "function", + Function: model.FunctionCall{ + Name: "test_func", + Arguments: `{}`, + }, + }, + }, + }, + { + Role: "tool", + ToolCallID: lo.ToPtr("call_test123"), + Content: model.MessageContent{ + Content: lo.ToPtr("result"), + }, + }, + } + + rawItems, err := MarshalResponsesInputItems(msgs) + if err != nil { + t.Fatalf("MarshalResponsesInputItems failed: %v", err) + } + + var items []map[string]interface{} + if err := json.Unmarshal(rawItems, &items); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + // Find function_call and function_call_output, then verify item_reference matches function_call.id + var functionCallID string + var itemReference string + var foundCall, foundOutput bool + for _, item := range items { + switch item["type"] { + case "function_call": + if id, ok := item["id"].(string); ok { + functionCallID = id + foundCall = true + } + case "function_call_output": + if ref, ok := item["item_reference"].(string); ok { + itemReference = ref + foundOutput = true + } + } + } + + if !foundCall { + t.Fatal("function_call item not found in marshaled output") + } + if functionCallID == "" { + t.Fatal("function_call item has empty id") + } + if !foundOutput { + t.Fatal("function_call_output item missing item_reference") + } + if itemReference != functionCallID { + t.Errorf("item_reference=%s doesn't match function_call id=%s", itemReference, functionCallID) + } +}