From 01a5b62042bcde8d919895bcf5656861ed22018f Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Tue, 22 Sep 2026 10:52:45 +0530 Subject: [PATCH 1/3] Add Response.ModelID and fold it from updates Response and ResponseUpdate now carry ModelID, the identifier of the model that produced the response, matching M.E.AI ChatResponse/ChatResponseUpdate (ProcessUpdate folds ModelId). Response.Update folds it like ResponseID, and ToUpdates round-trips it. The OpenAI (chat + responses), Anthropic, and Gemini providers populate it from the model on the response. ConversationID is intentionally not added: in Go the conversation identity lives on Session.ServiceID, so a separate field would be redundant. Implements #1140. --- agent/response.go | 11 +++++++++++ agent/response_test.go | 20 ++++++++++++++++++++ provider/anthropicprovider/agent.go | 1 + provider/geminiprovider/agent.go | 1 + provider/openaiprovider/chat.go | 2 ++ provider/openaiprovider/chat_test.go | 17 +++++++++++++++++ provider/openaiprovider/responses.go | 2 ++ 7 files changed, 54 insertions(+) diff --git a/agent/response.go b/agent/response.go index 0a74bedd..aaecb2bc 100644 --- a/agent/response.go +++ b/agent/response.go @@ -45,6 +45,10 @@ type Response struct { // ID identifies this response. ID string `json:",omitzero"` + // ModelID is the identifier of the model that produced this response, when + // the provider supplies it. It is empty otherwise. + ModelID string `json:",omitzero"` + // CreatedAt is the timestamp for the response. It is zero when the provider // did not supply a creation time. CreatedAt time.Time `json:",omitzero"` @@ -152,6 +156,7 @@ func (resp *Response) ToUpdates() []*ResponseUpdate { AgentID: resp.AgentID, MessageID: msg.ID, ResponseID: resp.ID, + ModelID: resp.ModelID, FinishReason: resp.FinishReason, AuthorName: msg.AuthorName, Role: msg.Role, @@ -203,6 +208,7 @@ func (resp *Response) Update(update *ResponseUpdate) { // Update the response object with those, preferring the values from later updates. resp.AgentID = cmp.Or(update.AgentID, resp.AgentID) resp.ID = cmp.Or(update.ResponseID, resp.ID) + resp.ModelID = cmp.Or(update.ModelID, resp.ModelID) resp.FinishReason = cmp.Or(update.FinishReason, resp.FinishReason) resp.RawRepresentation = appendRawRepresentation(resp.RawRepresentation, update.RawRepresentation) if update.ContinuationToken == "" { @@ -295,6 +301,11 @@ type ResponseUpdate struct { // ResponseID identifies the response of which this update is a part. ResponseID string + // ModelID is the identifier of the model that produced this update, when the + // provider supplies it. It is typically set on updates that carry provider + // response metadata. + ModelID string `json:",omitzero"` + // FinishReason is the reason the generation ended. It is typically set only // on the final update of a stream. Common values are "stop", "length", and // "tool_calls". diff --git a/agent/response_test.go b/agent/response_test.go index 7d45feee..a7dbe40d 100644 --- a/agent/response_test.go +++ b/agent/response_test.go @@ -455,6 +455,26 @@ func TestResponse_CreatedAt(t *testing.T) { } } +// ModelID folds onto the response from later updates and round-trips through +// ToUpdates, matching how ResponseID/FinishReason are handled. +func TestResponse_Update_ModelID(t *testing.T) { + resp := &agent.Response{} + resp.Update(&agent.ResponseUpdate{MessageID: "m1", Contents: message.Contents{&message.TextContent{Text: "hi"}}}) + resp.Update(&agent.ResponseUpdate{MessageID: "m1", ModelID: "gpt-4o-mini-2024-07-18"}) + if resp.ModelID != "gpt-4o-mini-2024-07-18" { + t.Fatalf("ModelID = %q, want gpt-4o-mini-2024-07-18", resp.ModelID) + } + + // Round-trip: ToUpdates carries ModelID, and re-collecting preserves it. + var collected agent.Response + for _, u := range resp.ToUpdates() { + collected.Update(u) + } + if collected.ModelID != resp.ModelID { + t.Errorf("round-tripped ModelID = %q, want %q", collected.ModelID, resp.ModelID) + } +} + func TestResponse_Update_AdditionalProperties(t *testing.T) { resp := &agent.Response{} diff --git a/provider/anthropicprovider/agent.go b/provider/anthropicprovider/agent.go index 43d25728..909374fd 100644 --- a/provider/anthropicprovider/agent.go +++ b/provider/anthropicprovider/agent.go @@ -124,6 +124,7 @@ func (a *client) run(ctx context.Context, messages []*message.Message, options . Role: message.RoleAssistant, MessageID: resp.ID, ResponseID: resp.ID, + ModelID: string(resp.Model), CreatedAt: time.Now(), FinishReason: mapStopReason(resp.StopReason), RawRepresentation: resp, diff --git a/provider/geminiprovider/agent.go b/provider/geminiprovider/agent.go index f56fa601..9aae1367 100644 --- a/provider/geminiprovider/agent.go +++ b/provider/geminiprovider/agent.go @@ -140,6 +140,7 @@ func (a *client) run(ctx context.Context, messages []*message.Message, options . yield(&agent.ResponseUpdate{ Contents: responseContents, Role: message.RoleAssistant, + ModelID: resp.ModelVersion, FinishReason: finishReason, CreatedAt: time.Now(), RawRepresentation: resp, diff --git a/provider/openaiprovider/chat.go b/provider/openaiprovider/chat.go index 30386aba..9c3b6f5e 100644 --- a/provider/openaiprovider/chat.go +++ b/provider/openaiprovider/chat.go @@ -160,6 +160,7 @@ func (a *chatClient) run(ctx context.Context, messages []*message.Message, optio Role: message.RoleAssistant, ResponseID: resp.ID, MessageID: resp.ID, + ModelID: resp.Model, FinishReason: finishReason, CreatedAt: time.Unix(resp.Created, 0), RawRepresentation: resp, @@ -216,6 +217,7 @@ func (a *chatClient) run(ctx context.Context, messages []*message.Message, optio Role: role, ResponseID: chunk.ID, MessageID: chunk.ID, + ModelID: chunk.Model, FinishReason: finishReason, CreatedAt: time.Unix(chunk.Created, 0), RawRepresentation: chunk, diff --git a/provider/openaiprovider/chat_test.go b/provider/openaiprovider/chat_test.go index 79fd8ac2..9962c721 100644 --- a/provider/openaiprovider/chat_test.go +++ b/provider/openaiprovider/chat_test.go @@ -475,6 +475,23 @@ func TestChatLegacyFunctionCallFinishReasonNormalized_Streaming(t *testing.T) { } } +// The model that produced the response must be surfaced on Response.ModelID. +func TestChatModelIDSurfaced(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-m","object":"chat.completion","created":1727888631,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`) + })) + defer server.Close() + + resp, err := newTestClient(server).RunText(t.Context(), "hi").Collect() + if err != nil { + t.Fatalf("error = %v", err) + } + if resp.ModelID != "gpt-4o-mini-2024-07-18" { + t.Errorf("ModelID = %q, want gpt-4o-mini-2024-07-18", resp.ModelID) + } +} + func TestChatURLCitationAnnotations_NonStreaming(t *testing.T) { const input = ` { diff --git a/provider/openaiprovider/responses.go b/provider/openaiprovider/responses.go index 0f380ffc..62dcc489 100644 --- a/provider/openaiprovider/responses.go +++ b/provider/openaiprovider/responses.go @@ -1080,6 +1080,7 @@ func responsesProcessResponse(resp *responses.Response, seqNum int64, yield func currentUpdate := &agent.ResponseUpdate{ ResponseID: resp.ID, + ModelID: resp.Model, FinishReason: finishReason, CreatedAt: time.Unix(int64(resp.CreatedAt), 0), Role: message.RoleAssistant, @@ -1107,6 +1108,7 @@ func responsesProcessResponse(resp *responses.Response, seqNum int64, yield func } currentUpdate.MessageID = out.ID currentUpdate.ResponseID = resp.ID + currentUpdate.ModelID = resp.Model currentUpdate.FinishReason = finishReason // Only set ContinuationToken if it's not empty if contToken != "" { From 2b1d5cfdb60c768e3be0d265386b772a12c68317 Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Sat, 3 Oct 2026 12:19:35 +0530 Subject: [PATCH 2/3] Round-trip ModelID through the metadata-only update in ToUpdates From 234cf7adbdffa946d3ac1fa20f0cf17816aa2477 Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Thu, 8 Oct 2026 11:00:49 +0530 Subject: [PATCH 3/3] Propagate ModelID through streaming updates for openai-responses, anthropic, and gemini --- provider/anthropicprovider/agent.go | 4 ++++ provider/anthropicprovider/agent_test.go | 19 +++++++++++++++++++ provider/geminiprovider/agent.go | 2 ++ provider/openaiprovider/responses.go | 18 ++++++++++++++++++ 4 files changed, 43 insertions(+) diff --git a/provider/anthropicprovider/agent.go b/provider/anthropicprovider/agent.go index 909374fd..865e23e3 100644 --- a/provider/anthropicprovider/agent.go +++ b/provider/anthropicprovider/agent.go @@ -136,6 +136,7 @@ func (a *client) run(ctx context.Context, messages []*message.Message, options . defer func() { _ = stream.Close() }() var messageID string + var modelID string var finishReason string var usage message.UsageDetails var accumulated anthropic.Message @@ -151,6 +152,7 @@ func (a *client) run(ctx context.Context, messages []*message.Message, options . switch event := event.AsAny().(type) { case anthropic.MessageStartEvent: messageID = cmp.Or(messageID, event.Message.ID) + modelID = cmp.Or(modelID, string(event.Message.Model)) usage.Add(toUsageDetails(event.Message.Usage)) case anthropic.MessageDeltaEvent: // Anthropic reports the final cumulative output token count on @@ -214,6 +216,7 @@ func (a *client) run(ctx context.Context, messages []*message.Message, options . Role: message.RoleAssistant, ResponseID: messageID, MessageID: messageID, + ModelID: modelID, CreatedAt: time.Now(), RawRepresentation: event, }, nil) { @@ -228,6 +231,7 @@ func (a *client) run(ctx context.Context, messages []*message.Message, options . CreatedAt: time.Now(), Role: message.RoleAssistant, MessageID: messageID, + ModelID: modelID, FinishReason: finishReason, Contents: []message.Content{ &message.UsageContent{ diff --git a/provider/anthropicprovider/agent_test.go b/provider/anthropicprovider/agent_test.go index 4a81a6c0..193b8596 100644 --- a/provider/anthropicprovider/agent_test.go +++ b/provider/anthropicprovider/agent_test.go @@ -247,6 +247,25 @@ func TestStreamingUsage_DoesNotDoubleCountOutputTokens(t *testing.T) { // minimalStreamingResponse returns an SSE stream that delivers payload as a // single text delta. +// A streamed run collected via ResponseStream.Collect must surface the model +// that produced the response on Response.ModelID, matching the non-streaming +// path. The model arrives on the message_start event. +func TestStreamingModelIDSurfaced(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, minimalStreamingResponse("hi")) + })) + defer server.Close() + + resp, err := newTestClient(t, server).RunText(t.Context(), "hello", agent.Stream(true)).Collect() + if err != nil { + t.Fatalf("error = %v", err) + } + if resp.ModelID != "claude-3-5-sonnet-20241022" { + t.Errorf("ModelID = %q, want claude-3-5-sonnet-20241022", resp.ModelID) + } +} + func minimalStreamingResponse(payload string) string { payloadJSON, _ := json.Marshal(payload) return "" + diff --git a/provider/geminiprovider/agent.go b/provider/geminiprovider/agent.go index c703a77a..1cb8fa8c 100644 --- a/provider/geminiprovider/agent.go +++ b/provider/geminiprovider/agent.go @@ -188,6 +188,7 @@ func (a *client) run(ctx context.Context, messages []*message.Message, options . if !yield(&agent.ResponseUpdate{ Contents: streamContents, Role: message.RoleAssistant, + ModelID: resp.ModelVersion, FinishReason: finishReason, CreatedAt: time.Now(), RawRepresentation: resp, @@ -203,6 +204,7 @@ func (a *client) run(ctx context.Context, messages []*message.Message, options . yield(&agent.ResponseUpdate{ Contents: finalContents, Role: message.RoleAssistant, + ModelID: latestMetadataResp.ModelVersion, CreatedAt: time.Now(), RawRepresentation: latestMetadataResp, }, nil) diff --git a/provider/openaiprovider/responses.go b/provider/openaiprovider/responses.go index 7542fd9d..4f457a1a 100644 --- a/provider/openaiprovider/responses.go +++ b/provider/openaiprovider/responses.go @@ -1431,6 +1431,7 @@ func responsesErrorContent(msg string, code string, details string) *message.Err type responsesStreamState struct { mcpApprovalRequests map[string]*message.ToolApprovalRequestContent messageID string + model string role message.Role anyFunctions bool // imagePartialsSeen records image-generation item IDs that emitted a @@ -1449,12 +1450,23 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, Role: role, Contents: contents, MessageID: state.messageID, + ModelID: state.model, ResponseID: responseID, RawRepresentation: update, } return u } + // setModel records the response model from a lifecycle event so this and + // every subsequent streaming update carries it, matching the non-streaming + // path. The createUpdate above runs before this, so stamp u directly too. + setModel := func(u *agent.ResponseUpdate, model string) { + if model != "" { + state.model = model + u.ModelID = model + } + } + // Lifecycle metadata belongs to the response, not the active message. // Omit its MessageID without resetting the state used by content updates. var u *agent.ResponseUpdate @@ -1464,6 +1476,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID + setModel(u, event.Response.Model) u.AdditionalProperties = responsesPopulateAdditionalProperties(&event.Response) if contToken := createContinuationToken(event.Response.ID, event.SequenceNumber, event.Response.Status, isBackground); contToken != "" { u.ContinuationToken = contToken @@ -1474,6 +1487,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID + setModel(u, event.Response.Model) u.AdditionalProperties = responsesPopulateAdditionalProperties(&event.Response) if contToken := createContinuationToken(event.Response.ID, event.SequenceNumber, event.Response.Status, isBackground); contToken != "" { u.ContinuationToken = contToken @@ -1484,6 +1498,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID + setModel(u, event.Response.Model) u.AdditionalProperties = responsesPopulateAdditionalProperties(&event.Response) if contToken := createContinuationToken(event.Response.ID, event.SequenceNumber, event.Response.Status, isBackground); contToken != "" { u.ContinuationToken = contToken @@ -1511,6 +1526,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID + setModel(u, event.Response.Model) u.FinishReason = responsesFinishReason(&event.Response) if state.anyFunctions && u.FinishReason == "stop" { u.FinishReason = "tool_calls" @@ -1526,6 +1542,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID + setModel(u, event.Response.Model) u.FinishReason = responsesFinishReason(&event.Response) u.AdditionalProperties = responsesPopulateAdditionalProperties(&event.Response) if contToken := createContinuationToken(event.Response.ID, event.SequenceNumber, event.Response.Status, isBackground); contToken != "" { @@ -1537,6 +1554,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID + setModel(u, event.Response.Model) u.AdditionalProperties = responsesPopulateAdditionalProperties(&event.Response) if failure := responsesFailureContent(&event.Response); failure != nil { u.Contents = []message.Content{failure}