feat(openai): add responses api `store`, `previous_response_id`, and `response.id` support (#175)

Michael Suchacz and Christian Rocha created

Co-authored-by: Christian Rocha <christian@rocha.is>

Change summary

providers/openai/openai_test.go              | 225 +++++++++++++++
providers/openai/responses_language_model.go | 212 +++++++++-----
providers/openai/responses_options.go        |  72 +++
providers/openai/responses_params_test.go    | 320 ++++++++++++++++++++++
4 files changed, 740 insertions(+), 89 deletions(-)

Detailed changes

providers/openai/openai_test.go 🔗

@@ -3593,6 +3593,156 @@ func TestResponsesGenerate_WebSearchResponse(t *testing.T) {
 	)
 }
 
+func TestResponsesGenerate_StoreOption(t *testing.T) {
+	t.Parallel()
+
+	server := newMockServer()
+	defer server.close()
+	server.response = mockResponsesWebSearchResponse()
+
+	model := newResponsesProvider(t, server.server.URL)
+
+	_, err := model.Generate(context.Background(), fantasy.Call{
+		Prompt: testPrompt,
+		ProviderOptions: fantasy.ProviderOptions{
+			Name: &ResponsesProviderOptions{
+				Store: fantasy.Opt(true),
+			},
+		},
+	})
+	require.NoError(t, err)
+
+	require.Equal(t, "POST", server.calls[0].method)
+	require.Equal(t, "/responses", server.calls[0].path)
+	require.Equal(t, true, server.calls[0].body["store"])
+}
+
+func TestResponsesGenerate_PreviousResponseIDOption(t *testing.T) {
+	t.Parallel()
+
+	server := newMockServer()
+	defer server.close()
+	server.response = mockResponsesWebSearchResponse()
+
+	model := newResponsesProvider(t, server.server.URL)
+
+	_, err := model.Generate(context.Background(), fantasy.Call{
+		Prompt: testPrompt,
+		ProviderOptions: fantasy.ProviderOptions{
+			Name: &ResponsesProviderOptions{
+				PreviousResponseID: fantasy.Opt("resp_prev_123"),
+				Store:              fantasy.Opt(true),
+			},
+		},
+	})
+	require.NoError(t, err)
+
+	require.Equal(t, "POST", server.calls[0].method)
+	require.Equal(t, "/responses", server.calls[0].path)
+	require.Equal(t, "resp_prev_123", server.calls[0].body["previous_response_id"])
+}
+
+func TestResponsesGenerate_StateChainingAcrossTurns(t *testing.T) {
+	t.Parallel()
+
+	server := newMockServer()
+	defer server.close()
+	server.response = map[string]any{
+		"id":     "resp_turn_1",
+		"object": "response",
+		"model":  "gpt-4.1",
+		"output": []any{
+			map[string]any{
+				"type":   "message",
+				"id":     "msg_1",
+				"role":   "assistant",
+				"status": "completed",
+				"content": []any{
+					map[string]any{
+						"type": "output_text",
+						"text": "First turn",
+					},
+				},
+			},
+		},
+		"status": "completed",
+		"usage": map[string]any{
+			"input_tokens":  10,
+			"output_tokens": 5,
+			"total_tokens":  15,
+		},
+	}
+
+	model := newResponsesProvider(t, server.server.URL)
+
+	first, err := model.Generate(context.Background(), fantasy.Call{
+		Prompt: testPrompt,
+		ProviderOptions: fantasy.ProviderOptions{
+			Name: &ResponsesProviderOptions{Store: fantasy.Opt(true)},
+		},
+	})
+	require.NoError(t, err)
+
+	meta, ok := first.ProviderMetadata[Name].(*ResponsesProviderMetadata)
+	require.True(t, ok)
+	require.Equal(t, "resp_turn_1", meta.ResponseID)
+
+	server.response = map[string]any{
+		"id":     "resp_turn_2",
+		"object": "response",
+		"model":  "gpt-4.1",
+		"output": []any{
+			map[string]any{
+				"type":   "message",
+				"id":     "msg_2",
+				"role":   "assistant",
+				"status": "completed",
+				"content": []any{
+					map[string]any{
+						"type": "output_text",
+						"text": "Second turn",
+					},
+				},
+			},
+		},
+		"status": "completed",
+		"usage": map[string]any{
+			"input_tokens":  8,
+			"output_tokens": 4,
+			"total_tokens":  12,
+		},
+	}
+
+	_, err = model.Generate(context.Background(), fantasy.Call{
+		Prompt: fantasy.Prompt{
+			fantasy.NewUserMessage("follow-up only"),
+		},
+		ProviderOptions: fantasy.ProviderOptions{
+			Name: &ResponsesProviderOptions{
+				Store:              fantasy.Opt(true),
+				PreviousResponseID: &meta.ResponseID,
+			},
+		},
+	})
+	require.NoError(t, err)
+	require.Len(t, server.calls, 2)
+
+	firstCall := server.calls[0]
+	require.Equal(t, true, firstCall.body["store"])
+
+	secondCall := server.calls[1]
+	require.Equal(t, "resp_turn_1", secondCall.body["previous_response_id"])
+	require.Equal(t, true, secondCall.body["store"])
+
+	input, ok := secondCall.body["input"].([]any)
+	require.True(t, ok)
+	require.Len(t, input, 1)
+
+	inputMessage, ok := input[0].(map[string]any)
+	require.True(t, ok)
+	require.Equal(t, "user", inputMessage["role"])
+}
+
 func TestResponsesGenerate_WebSearchToolInRequest(t *testing.T) {
 	t.Parallel()
 
@@ -3775,6 +3925,7 @@ func TestResponsesStream_WebSearchResponse(t *testing.T) {
 		toolCalls       []fantasy.StreamPart
 		toolResults     []fantasy.StreamPart
 		textDeltas      []fantasy.StreamPart
+		finishes        []fantasy.StreamPart
 	)
 	for _, p := range parts {
 		switch p.Type {
@@ -3786,6 +3937,8 @@ func TestResponsesStream_WebSearchResponse(t *testing.T) {
 			toolResults = append(toolResults, p)
 		case fantasy.StreamPartTypeTextDelta:
 			textDeltas = append(textDeltas, p)
+		case fantasy.StreamPartTypeFinish:
+			finishes = append(finishes, p)
 		}
 	}
 
@@ -3804,4 +3957,76 @@ func TestResponsesStream_WebSearchResponse(t *testing.T) {
 
 	require.NotEmpty(t, textDeltas, "should have text deltas")
 	require.Equal(t, "Here are the results.", textDeltas[0].Delta)
+
+	require.Len(t, finishes, 1)
+	responsesMeta, ok := finishes[0].ProviderMetadata[Name].(*ResponsesProviderMetadata)
+	require.True(t, ok)
+	require.Equal(t, "resp_01", responsesMeta.ResponseID)
+}
+
+func TestResponsesStream_StoreOption(t *testing.T) {
+	t.Parallel()
+
+	chunks := []string{
+		"event: response.completed\n" +
+			`data: {"type":"response.completed","response":{"id":"resp_01","status":"completed","output":[],"usage":{"input_tokens":100,"output_tokens":50,"total_tokens":150}}}` + "\n\n",
+	}
+
+	sms := newStreamingMockServer()
+	defer sms.close()
+	sms.chunks = chunks
+
+	model := newResponsesProvider(t, sms.server.URL)
+
+	stream, err := model.Stream(context.Background(), fantasy.Call{
+		Prompt: testPrompt,
+		ProviderOptions: fantasy.ProviderOptions{
+			Name: &ResponsesProviderOptions{
+				Store: fantasy.Opt(true),
+			},
+		},
+	})
+	require.NoError(t, err)
+
+	stream(func(part fantasy.StreamPart) bool {
+		return part.Type != fantasy.StreamPartTypeFinish
+	})
+
+	require.Equal(t, "POST", sms.calls[0].method)
+	require.Equal(t, "/responses", sms.calls[0].path)
+	require.Equal(t, true, sms.calls[0].body["store"])
+}
+
+func TestResponsesStream_PreviousResponseIDOption(t *testing.T) {
+	t.Parallel()
+
+	chunks := []string{
+		"event: response.completed\n" +
+			`data: {"type":"response.completed","response":{"id":"resp_01","status":"completed","output":[],"usage":{"input_tokens":100,"output_tokens":50,"total_tokens":150}}}` + "\n\n",
+	}
+
+	sms := newStreamingMockServer()
+	defer sms.close()
+	sms.chunks = chunks
+
+	model := newResponsesProvider(t, sms.server.URL)
+
+	stream, err := model.Stream(context.Background(), fantasy.Call{
+		Prompt: testPrompt,
+		ProviderOptions: fantasy.ProviderOptions{
+			Name: &ResponsesProviderOptions{
+				PreviousResponseID: fantasy.Opt("resp_prev_456"),
+				Store:              fantasy.Opt(true),
+			},
+		},
+	})
+	require.NoError(t, err)
+
+	stream(func(part fantasy.StreamPart) bool {
+		return part.Type != fantasy.StreamPartTypeFinish
+	})
+
+	require.Equal(t, "POST", sms.calls[0].method)
+	require.Equal(t, "/responses", sms.calls[0].path)
+	require.Equal(t, "resp_prev_456", sms.calls[0].body["previous_response_id"])
 }

providers/openai/responses_language_model.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/base64"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"reflect"
 	"strings"
@@ -28,8 +29,7 @@ type responsesLanguageModel struct {
 	noDefaultUserAgent bool
 }
 
-// newResponsesLanguageModel implements a responses api model
-// INFO: (kujtim) currently we do not support stored parameter we default it to false.
+// newResponsesLanguageModel implements a responses api model.
 func newResponsesLanguageModel(modelID string, provider string, client openai.Client, objectMode fantasy.ObjectMode, noDefaultUserAgent bool) responsesLanguageModel {
 	return responsesLanguageModel{
 		modelID:            modelID,
@@ -121,11 +121,14 @@ func getResponsesModelConfig(modelID string) responsesModelConfig {
 	}
 }
 
-func (o responsesLanguageModel) prepareParams(call fantasy.Call) (*responses.ResponseNewParams, []fantasy.CallWarning) {
+const (
+	previousResponseIDHistoryError = "cannot combine previous_response_id with replayed conversation history; use either previous_response_id (server-side chaining) or explicit message replay, not both"
+	previousResponseIDStoreError   = "previous_response_id requires store to be true; the current response will not be stored and cannot be used for further chaining"
+)
+
+func (o responsesLanguageModel) prepareParams(call fantasy.Call) (*responses.ResponseNewParams, []fantasy.CallWarning, error) {
 	var warnings []fantasy.CallWarning
-	params := &responses.ResponseNewParams{
-		Store: param.NewOpt(false),
-	}
+	params := &responses.ResponseNewParams{}
 
 	modelConfig := getResponsesModelConfig(o.modelID)
 
@@ -157,6 +160,22 @@ func (o responsesLanguageModel) prepareParams(call fantasy.Call) (*responses.Res
 		}
 	}
 
+	if openaiOptions != nil && openaiOptions.Store != nil {
+		params.Store = param.NewOpt(*openaiOptions.Store)
+	} else {
+		params.Store = param.NewOpt(false)
+	}
+
+	if openaiOptions != nil && openaiOptions.PreviousResponseID != nil && *openaiOptions.PreviousResponseID != "" {
+		if err := validatePreviousResponseIDPrompt(call.Prompt); err != nil {
+			return nil, warnings, err
+		}
+		if openaiOptions.Store == nil || !*openaiOptions.Store {
+			return nil, warnings, errors.New(previousResponseIDStoreError)
+		}
+		params.PreviousResponseID = param.NewOpt(*openaiOptions.PreviousResponseID)
+	}
+
 	input, inputWarnings := toResponsesPrompt(call.Prompt, modelConfig.systemMessageMode)
 	warnings = append(warnings, inputWarnings...)
 
@@ -328,7 +347,46 @@ func (o responsesLanguageModel) prepareParams(call fantasy.Call) (*responses.Res
 		params.ToolChoice = toolChoice
 	}
 
-	return params, warnings
+	return params, warnings, nil
+}
+
+func validatePreviousResponseIDPrompt(prompt fantasy.Prompt) error {
+	for _, msg := range prompt {
+		switch msg.Role {
+		case fantasy.MessageRoleSystem, fantasy.MessageRoleUser:
+			continue
+		default:
+			return errors.New(previousResponseIDHistoryError)
+		}
+	}
+	return nil
+}
+
+func responsesProviderMetadata(responseID string) fantasy.ProviderMetadata {
+	if responseID == "" {
+		return fantasy.ProviderMetadata{}
+	}
+
+	return fantasy.ProviderMetadata{
+		Name: &ResponsesProviderMetadata{
+			ResponseID: responseID,
+		},
+	}
+}
+
+func responsesUsage(resp responses.Response) fantasy.Usage {
+	usage := fantasy.Usage{
+		InputTokens:  resp.Usage.InputTokens,
+		OutputTokens: resp.Usage.OutputTokens,
+		TotalTokens:  resp.Usage.InputTokens + resp.Usage.OutputTokens,
+	}
+	if resp.Usage.OutputTokensDetails.ReasoningTokens != 0 {
+		usage.ReasoningTokens = resp.Usage.OutputTokensDetails.ReasoningTokens
+	}
+	if resp.Usage.InputTokensDetails.CachedTokens != 0 {
+		usage.CacheReadTokens = resp.Usage.InputTokensDetails.CachedTokens
+	}
+	return usage
 }
 
 func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string) (responses.ResponseInputParam, []fantasy.CallWarning) {
@@ -512,7 +570,7 @@ func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string) (respons
 						continue
 					}
 					// we want to always send an empty array
-					summary := []responses.ResponseReasoningItemSummaryParam{}
+					summary := make([]responses.ResponseReasoningItemSummaryParam, 0, len(reasoningMetadata.Summary))
 					for _, s := range reasoningMetadata.Summary {
 						summary = append(summary, responses.ResponseReasoningItemSummaryParam{
 							Type: "summary_text",
@@ -607,7 +665,7 @@ func hasVisibleResponsesUserContent(content responses.ResponseInputMessageConten
 func hasVisibleResponsesAssistantContent(items []responses.ResponseInputItemUnionParam, startIdx int) bool {
 	// Check if we added any assistant content parts from this message
 	for i := startIdx; i < len(items); i++ {
-		if items[i].OfMessage != nil || items[i].OfFunctionCall != nil {
+		if items[i].OfMessage != nil || items[i].OfFunctionCall != nil || items[i].OfItemReference != nil {
 			return true
 		}
 	}
@@ -695,7 +753,11 @@ func toResponsesTools(tools []fantasy.Tool, toolChoice *fantasy.ToolChoice, opti
 }
 
 func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
-	params, warnings := o.prepareParams(call)
+	params, warnings, err := o.prepareParams(call)
+	if err != nil {
+		return nil, err
+	}
+
 	response, err := o.client.Responses.New(ctx, *params, callUARequestOptions(call, o.noDefaultUserAgent)...)
 	if err != nil {
 		return nil, toProviderErr(err)
@@ -812,26 +874,14 @@ func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call)
 		}
 	}
 
-	usage := fantasy.Usage{
-		InputTokens:  response.Usage.InputTokens,
-		OutputTokens: response.Usage.OutputTokens,
-		TotalTokens:  response.Usage.InputTokens + response.Usage.OutputTokens,
-	}
-
-	if response.Usage.OutputTokensDetails.ReasoningTokens != 0 {
-		usage.ReasoningTokens = response.Usage.OutputTokensDetails.ReasoningTokens
-	}
-	if response.Usage.InputTokensDetails.CachedTokens != 0 {
-		usage.CacheReadTokens = response.Usage.InputTokensDetails.CachedTokens
-	}
-
+	usage := responsesUsage(*response)
 	finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, hasFunctionCall)
 
 	return &fantasy.Response{
 		Content:          content,
 		Usage:            usage,
 		FinishReason:     finishReason,
-		ProviderMetadata: fantasy.ProviderMetadata{},
+		ProviderMetadata: responsesProviderMetadata(response.ID),
 		Warnings:         warnings,
 	}, nil
 }
@@ -854,12 +904,21 @@ func mapResponsesFinishReason(reason string, hasFunctionCall bool) fantasy.Finis
 }
 
 func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
-	params, warnings := o.prepareParams(call)
+	params, warnings, err := o.prepareParams(call)
+	if err != nil {
+		return nil, err
+	}
 
 	stream := o.client.Responses.NewStreaming(ctx, *params, callUARequestOptions(call, o.noDefaultUserAgent)...)
 
 	finishReason := fantasy.FinishReasonUnknown
 	var usage fantasy.Usage
+	// responseID tracks the server-assigned response ID. It's first set from the
+	// response.created event and may be overwritten by response.completed or
+	// response.incomplete events. Per the OpenAI API contract, these IDs are
+	// identical; the overwrites ensure we have the final value even if an event
+	// is missed.
+	responseID := ""
 	ongoingToolCalls := make(map[int64]*ongoingToolCall)
 	hasFunctionCall := false
 	activeReasoning := make(map[string]*reasoningState)
@@ -879,7 +938,8 @@ func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (
 
 			switch event.Type {
 			case "response.created":
-				_ = event.AsResponseCreated()
+				created := event.AsResponseCreated()
+				responseID = created.Response.ID
 
 			case "response.output_item.added":
 				added := event.AsResponseOutputItemAdded()
@@ -1080,20 +1140,17 @@ func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (
 					}
 				}
 
-			case "response.completed", "response.incomplete":
+			case "response.completed":
 				completed := event.AsResponseCompleted()
+				responseID = completed.Response.ID
 				finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
-				usage = fantasy.Usage{
-					InputTokens:  completed.Response.Usage.InputTokens,
-					OutputTokens: completed.Response.Usage.OutputTokens,
-					TotalTokens:  completed.Response.Usage.InputTokens + completed.Response.Usage.OutputTokens,
-				}
-				if completed.Response.Usage.OutputTokensDetails.ReasoningTokens != 0 {
-					usage.ReasoningTokens = completed.Response.Usage.OutputTokensDetails.ReasoningTokens
-				}
-				if completed.Response.Usage.InputTokensDetails.CachedTokens != 0 {
-					usage.CacheReadTokens = completed.Response.Usage.InputTokensDetails.CachedTokens
-				}
+				usage = responsesUsage(completed.Response)
+
+			case "response.incomplete":
+				incomplete := event.AsResponseIncomplete()
+				responseID = incomplete.Response.ID
+				finishReason = mapResponsesFinishReason(incomplete.Response.IncompleteDetails.Reason, hasFunctionCall)
+				usage = responsesUsage(incomplete.Response)
 
 			case "error":
 				errorEvent := event.AsError()
@@ -1117,9 +1174,10 @@ func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) (
 		}
 
 		yield(fantasy.StreamPart{
-			Type:         fantasy.StreamPartTypeFinish,
-			Usage:        usage,
-			FinishReason: finishReason,
+			Type:             fantasy.StreamPartTypeFinish,
+			Usage:            usage,
+			FinishReason:     finishReason,
+			ProviderMetadata: responsesProviderMetadata(responseID),
 		})
 	}, nil
 }
@@ -1247,7 +1305,10 @@ func (o responsesLanguageModel) generateObjectWithJSONMode(ctx context.Context,
 		ProviderOptions:  call.ProviderOptions,
 	}
 
-	params, warnings := o.prepareParams(fantasyCall)
+	params, warnings, err := o.prepareParams(fantasyCall)
+	if err != nil {
+		return nil, err
+	}
 
 	// Add structured output via Text.Format field
 	params.Text = responses.ResponseTextConfigParam{
@@ -1303,18 +1364,7 @@ func (o responsesLanguageModel) generateObjectWithJSONMode(ctx context.Context,
 		obj, err = schema.ParseAndValidate(jsonText, call.Schema)
 	}
 
-	usage := fantasy.Usage{
-		InputTokens:  response.Usage.InputTokens,
-		OutputTokens: response.Usage.OutputTokens,
-		TotalTokens:  response.Usage.InputTokens + response.Usage.OutputTokens,
-	}
-	if response.Usage.OutputTokensDetails.ReasoningTokens != 0 {
-		usage.ReasoningTokens = response.Usage.OutputTokensDetails.ReasoningTokens
-	}
-	if response.Usage.InputTokensDetails.CachedTokens != 0 {
-		usage.CacheReadTokens = response.Usage.InputTokensDetails.CachedTokens
-	}
-
+	usage := responsesUsage(*response)
 	finishReason := mapResponsesFinishReason(response.IncompleteDetails.Reason, false)
 
 	if err != nil {
@@ -1327,11 +1377,12 @@ func (o responsesLanguageModel) generateObjectWithJSONMode(ctx context.Context,
 	}
 
 	return &fantasy.ObjectResponse{
-		Object:       obj,
-		RawText:      jsonText,
-		Usage:        usage,
-		FinishReason: finishReason,
-		Warnings:     warnings,
+		Object:           obj,
+		RawText:          jsonText,
+		Usage:            usage,
+		FinishReason:     finishReason,
+		Warnings:         warnings,
+		ProviderMetadata: responsesProviderMetadata(response.ID),
 	}, nil
 }
 
@@ -1358,7 +1409,10 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca
 		ProviderOptions:  call.ProviderOptions,
 	}
 
-	params, warnings := o.prepareParams(fantasyCall)
+	params, warnings, err := o.prepareParams(fantasyCall)
+	if err != nil {
+		return nil, err
+	}
 
 	// Add structured output via Text.Format field
 	params.Text = responses.ResponseTextConfigParam{
@@ -1381,6 +1435,12 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca
 		var lastParsedObject any
 		var usage fantasy.Usage
 		var finishReason fantasy.FinishReason
+		// responseID tracks the server-assigned response ID. It's first set from the
+		// response.created event and may be overwritten by response.completed or
+		// response.incomplete events. Per the OpenAI API contract, these IDs are
+		// identical; the overwrites ensure we have the final value even if an event
+		// is missed.
+		var responseID string
 		var streamErr error
 		hasFunctionCall := false
 
@@ -1388,6 +1448,10 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca
 			event := stream.Current()
 
 			switch event.Type {
+			case "response.created":
+				created := event.AsResponseCreated()
+				responseID = created.Response.ID
+
 			case "response.output_text.delta":
 				textDelta := event.AsResponseOutputTextDelta()
 				accumulated += textDelta.Delta
@@ -1431,20 +1495,17 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca
 					}
 				}
 
-			case "response.completed", "response.incomplete":
+			case "response.completed":
 				completed := event.AsResponseCompleted()
+				responseID = completed.Response.ID
 				finishReason = mapResponsesFinishReason(completed.Response.IncompleteDetails.Reason, hasFunctionCall)
-				usage = fantasy.Usage{
-					InputTokens:  completed.Response.Usage.InputTokens,
-					OutputTokens: completed.Response.Usage.OutputTokens,
-					TotalTokens:  completed.Response.Usage.InputTokens + completed.Response.Usage.OutputTokens,
-				}
-				if completed.Response.Usage.OutputTokensDetails.ReasoningTokens != 0 {
-					usage.ReasoningTokens = completed.Response.Usage.OutputTokensDetails.ReasoningTokens
-				}
-				if completed.Response.Usage.InputTokensDetails.CachedTokens != 0 {
-					usage.CacheReadTokens = completed.Response.Usage.InputTokensDetails.CachedTokens
-				}
+				usage = responsesUsage(completed.Response)
+
+			case "response.incomplete":
+				incomplete := event.AsResponseIncomplete()
+				responseID = incomplete.Response.ID
+				finishReason = mapResponsesFinishReason(incomplete.Response.IncompleteDetails.Reason, hasFunctionCall)
+				usage = responsesUsage(incomplete.Response)
 
 			case "error":
 				errorEvent := event.AsError()
@@ -1471,9 +1532,10 @@ func (o responsesLanguageModel) streamObjectWithJSONMode(ctx context.Context, ca
 		// Final validation and emit
 		if streamErr == nil && lastParsedObject != nil {
 			yield(fantasy.ObjectStreamPart{
-				Type:         fantasy.ObjectStreamPartTypeFinish,
-				Usage:        usage,
-				FinishReason: finishReason,
+				Type:             fantasy.ObjectStreamPartTypeFinish,
+				Usage:            usage,
+				FinishReason:     finishReason,
+				ProviderMetadata: responsesProviderMetadata(responseID),
 			})
 		} else if streamErr == nil && lastParsedObject == nil {
 			// No object was generated

providers/openai/responses_options.go 🔗

@@ -10,6 +10,7 @@ import (
 
 // Global type identifiers for OpenAI Responses API-specific data.
 const (
+	TypeResponsesProviderMetadata  = Name + ".responses.metadata"
 	TypeResponsesProviderOptions   = Name + ".responses.options"
 	TypeResponsesReasoningMetadata = Name + ".responses.reasoning_metadata"
 	TypeWebSearchCallMetadata      = Name + ".responses.web_search_call_metadata"
@@ -17,6 +18,13 @@ const (
 
 // Register OpenAI Responses API-specific types with the global registry.
 func init() {
+	fantasy.RegisterProviderType(TypeResponsesProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
+		var v ResponsesProviderMetadata
+		if err := json.Unmarshal(data, &v); err != nil {
+			return nil, err
+		}
+		return &v, nil
+	})
 	fantasy.RegisterProviderType(TypeResponsesProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
 		var v ResponsesProviderOptions
 		if err := json.Unmarshal(data, &v); err != nil {
@@ -40,6 +48,34 @@ func init() {
 	})
 }
 
+// ResponsesProviderMetadata contains response-level metadata from the OpenAI Responses API.
+// The ResponseID can be used as PreviousResponseID in follow-up requests to chain responses.
+type ResponsesProviderMetadata struct {
+	ResponseID string `json:"response_id"`
+}
+
+var _ fantasy.ProviderOptionsData = (*ResponsesProviderMetadata)(nil)
+
+// Options implements the ProviderOptions interface.
+func (*ResponsesProviderMetadata) Options() {}
+
+// MarshalJSON implements custom JSON marshaling with type info for ResponsesProviderMetadata.
+func (m ResponsesProviderMetadata) MarshalJSON() ([]byte, error) {
+	type plain ResponsesProviderMetadata
+	return fantasy.MarshalProviderType(TypeResponsesProviderMetadata, plain(m))
+}
+
+// UnmarshalJSON implements custom JSON unmarshaling with type info for ResponsesProviderMetadata.
+func (m *ResponsesProviderMetadata) UnmarshalJSON(data []byte) error {
+	type plain ResponsesProviderMetadata
+	var p plain
+	if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
+		return err
+	}
+	*m = ResponsesProviderMetadata(p)
+	return nil
+}
+
 // ResponsesReasoningMetadata represents reasoning metadata for OpenAI Responses API.
 type ResponsesReasoningMetadata struct {
 	ItemID           string   `json:"item_id"`
@@ -105,20 +141,28 @@ const (
 
 // ResponsesProviderOptions represents additional options for OpenAI Responses API.
 type ResponsesProviderOptions struct {
-	Include           []IncludeType    `json:"include"`
-	Instructions      *string          `json:"instructions"`
-	Logprobs          any              `json:"logprobs"`
-	MaxToolCalls      *int64           `json:"max_tool_calls"`
-	Metadata          map[string]any   `json:"metadata"`
-	ParallelToolCalls *bool            `json:"parallel_tool_calls"`
-	PromptCacheKey    *string          `json:"prompt_cache_key"`
-	ReasoningEffort   *ReasoningEffort `json:"reasoning_effort"`
-	ReasoningSummary  *string          `json:"reasoning_summary"`
-	SafetyIdentifier  *string          `json:"safety_identifier"`
-	ServiceTier       *ServiceTier     `json:"service_tier"`
-	StrictJSONSchema  *bool            `json:"strict_json_schema"`
-	TextVerbosity     *TextVerbosity   `json:"text_verbosity"`
-	User              *string          `json:"user"`
+	Include           []IncludeType  `json:"include"`
+	Instructions      *string        `json:"instructions"`
+	Logprobs          any            `json:"logprobs"`
+	MaxToolCalls      *int64         `json:"max_tool_calls"`
+	Metadata          map[string]any `json:"metadata"`
+	ParallelToolCalls *bool          `json:"parallel_tool_calls"`
+	// PreviousResponseID chains this request to a prior stored response, enabling
+	// server-side conversation state. When set, the prompt should contain only the
+	// new incremental turn—not replayed assistant history.
+	PreviousResponseID *string          `json:"previous_response_id"`
+	PromptCacheKey     *string          `json:"prompt_cache_key"`
+	ReasoningEffort    *ReasoningEffort `json:"reasoning_effort"`
+	ReasoningSummary   *string          `json:"reasoning_summary"`
+	SafetyIdentifier   *string          `json:"safety_identifier"`
+	ServiceTier        *ServiceTier     `json:"service_tier"`
+	// Store indicates whether OpenAI should persist this response for future
+	// retrieval and chaining via PreviousResponseID. Defaults to false to prevent
+	// unintended storage of potentially sensitive conversations.
+	Store            *bool          `json:"store"`
+	StrictJSONSchema *bool          `json:"strict_json_schema"`
+	TextVerbosity    *TextVerbosity `json:"text_verbosity"`
+	User             *string        `json:"user"`
 }
 
 // Options implements the ProviderOptions interface.

providers/openai/responses_params_test.go 🔗

@@ -0,0 +1,320 @@
+package openai
+
+import (
+	"encoding/json"
+	"testing"
+
+	"charm.land/fantasy"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPrepareParams_Store(t *testing.T) {
+	t.Parallel()
+
+	lm := testResponsesLM()
+	prompt := fantasy.Prompt{testTextMessage(fantasy.MessageRoleUser, "hello")}
+
+	tests := []struct {
+		name      string
+		opts      *ResponsesProviderOptions
+		wantStore bool
+	}{
+		{
+			name:      "store true",
+			opts:      &ResponsesProviderOptions{Store: fantasy.Opt(true)},
+			wantStore: true,
+		},
+		{
+			name:      "store false",
+			opts:      &ResponsesProviderOptions{Store: fantasy.Opt(false)},
+			wantStore: false,
+		},
+		{
+			name:      "store default",
+			opts:      &ResponsesProviderOptions{},
+			wantStore: false,
+		},
+		{
+			name:      "no provider options",
+			opts:      nil,
+			wantStore: false,
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			params, warnings, err := lm.prepareParams(testCall(prompt, tt.opts))
+			require.NoError(t, err)
+			require.Empty(t, warnings)
+			require.True(t, params.Store.Valid())
+			require.Equal(t, tt.wantStore, params.Store.Value)
+		})
+	}
+}
+
+func TestPrepareParams_PreviousResponseID(t *testing.T) {
+	t.Parallel()
+
+	lm := testResponsesLM()
+	prompt := fantasy.Prompt{testTextMessage(fantasy.MessageRoleUser, "hello")}
+
+	t.Run("forwarded", func(t *testing.T) {
+		t.Parallel()
+
+		params, warnings, err := lm.prepareParams(testCall(prompt, &ResponsesProviderOptions{
+			PreviousResponseID: fantasy.Opt("resp_abc123"),
+			Store:              fantasy.Opt(true),
+		}))
+		require.NoError(t, err)
+		require.Empty(t, warnings)
+		require.True(t, params.PreviousResponseID.Valid())
+		require.Equal(t, "resp_abc123", params.PreviousResponseID.Value)
+	})
+
+	t.Run("not set", func(t *testing.T) {
+		t.Parallel()
+
+		params, warnings, err := lm.prepareParams(testCall(prompt, &ResponsesProviderOptions{}))
+		require.NoError(t, err)
+		require.Empty(t, warnings)
+		require.False(t, params.PreviousResponseID.Valid())
+	})
+
+	t.Run("empty string ignored", func(t *testing.T) {
+		t.Parallel()
+
+		params, warnings, err := lm.prepareParams(testCall(prompt, &ResponsesProviderOptions{
+			PreviousResponseID: fantasy.Opt(""),
+		}))
+		require.NoError(t, err)
+		require.Empty(t, warnings)
+		require.False(t, params.PreviousResponseID.Valid())
+	})
+}
+
+func TestPrepareParams_PreviousResponseID_Validation(t *testing.T) {
+	t.Parallel()
+
+	lm := testResponsesLM()
+	opts := &ResponsesProviderOptions{
+		PreviousResponseID: fantasy.Opt("resp_abc123"),
+		Store:              fantasy.Opt(true),
+	}
+
+	t.Run("rejects with assistant messages", func(t *testing.T) {
+		t.Parallel()
+
+		_, _, err := lm.prepareParams(testCall(fantasy.Prompt{
+			testTextMessage(fantasy.MessageRoleUser, "hello"),
+			testTextMessage(fantasy.MessageRoleAssistant, "hi there"),
+		}, opts))
+		require.EqualError(t, err, previousResponseIDHistoryError)
+	})
+
+	t.Run("allows user-only prompt", func(t *testing.T) {
+		t.Parallel()
+
+		_, warnings, err := lm.prepareParams(testCall(fantasy.Prompt{
+			testTextMessage(fantasy.MessageRoleUser, "hello"),
+			testTextMessage(fantasy.MessageRoleUser, "follow up"),
+		}, opts))
+		require.NoError(t, err)
+		require.Empty(t, warnings)
+	})
+
+	t.Run("allows system + user prompt", func(t *testing.T) {
+		t.Parallel()
+
+		_, warnings, err := lm.prepareParams(testCall(fantasy.Prompt{
+			testTextMessage(fantasy.MessageRoleSystem, "be concise"),
+			testTextMessage(fantasy.MessageRoleUser, "hello"),
+		}, opts))
+		require.NoError(t, err)
+		require.Empty(t, warnings)
+	})
+
+	t.Run("rejects tool messages", func(t *testing.T) {
+		t.Parallel()
+
+		_, _, err := lm.prepareParams(testCall(fantasy.Prompt{
+			testToolResultMessage("done"),
+			testTextMessage(fantasy.MessageRoleUser, "hello"),
+		}, opts))
+		require.EqualError(t, err, previousResponseIDHistoryError)
+	})
+
+	t.Run("rejects without store", func(t *testing.T) {
+		t.Parallel()
+
+		_, _, err := lm.prepareParams(testCall(fantasy.Prompt{
+			testTextMessage(fantasy.MessageRoleUser, "hello"),
+		}, &ResponsesProviderOptions{
+			PreviousResponseID: fantasy.Opt("resp_abc123"),
+		}))
+		require.EqualError(t, err, previousResponseIDStoreError)
+	})
+
+	t.Run("rejects with store false", func(t *testing.T) {
+		t.Parallel()
+
+		_, _, err := lm.prepareParams(testCall(fantasy.Prompt{
+			testTextMessage(fantasy.MessageRoleUser, "hello"),
+		}, &ResponsesProviderOptions{
+			PreviousResponseID: fantasy.Opt("resp_abc123"),
+			Store:              fantasy.Opt(false),
+		}))
+		require.EqualError(t, err, previousResponseIDStoreError)
+	})
+}
+
+func TestValidatePreviousResponseIDPrompt(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name    string
+		prompt  fantasy.Prompt
+		wantErr bool
+	}{
+		{
+			name:   "empty prompt",
+			prompt: nil,
+		},
+		{
+			name: "user-only messages",
+			prompt: fantasy.Prompt{
+				testTextMessage(fantasy.MessageRoleUser, "hello"),
+				testTextMessage(fantasy.MessageRoleUser, "follow up"),
+			},
+		},
+		{
+			name: "system + user messages",
+			prompt: fantasy.Prompt{
+				testTextMessage(fantasy.MessageRoleSystem, "be concise"),
+				testTextMessage(fantasy.MessageRoleUser, "hello"),
+			},
+		},
+		{
+			name: "contains assistant message",
+			prompt: fantasy.Prompt{
+				testTextMessage(fantasy.MessageRoleAssistant, "hi there"),
+			},
+			wantErr: true,
+		},
+		{
+			name: "assistant in the middle",
+			prompt: fantasy.Prompt{
+				testTextMessage(fantasy.MessageRoleUser, "hello"),
+				testTextMessage(fantasy.MessageRoleAssistant, "hi there"),
+				testTextMessage(fantasy.MessageRoleUser, "follow up"),
+			},
+			wantErr: true,
+		},
+		{
+			name: "contains tool message",
+			prompt: fantasy.Prompt{
+				testToolResultMessage("done"),
+				testTextMessage(fantasy.MessageRoleUser, "follow up"),
+			},
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			err := validatePreviousResponseIDPrompt(tt.prompt)
+			if tt.wantErr {
+				require.EqualError(t, err, previousResponseIDHistoryError)
+				return
+			}
+
+			require.NoError(t, err)
+		})
+	}
+}
+
+func TestResponsesProviderMetadata_Helper(t *testing.T) {
+	t.Parallel()
+
+	t.Run("non-empty id", func(t *testing.T) {
+		t.Parallel()
+
+		metadata := responsesProviderMetadata("resp_123")
+		require.Len(t, metadata, 1)
+
+		providerMetadata, ok := metadata[Name].(*ResponsesProviderMetadata)
+		require.True(t, ok)
+		require.Equal(t, "resp_123", providerMetadata.ResponseID)
+	})
+
+	t.Run("empty id", func(t *testing.T) {
+		t.Parallel()
+
+		metadata := responsesProviderMetadata("")
+		require.Empty(t, metadata)
+	})
+}
+
+func TestResponsesProviderMetadata_JSON(t *testing.T) {
+	t.Parallel()
+
+	encoded, err := json.Marshal(ResponsesProviderMetadata{ResponseID: "resp_123"})
+	require.NoError(t, err)
+	require.Contains(t, string(encoded), `"response_id":"resp_123"`)
+
+	decoded, err := fantasy.UnmarshalProviderMetadata(map[string]json.RawMessage{
+		Name: encoded,
+	})
+	require.NoError(t, err)
+
+	providerMetadata, ok := decoded[Name].(*ResponsesProviderMetadata)
+	require.True(t, ok)
+	require.Equal(t, "resp_123", providerMetadata.ResponseID)
+}
+
+func testCall(prompt fantasy.Prompt, opts *ResponsesProviderOptions) fantasy.Call {
+	call := fantasy.Call{
+		Prompt: prompt,
+	}
+	if opts != nil {
+		call.ProviderOptions = fantasy.ProviderOptions{
+			Name: opts,
+		}
+	}
+	return call
+}
+
+func testResponsesLM() responsesLanguageModel {
+	return responsesLanguageModel{
+		provider: Name,
+		modelID:  "gpt-4o",
+	}
+}
+
+func testTextMessage(role fantasy.MessageRole, text string) fantasy.Message {
+	return fantasy.Message{
+		Role: role,
+		Content: []fantasy.MessagePart{
+			fantasy.TextPart{Text: text},
+		},
+	}
+}
+
+func testToolResultMessage(text string) fantasy.Message {
+	return fantasy.Message{
+		Role: fantasy.MessageRoleTool,
+		Content: []fantasy.MessagePart{
+			fantasy.ToolResultPart{
+				ToolCallID: "call_123",
+				Output: fantasy.ToolResultOutputContentText{
+					Text: text,
+				},
+			},
+		},
+	}
+}