From 0c8663fa4424aed206f22ae632040e03dc1285b9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:44:26 +0100 Subject: [PATCH] feat(openai): add responses api `store`, `previous_response_id`, and `response.id` support (#175) Co-authored-by: Christian Rocha --- 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(-) create mode 100644 providers/openai/responses_params_test.go diff --git a/providers/openai/openai_test.go b/providers/openai/openai_test.go index 3d57070afc0a8b23ecd6ba4a71adc1bfcca39dc2..e9fd29dfc0bd73703a44219d2823bba27f8e8749 100644 --- a/providers/openai/openai_test.go +++ b/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"]) } diff --git a/providers/openai/responses_language_model.go b/providers/openai/responses_language_model.go index 9b1d9e3be3d9c4ac95e1f1e491ef7dc47a634687..dc7c42857d411aa40ce993047e453444f4d3e064 100644 --- a/providers/openai/responses_language_model.go +++ b/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 diff --git a/providers/openai/responses_options.go b/providers/openai/responses_options.go index 211fa2c15455dc3deae10b75cd8fb29711d25ead..a80a5d3103f988649727702369748277560aff85 100644 --- a/providers/openai/responses_options.go +++ b/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. diff --git a/providers/openai/responses_params_test.go b/providers/openai/responses_params_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2687f1db57108e47f58c240500b90bf1bc5e8d8b --- /dev/null +++ b/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, + }, + }, + }, + } +}