diff --git a/provider/openaiprovider/responses.go b/provider/openaiprovider/responses.go index 18373ff6..024b5f6f 100644 --- a/provider/openaiprovider/responses.go +++ b/provider/openaiprovider/responses.go @@ -1177,6 +1177,9 @@ func responsesProcessResponse(resp *responses.Response, seqNum int64, yield func case responses.ResponseOutputText: textContent := &message.TextContent{Text: c.Text} populateAnnotations(c.Annotations, textContent) + if len(c.Logprobs) > 0 { + textContent.AdditionalProperties = map[string]any{"Logprobs": c.Logprobs} + } currentUpdate.Contents = append(currentUpdate.Contents, textContent) case responses.ResponseOutputRefusal: currentUpdate.Contents = append(currentUpdate.Contents, &message.ErrorContent{ @@ -1681,24 +1684,27 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, u.Role = state.role // For messages, only emit content if there are annotations that weren't in delta events // Delta events handle the text itself, but annotations only appear in done events - hasAnnotations := false + hasMetadata := false for _, c := range item.Content { - if c, ok := c.AsAny().(responses.ResponseOutputText); ok && len(c.Annotations) > 0 { - hasAnnotations = true + if c, ok := c.AsAny().(responses.ResponseOutputText); ok && (len(c.Annotations) > 0 || len(c.Logprobs) > 0) { + hasMetadata = true break } } - if hasAnnotations { + if hasMetadata { annotatedContent := &message.TextContent{} for _, c := range item.Content { if outputText, ok := c.AsAny().(responses.ResponseOutputText); ok { populateAnnotations(outputText.Annotations, annotatedContent) + if len(outputText.Logprobs) > 0 { + annotatedContent.AdditionalProperties = map[string]any{"Logprobs": outputText.Logprobs} + } } } u.Contents = []message.Content{annotatedContent} } - // If no annotations, don't emit content (delta events already did) + // If no annotations or logprobs, don't emit content (delta events already did) case responses.ResponseFunctionToolCall: state.anyFunctions = true diff --git a/provider/openaiprovider/responses_test.go b/provider/openaiprovider/responses_test.go index 2b644e36..13e17e4b 100644 --- a/provider/openaiprovider/responses_test.go +++ b/provider/openaiprovider/responses_test.go @@ -153,6 +153,82 @@ func TestResponsesRequestIncludesAgentFrameworkUserAgent(t *testing.T) { } } +// output_text logprobs must be surfaced on the TextContent's +// AdditionalProperties, matching the Python client which stores them there. +func TestResponsesOutputTextLogprobsSurfaced_NonStreaming(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{ + "id":"resp_lp", + "object":"response", + "created_at":1741891428, + "status":"completed", + "error":null, + "incomplete_details":null, + "model":"gpt-4o-mini", + "output":[{"type":"message","id":"msg_lp","status":"completed","role":"assistant","content":[{"type":"output_text","text":"hello","annotations":[],"logprobs":[{"token":"hello","logprob":-0.05,"bytes":[104,101,108,108,111],"top_logprobs":[]}]}]}] + }`) + })) + defer server.Close() + + resp, err := newTestResponsesClient(server, "gpt-4o-mini").RunText(t.Context(), "hi").Collect() + if err != nil { + t.Fatalf("error = %v", err) + } + + var text *message.TextContent + for content := range resp.Contents() { + if tc, ok := content.(*message.TextContent); ok { + text = tc + } + } + if text == nil { + t.Fatalf("no TextContent in response") + } + if _, ok := text.AdditionalProperties["Logprobs"]; !ok { + t.Errorf("Logprobs missing from TextContent AdditionalProperties: %#v", text.AdditionalProperties) + } +} + +// The streaming output_item.done path must also surface output_text logprobs on +// the emitted text content. +func TestResponsesOutputTextLogprobsSurfaced_Streaming(t *testing.T) { + const input = `{"model":"gpt-4o-mini","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}],"stream":true}` + const output = `event: response.created +data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_lp","object":"response","created_at":1741891428,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":1,"item_id":"msg_lp","output_index":0,"content_index":0,"delta":"hello"} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"type":"message","id":"msg_lp","status":"completed","role":"assistant","content":[{"type":"output_text","text":"hello","annotations":[],"logprobs":[{"token":"hello","logprob":-0.05,"bytes":[104],"top_logprobs":[]}]}]}} + +event: response.completed +data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_lp","object":"response","created_at":1741891428,"status":"completed","model":"gpt-4o-mini","output":[]}} + +` + + server := newTestResponsesServerStreaming(t, input, output) + defer server.Close() + + var withLogprobs *message.TextContent + for update, err := range newTestResponsesClient(server, "gpt-4o-mini").RunText(t.Context(), "hi", agent.Stream(true)) { + if err != nil { + t.Fatalf("error = %v", err) + } + for _, content := range update.Contents { + if tc, ok := content.(*message.TextContent); ok { + if _, ok := tc.AdditionalProperties["Logprobs"]; ok { + withLogprobs = tc + } + } + } + } + if withLogprobs == nil { + t.Fatal("no streamed TextContent carried Logprobs from output_item.done") + } +} + func TestNewAgentCurrentlyUsesResponsesAPI(t *testing.T) { const input = ` {