From 8aa128dfb1ec495474045305ec8fb92e2555d44f Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Fri, 18 Sep 2026 17:58:20 +0530 Subject: [PATCH] Surface chat system_fingerprint and logprobs as response metadata The Chat path set ResponseID, FinishReason, and usage but never populated AdditionalProperties, dropping the response system_fingerprint and the per-choice logprobs that a caller requesting logprobs would expect back. The Responses path already populates AdditionalProperties, so this was also a chat-vs-responses asymmetry. Populate AdditionalProperties with SystemFingerprint (when present) and Logprobs (when the choice carries any) on both the non-streaming and streaming paths, matching the Python client which surfaces both as chat response metadata. --- provider/openaiprovider/chat.go | 50 ++++++++++++++++++-------- provider/openaiprovider/chat_test.go | 54 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/provider/openaiprovider/chat.go b/provider/openaiprovider/chat.go index 1479a0bb..0cca3706 100644 --- a/provider/openaiprovider/chat.go +++ b/provider/openaiprovider/chat.go @@ -131,6 +131,7 @@ func (a *chatClient) run(ctx context.Context, messages []*message.Message, optio // the streaming path below. var contents []message.Content var finishReason string + var additionalProperties map[string]any if len(resp.Choices) > 0 { choice := resp.Choices[0] contents = make([]message.Content, 0, 1+len(choice.Message.ToolCalls)) @@ -150,19 +151,29 @@ func (a *chatClient) run(ctx context.Context, messages []*message.Message, optio contents = append(contents, &message.ErrorContent{Message: choice.Message.Refusal, ErrorCode: "Refusal"}) } finishReason = choice.FinishReason + if len(choice.Logprobs.Content) > 0 || len(choice.Logprobs.Refusal) > 0 { + additionalProperties = map[string]any{"Logprobs": choice.Logprobs} + } + } + if resp.SystemFingerprint != "" { + if additionalProperties == nil { + additionalProperties = make(map[string]any) + } + additionalProperties["SystemFingerprint"] = resp.SystemFingerprint } if resp.JSON.Usage.Valid() { contents = addUsage(contents, resp.Usage) } return func(yield func(*agent.ResponseUpdate, error) bool) { update := &agent.ResponseUpdate{ - Contents: contents, - Role: message.RoleAssistant, - ResponseID: resp.ID, - MessageID: resp.ID, - FinishReason: finishReason, - CreatedAt: time.Unix(resp.Created, 0), - RawRepresentation: resp, + Contents: contents, + Role: message.RoleAssistant, + ResponseID: resp.ID, + MessageID: resp.ID, + FinishReason: finishReason, + CreatedAt: time.Unix(resp.Created, 0), + AdditionalProperties: additionalProperties, + RawRepresentation: resp, } if !yield(update, nil) { return @@ -208,17 +219,28 @@ func (a *chatClient) run(ctx context.Context, messages []*message.Message, optio contents = addUsage(contents, chunk.Usage) } var finishReason string + var additionalProperties map[string]any if len(chunk.Choices) > 0 { finishReason = chunk.Choices[0].FinishReason + if logprobs := chunk.Choices[0].Logprobs; len(logprobs.Content) > 0 || len(logprobs.Refusal) > 0 { + additionalProperties = map[string]any{"Logprobs": logprobs} + } + } + if chunk.SystemFingerprint != "" { + if additionalProperties == nil { + additionalProperties = make(map[string]any) + } + additionalProperties["SystemFingerprint"] = chunk.SystemFingerprint } resp := &agent.ResponseUpdate{ - Contents: contents, - Role: role, - ResponseID: chunk.ID, - MessageID: chunk.ID, - FinishReason: finishReason, - CreatedAt: time.Unix(chunk.Created, 0), - RawRepresentation: chunk, + Contents: contents, + Role: role, + ResponseID: chunk.ID, + MessageID: chunk.ID, + FinishReason: finishReason, + CreatedAt: time.Unix(chunk.Created, 0), + AdditionalProperties: additionalProperties, + RawRepresentation: chunk, } if !yield(resp, nil) { return diff --git a/provider/openaiprovider/chat_test.go b/provider/openaiprovider/chat_test.go index 20447b8c..07077d2f 100644 --- a/provider/openaiprovider/chat_test.go +++ b/provider/openaiprovider/chat_test.go @@ -428,6 +428,60 @@ func TestChatBasicRequestResponse_NonStreaming(t *testing.T) { } } +// system_fingerprint (response) and per-choice logprobs must be surfaced on the +// update's AdditionalProperties, matching the Python client which carries both +// as chat response metadata. +func TestChatResponseMetadataSurfaced_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":"chatcmpl-md","object":"chat.completion","created":1727888631,"model":"gpt-4o-mini","system_fingerprint":"fp_test","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"logprobs":{"content":[{"token":"ok","logprob":-0.1,"bytes":[111,107],"top_logprobs":[]}]},"finish_reason":"stop"}]}`) + })) + defer server.Close() + + resp, err := newTestClient(server).RunText(t.Context(), "hi").Collect() + if err != nil { + t.Fatalf("error = %v", err) + } + props := lastMessageAdditionalProperties(t, resp) + if props["SystemFingerprint"] != "fp_test" { + t.Errorf("SystemFingerprint = %v, want %q", props["SystemFingerprint"], "fp_test") + } + if _, ok := props["Logprobs"]; !ok { + t.Errorf("Logprobs missing from AdditionalProperties: %#v", props) + } +} + +func TestChatResponseMetadataSurfaced_Streaming(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"id\":\"chatcmpl-md\",\"object\":\"chat.completion.chunk\",\"created\":1727888631,\"model\":\"gpt-4o-mini\",\"system_fingerprint\":\"fp_test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\n") + _, _ = io.WriteString(w, "data: [DONE]\n\n") + })) + defer server.Close() + + resp, err := newTestClient(server).RunText(t.Context(), "hi", agent.Stream(true)).Collect() + if err != nil { + t.Fatalf("error = %v", err) + } + props := lastMessageAdditionalProperties(t, resp) + if props["SystemFingerprint"] != "fp_test" { + t.Errorf("SystemFingerprint = %v, want %q", props["SystemFingerprint"], "fp_test") + } +} + +// lastMessageAdditionalProperties returns the AdditionalProperties of the last +// message carrying any, so metadata assertions do not depend on message count. +func lastMessageAdditionalProperties(t *testing.T, resp *agent.Response) map[string]any { + t.Helper() + for i := len(resp.Messages) - 1; i >= 0; i-- { + if len(resp.Messages[i].AdditionalProperties) > 0 { + return resp.Messages[i].AdditionalProperties + } + } + t.Fatalf("no message carried AdditionalProperties: %#v", resp.Messages) + return nil +} + func TestChatURLCitationAnnotations_NonStreaming(t *testing.T) { const input = ` {