Skip to content

JSON-encode structured tool results for OpenAI instead of Go %v#525

Open
PratikDhanave wants to merge 3 commits into
microsoft:mainfrom
PratikDhanave:fix-openai-toolresult-json
Open

JSON-encode structured tool results for OpenAI instead of Go %v#525
PratikDhanave wants to merge 3 commits into
microsoft:mainfrom
PratikDhanave:fix-openai-toolresult-json

Conversation

@PratikDhanave

Copy link
Copy Markdown
Contributor

Summary

Both OpenAI request-building paths render a non-string FunctionResultContent.Result with fmt.Sprintf("%v", ret):

  • provider/openaiprovider/chat.go (RoleTool handling)
  • provider/openaiprovider/responses.go (function-call-output default case)

So a struct or map result — exactly what a typed functool.New[In, Out] with a struct Out returns (the handler returns the raw Out value, not a JSON string) — is sent to the model as Go's %v rendering instead of JSON:

  • struct weather{City: "Paris", TempC: 20}"{Paris 20}" sent to the model, instead of {"city":"Paris","temp_c":20}.

The anthropic and gemini providers already json.Marshal such results; only the two OpenAI paths don't.

Fix

Add a shared toolResultText(ret any) string helper that JSON-encodes structured results, while passing string / json.RawMessage / []byte through unchanged and rendering error via .Error() (and nil"", which also removes a stray "<nil>" on the Responses path). Use it in both paths.

Public API

No exported symbols change.

Tests

Adds TestChatToolResult_StructSerializedAsJSON_NonStreaming (black-box, in chat_test.go): a RoleTool message with a struct result. The request body contains "{Paris 20}" (Go %v) before this change and the JSON-encoded temp_c field after. Full ./provider/openaiprovider suite passes.

Both the Chat Completions and Responses paths rendered a non-string
FunctionResultContent.Result with fmt.Sprintf("%v", ...). A struct or map
result — exactly what a typed functool.New[In,Out] with a struct Out
returns — was therefore sent to the model as Go's rendering (e.g.
"{Paris 20}") instead of JSON ("{\"city\":\"Paris\",\"temp_c\":20}").

Add a shared toolResultText helper that JSON-encodes structured results
(passing strings/raw JSON/[]byte through and rendering errors via Error()),
mirroring the anthropic and gemini providers which already json.Marshal.

Adds a black-box test asserting a struct tool result is JSON-encoded in the
request body, not rendered with %v.
Copilot AI review requested due to automatic review settings July 17, 2026 18:36
@PratikDhanave
PratikDhanave requested a review from a team as a code owner July 17, 2026 18:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes how OpenAI request builders serialize function/tool results by JSON-encoding structured outputs (e.g., structs/maps) instead of using Go’s %v formatting, aligning OpenAI provider behavior with the Anthropic/Gemini providers and improving model-side parseability.

Changes:

  • Introduces a shared toolResultText(ret any) string helper to render tool results as JSON where appropriate.
  • Uses toolResultText in both Chat Completions (chat.go) and Responses API (responses.go) tool-result paths.
  • Adds a non-streaming chat test covering struct tool results serialization.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
provider/openaiprovider/responses.go Switches default tool-result serialization from fmt.Sprintf("%v", ret) to toolResultText(ret) for Responses API inputs.
provider/openaiprovider/chat.go Adds toolResultText helper and uses it for RoleTool messages in Chat Completions requests.
provider/openaiprovider/chat_test.go Adds a test to ensure structured tool results are JSON-encoded in Chat Completions requests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +749 to 754
// 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 thread provider/openaiprovider/chat_test.go Outdated
Comment on lines +1559 to +1565
var captured string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
captured = 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"}]}`)
}))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the captured request body now passes through a buffered channel instead of a shared variable, so the assertions are race-free. Also added a Responses-path structured-result test (the other review point).

Comment on lines +1585 to +1590
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)
}
Address review feedback:
- Add TestResponsesToolResult_StructSerializedAsJSON covering a struct
  FunctionResultContent.Result through the Responses API path (the fix
  changed responses.go too, but only the chat path had a test).
- Pass the captured request body through a channel instead of a shared
  variable so the non-streaming chat tool-result test is race-free.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants