Detailed changes
@@ -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"])
}
@@ -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
@@ -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.
@@ -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,
+ },
+ },
+ },
+ }
+}