From 8aa128dfb1ec495474045305ec8fb92e2555d44f Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Fri, 18 Sep 2026 17:58:20 +0530 Subject: [PATCH 1/2] 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 = ` { From 51552c492e2adbd16a06cd249095672b2f7427c5 Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Mon, 21 Sep 2026 17:01:44 +0530 Subject: [PATCH 2/2] Address review: accumulate streaming chat logprobs across chunks Streaming logprobs arrive incrementally (each chunk carries only its own tokens), and Response.Update merges AdditionalProperties with maps.Copy, so attaching per-chunk logprobs kept only the final chunk's tokens after Collect(). Accumulate content/refusal token logprobs across chunks and attach the running total, so the collected message holds the complete per-choice list. --- go.mod | 21 ++++++------- go.sum | 44 +++++++++++++++------------- provider/openaiprovider/chat.go | 10 ++++++- provider/openaiprovider/chat_test.go | 25 ++++++++++++++++ 4 files changed, 69 insertions(+), 31 deletions(-) diff --git a/go.mod b/go.mod index d18de353..0a9307b3 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,8 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 github.com/a2aproject/a2a-go/v2 v2.5.0 github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-20260312103001-8e7ab1df34c8 - github.com/anthropics/anthropic-sdk-go v1.72.0 - github.com/github/copilot-sdk/go v1.0.13 + github.com/anthropics/anthropic-sdk-go v1.73.0 + github.com/github/copilot-sdk/go v1.0.14 github.com/gofrs/flock v0.13.1 github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 @@ -22,12 +22,12 @@ require ( go.opentelemetry.io/otel/trace v1.46.0 golang.org/x/sync v0.23.0 google.golang.org/genai v1.71.0 - google.golang.org/grpc v1.83.2 + google.golang.org/grpc v1.84.0 ) require ( cloud.google.com/go v0.116.0 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect @@ -36,14 +36,14 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coder/websocket v1.8.15 // indirect github.com/ebitengine/purego v0.10.1 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect - github.com/googleapis/gax-go/v2 v2.17.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/invopop/jsonschema v0.14.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect @@ -59,7 +59,7 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/crypto v0.55.0 // indirect golang.org/x/mod v0.38.0 // indirect @@ -68,7 +68,8 @@ require ( golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/api v0.278.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 9ff26b78..2cc359b8 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= -cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= -cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo= @@ -20,8 +20,8 @@ github.com/a2aproject/a2a-go/v2 v2.5.0 h1:ZdcFoxv+nZTUV0i2ue5hES76YCANFPG9vjqd7v github.com/a2aproject/a2a-go/v2 v2.5.0/go.mod h1:NcRp/ZHxgMzDj12/BteIC2gOjljuEBKaGRfEdJ2lNSI= github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-20260312103001-8e7ab1df34c8 h1:gcgM2iVSJ+5v1Li2O2xBm8jdY9bIo0coZdOlXOd0uLU= github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-20260312103001-8e7ab1df34c8/go.mod h1:ERAMOexUee4AIuoxksuuGoEcHl3aqLwaazjGwlR9ZCI= -github.com/anthropics/anthropic-sdk-go v1.72.0 h1:T0qQWWfygiL24lqEQaffRXCvEeno3yiv/mFzUdY1E88= -github.com/anthropics/anthropic-sdk-go v1.72.0/go.mod h1:x+lPk/cCl48uRegeP0hlYYBN1b7bEBTveInIMgLicnY= +github.com/anthropics/anthropic-sdk-go v1.73.0 h1:brLt0oVV6LH0YcG6O6G3pBnIVDJQ/QPoXul+OJHXEFc= +github.com/anthropics/anthropic-sdk-go v1.73.0/go.mod h1:x+lPk/cCl48uRegeP0hlYYBN1b7bEBTveInIMgLicnY= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= @@ -36,10 +36,10 @@ github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/github/copilot-sdk/go v1.0.13 h1:sc4kLSJksYb6lsUwFV48P4jDJ06PQR+4OzpqOhvxho0= -github.com/github/copilot-sdk/go v1.0.13/go.mod h1:U0STg9Jdv9LDwtkUtcUJCzPQ4KTnKakRfP+1LhEx248= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/github/copilot-sdk/go v1.0.14 h1:DeHPF2CbxRCe4EeAjYIo7v3CovGEEMHsrURUNaogf5A= +github.com/github/copilot-sdk/go v1.0.14/go.mod h1:U0STg9Jdv9LDwtkUtcUJCzPQ4KTnKakRfP+1LhEx248= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -59,10 +59,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= -github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= -github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= -github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= @@ -107,8 +107,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo= @@ -151,14 +151,18 @@ golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.278.0 h1:W7jiRvRi53VYFfZ/HoZjQBtJk7gOFbHD8ot1RzVZU6E= +google.golang.org/api v0.278.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= google.golang.org/genai v1.71.0 h1:Wfo9n0uSzMhZH7d+rP7QxxSWELEDSD4z6O8W/C9s3oM= google.golang.org/genai v1.71.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 h1:admdQBe8jR3VWhBsUrAOaF2Qw6K/+p5pSm1GN8+6Fw4= +google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800/go.mod h1:FPk7EXUKMtImne7AmknoYjT4QXqKIzzRbeQIXzLk6fQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/provider/openaiprovider/chat.go b/provider/openaiprovider/chat.go index 0cca3706..6590448f 100644 --- a/provider/openaiprovider/chat.go +++ b/provider/openaiprovider/chat.go @@ -191,6 +191,10 @@ func (a *chatClient) run(ctx context.Context, messages []*message.Message, optio stream := a.client.Chat.Completions.NewStreaming(ctx, body, telemetryRequestOption) defer func() { _ = stream.Close() }() var acc openai.ChatCompletionAccumulator + // Logprobs stream incrementally (one chunk carries only its own tokens), + // so accumulate them across chunks; each update carries the running total + // and the final message keeps the complete per-choice list. + var accLogprobs openai.ChatCompletionChunkChoiceLogprobs for stream.Next() { chunk := stream.Current() if !acc.AddChunk(chunk) { @@ -223,9 +227,13 @@ func (a *chatClient) run(ctx context.Context, messages []*message.Message, optio 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} + accLogprobs.Content = append(accLogprobs.Content, logprobs.Content...) + accLogprobs.Refusal = append(accLogprobs.Refusal, logprobs.Refusal...) } } + if len(accLogprobs.Content) > 0 || len(accLogprobs.Refusal) > 0 { + additionalProperties = map[string]any{"Logprobs": accLogprobs} + } if chunk.SystemFingerprint != "" { if additionalProperties == nil { additionalProperties = make(map[string]any) diff --git a/provider/openaiprovider/chat_test.go b/provider/openaiprovider/chat_test.go index 07077d2f..8b8b855e 100644 --- a/provider/openaiprovider/chat_test.go +++ b/provider/openaiprovider/chat_test.go @@ -469,6 +469,31 @@ func TestChatResponseMetadataSurfaced_Streaming(t *testing.T) { } } +// Streaming logprobs arrive incrementally per chunk; the collected message must +// carry the accumulated per-choice list, not just the final chunk's tokens. +func TestChatStreamingLogprobsAccumulated(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\":\"c\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-4o-mini\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hel\"},\"logprobs\":{\"content\":[{\"token\":\"Hel\",\"logprob\":-0.1,\"bytes\":[72],\"top_logprobs\":[]}]}}]}\n\n") + _, _ = io.WriteString(w, "data: {\"id\":\"c\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-4o-mini\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"lo\"},\"logprobs\":{\"content\":[{\"token\":\"lo\",\"logprob\":-0.2,\"bytes\":[108],\"top_logprobs\":[]}]},\"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) + lp, ok := props["Logprobs"].(openai.ChatCompletionChunkChoiceLogprobs) + if !ok { + t.Fatalf("Logprobs = %T, want ChatCompletionChunkChoiceLogprobs", props["Logprobs"]) + } + if len(lp.Content) != 2 { + t.Errorf("accumulated logprob content = %d tokens, want 2 (one per chunk)", len(lp.Content)) + } +} + // 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 {