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
27 changes: 24 additions & 3 deletions provider/openaiprovider/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,10 +500,8 @@ func buildMessageParam(msg *message.Message) ([]openai.ChatCompletionMessagePara
ret := funcResult.Result
if funcResult.Error != nil {
ret = funcResult.Error
} else if b, ok := ret.(json.RawMessage); ok {
ret = string(b)
}
messages = append(messages, openai.ToolMessage(fmt.Sprintf("%v", ret), funcResult.CallID))
messages = append(messages, openai.ToolMessage(toolResultText(ret), funcResult.CallID))
}
}
return messages, nil
Expand All @@ -513,6 +511,29 @@ func buildMessageParam(msg *message.Message) ([]openai.ChatCompletionMessagePara
}
}

// toolResultText renders a function-tool result for the OpenAI wire format. A
// non-string, non-raw result (e.g. a struct or map returned by a typed
// functool) is JSON-encoded rather than rendered with Go's %v, which would send
// an unparseable Go representation like "{Paris 20}" to the model.
func toolResultText(ret any) string {
switch v := ret.(type) {
case nil:
return ""
case string:
return v
case json.RawMessage:
return string(v)
case []byte:
return string(v)
case error:
return v.Error()
}
if b, err := json.Marshal(ret); err == nil {
return string(b)
}
return fmt.Sprintf("%v", ret)
}

func addUsage(contents []message.Content, usage openai.CompletionUsage) []message.Content {
details := message.UsageDetails{
InputTokenCount: usage.PromptTokens,
Expand Down
39 changes: 39 additions & 0 deletions provider/openaiprovider/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1551,3 +1551,42 @@ func TestChatEmptyChoices_NonStreaming(t *testing.T) {
t.Errorf("expected usage input=12 total=12 to be surfaced, got %+v", usage)
}
}

// A structured (non-string) function-tool result — e.g. a struct returned by a
// typed functool — must be JSON-encoded in the request, not rendered with Go's
// %v, which would send an unparseable representation like "{Paris 20}" to the model.
func TestChatToolResult_StructSerializedAsJSON_NonStreaming(t *testing.T) {
capturedCh := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
capturedCh <- string(b)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"id":"x","object":"chat.completion","created":1,"model":"gpt-4o-mini","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)
}))
defer server.Close()
a := newTestClient(server)

type weather struct {
City string `json:"city"`
TempC int `json:"temp_c"`
}
messages := []*message.Message{
{Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "weather?"}}},
{Role: message.RoleAssistant, Contents: []message.Content{
&message.FunctionCallContent{CallID: "c1", Name: "GetWeather", Arguments: "{}"},
}},
{Role: message.RoleTool, Contents: []message.Content{
&message.FunctionResultContent{CallID: "c1", Result: weather{City: "Paris", TempC: 20}},
}},
}
if _, err := a.Run(t.Context(), messages).Collect(); err != nil {
t.Fatalf("run: %v", err)
}
captured := <-capturedCh
if strings.Contains(captured, "{Paris 20}") {
t.Errorf("tool result rendered with Go %%v instead of JSON:\n%s", captured)
}
if !strings.Contains(captured, "temp_c") {
t.Errorf("tool result was not JSON-encoded (missing field temp_c):\n%s", captured)
}
}
5 changes: 3 additions & 2 deletions provider/openaiprovider/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -746,10 +746,11 @@ func responsesBuildMessageParam(msg *message.Message, resp responses.ResponseInp
outputContent,
))
} else {
// Default case - convert to string
// Default case - convert to string (JSON-encode structured
// results rather than rendering them with Go's %v).
resp = append(resp, responses.ResponseInputItemParamOfFunctionCallOutput(
funcResult.CallID,
fmt.Sprintf("%v", ret),
toolResultText(ret),
))
Comment on lines +749 to 754
}

Expand Down
Loading