Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
006b727
fix(providers/openai): replay unstored web searches inline
mafredri Oct 27, 2026
8c455dd
fix(providers/openai): warn when an unstored web search is dropped
mafredri Oct 27, 2026
a410684
fix(providers/openai): name the action type a dropped search had
mafredri Oct 27, 2026
a52f08e
fix(providers/openai): send only API action fields for unstored searches
mafredri Oct 27, 2026
37e7dd2
fix(providers/openai): replay searches inline when not referenced
mafredri Oct 27, 2026
50bf6c5
docs(providers/openai): say why inline searches omit sources
mafredri Oct 27, 2026
67300d9
fix(providers/openai): never both inline and reference one search
mafredri Oct 27, 2026
cb92642
fix(providers/openai): reference no search after an inline one
mafredri Oct 27, 2026
9bd1001
fix(providers/openai): replay each search once
mafredri Oct 27, 2026
e06de3d
docs(providers/openai): say why referenceAt counts parts
mafredri Oct 27, 2026
b4d4cde
test(providers/openai): rename later to laterContent
mafredri Oct 27, 2026
e782df2
docs(providers/openai): give the reason referenceAt counts parts
mafredri Oct 27, 2026
1e0f65b
fix(providers/openai): warn when a tool message drops a search
mafredri Oct 27, 2026
f60a36a
test(providers/openai): check the warning alongside the pairing error
mafredri Oct 27, 2026
0167276
fix(providers/openai): warn only for searches that are lost
mafredri Oct 27, 2026
4ebc8e4
fix(providers/openai): dedupe and warn on the whole prompt
mafredri Oct 27, 2026
511792c
fix(providers/openai): warn once per dropped search
mafredri Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions providers/openai/openai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4696,28 +4696,34 @@ func TestResponsesToPrompt_WebSearchProviderExecutedToolResults(t *testing.T) {
},
}

t.Run("store false skips item reference", func(t *testing.T) {
t.Run("store false skips search without metadata", func(t *testing.T) {
t.Parallel()

input, warnings, err := toResponsesPrompt(prompt, "system instructions", false)

require.NoError(t, err)

require.Empty(t, warnings)
require.Equal(t, []fantasy.CallWarning{{
Type: fantasy.CallWarningTypeOther,
Message: `dropping web search "ws_01": it has no web search metadata`,
}}, warnings)
require.Len(t, input, 2,
"expected user + assistant text when store=false")
require.Nil(t, input[0].OfItemReference)
require.Nil(t, input[1].OfItemReference)
})

t.Run("store true skips item reference", func(t *testing.T) {
t.Run("store true skips unreferenced search without metadata", func(t *testing.T) {
t.Parallel()

input, warnings, err := toResponsesPrompt(prompt, "system instructions", true)

require.NoError(t, err)

require.Empty(t, warnings)
require.Equal(t, []fantasy.CallWarning{{
Type: fantasy.CallWarningTypeOther,
Message: `dropping web search "ws_01": it has no web search metadata`,
}}, warnings)
require.Len(t, input, 2,
"expected user + assistant text when store=true")
require.Nil(t, input[0].OfItemReference)
Expand Down Expand Up @@ -4924,10 +4930,14 @@ func TestResponsesToPrompt_ReasoningWithWebSearchCombined(t *testing.T) {
input, warnings, err := toResponsesPrompt(prompt, "system", false)

require.NoError(t, err)
require.Empty(t, warnings)
require.Equal(t, []fantasy.CallWarning{{
Type: fantasy.CallWarningTypeOther,
Message: `dropping web search "ws_002": it has no web search metadata`,
}}, warnings)

// Both reasoning and the provider-executed web_search_call
// are skipped under store=false. user, assistant text, user.
// Reasoning without encrypted content is skipped under
// store=false, and so is the web_search_call, whose result has
// no metadata to send inline. user, assistant text, user.
require.Len(t, input, 3)
for _, item := range input {
require.Nil(t, item.OfItemReference)
Expand Down
164 changes: 152 additions & 12 deletions providers/openai/responses_language_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,10 @@ func toResponsesPromptWithValidation(prompt fantasy.Prompt, systemMessageMode st
}
}

// A search's call and result parts can each replay it, possibly from
// different messages; only the first one does.
replayedSearches := make(map[string]bool)
var dropped droppedSearches
for _, msg := range prompt {
switch msg.Role {
case fantasy.MessageRoleSystem:
Expand Down Expand Up @@ -610,8 +614,13 @@ func toResponsesPromptWithValidation(prompt fantasy.Prompt, systemMessageMode st

case fantasy.MessageRoleAssistant:
startIdx := len(input)
canReferenceWebSearch := false
for _, c := range msg.Content {
// referenceAt is the index of the part that can reference a
// stored search: the part right after a reasoning part from a
// stored response. It counts parts, not input items, because a
// skipped part means the search did not directly follow its
// reasoning in the response.
referenceAt := -1
for i, c := range msg.Content {
switch c.GetType() {
case fantasy.ContentTypeText:
textPart, ok := fantasy.AsContentType[fantasy.TextPart](c)
Expand All @@ -629,7 +638,6 @@ func toResponsesPromptWithValidation(prompt fantasy.Prompt, systemMessageMode st
message.OfMessage.Phase = responses.EasyInputMessagePhase(metadata.Phase)
}
input = append(input, message)
canReferenceWebSearch = false

case fantasy.ContentTypeToolCall:
toolCallPart, ok := fantasy.AsContentType[fantasy.ToolCallPart](c)
Expand All @@ -642,12 +650,13 @@ func toResponsesPromptWithValidation(prompt fantasy.Prompt, systemMessageMode st
}

if toolCallPart.ProviderExecuted {
if store && canReferenceWebSearch &&
if store && referenceAt == i &&
isResponsesWebSearchToolCall(toolCallPart) &&
toolCallPart.ToolCallID != "" {
toolCallPart.ToolCallID != "" &&
!replayedSearches[toolCallPart.ToolCallID] {
input = append(input, responses.ResponseInputItemParamOfItemReference(toolCallPart.ToolCallID))
replayedSearches[toolCallPart.ToolCallID] = true
}
canReferenceWebSearch = false
continue
}

Expand All @@ -662,13 +671,11 @@ func toResponsesPromptWithValidation(prompt fantasy.Prompt, systemMessageMode st
}

input = append(input, responses.ResponseInputItemParamOfFunctionCall(toolCallPart.Input, toolCallPart.ToolCallID, toolCallPart.ToolName))
canReferenceWebSearch = false
case fantasy.ContentTypeSource:
// Source citations from web search are not a
// recognised Responses API input type; skip.
continue
case fantasy.ContentTypeReasoning:
canReferenceWebSearch = false
reasoningPart, ok := fantasy.AsContentType[fantasy.ReasoningPart](c)
if !ok {
warnings = append(warnings, fantasy.CallWarning{
Expand All @@ -691,8 +698,32 @@ func toResponsesPromptWithValidation(prompt fantasy.Prompt, systemMessageMode st
continue
}
// Item-reference replay predates source storage metadata.
canReferenceWebSearch = !meta.Finalized || meta.SourceStoreEnabled
if !meta.Finalized || meta.SourceStoreEnabled {
referenceAt = i + 1
}
continue
case fantasy.ContentTypeToolResult:
toolResultPart, ok := fantasy.AsContentType[fantasy.ToolResultPart](c)
if !ok || !toolResultPart.ProviderExecuted {
continue
}
meta, searchID := webSearchResult(toolResultPart)
if replayedSearches[searchID] {
continue
}
if meta == nil {
dropped.add(searchID, "it has no web search metadata", false)
continue
}
item, err := inlineWebSearchCallItem(meta)
if err != nil {
dropped.add(searchID, err.Error(), false)
continue
}
input = append(input, item)
Comment thread
mafredri marked this conversation as resolved.
if searchID != "" {
replayedSearches[searchID] = true
}
}
}
if !hasVisibleResponsesAssistantContent(input, startIdx) {
Expand Down Expand Up @@ -724,9 +755,15 @@ func toResponsesPromptWithValidation(prompt fantasy.Prompt, systemMessageMode st
continue
}

// Provider-executed tool results (e.g. web search)
// are already round-tripped via the tool call; skip.
// A tool message has lost the result's position among the
// assistant's items, so only the assistant branch can
// replay it.
if toolResultPart.ProviderExecuted {
if meta, searchID := webSearchResult(toolResultPart); meta == nil {
dropped.add(searchID, "it has no web search metadata", true)
} else {
dropped.add(searchID, "it is in a tool message, and is replayed only from an assistant message", true)
}
continue
}

Expand Down Expand Up @@ -850,6 +887,8 @@ func toResponsesPromptWithValidation(prompt fantasy.Prompt, systemMessageMode st
}
}

warnings = append(warnings, dropped.warnings(replayedSearches)...)

if err := validateResponsesInputWithOptions(input, allowUnpairedFunctionCallOutputs); err != nil {
return nil, warnings, err
}
Expand Down Expand Up @@ -902,6 +941,107 @@ func isResponsesWebSearchToolCall(toolCallPart fantasy.ToolCallPart) bool {
toolCallPart.ToolName == "web_search_preview"
}

// droppedSearches records why search results were not replayed, so that
// each search the whole prompt never replays gets one warning. Another
// part of the prompt can still replay a search, and a reason from an
// assistant message is more specific than one from a tool message.
type droppedSearches struct {
list []droppedSearch
index map[string]int
}

type droppedSearch struct {
id string
reason string
fromToolMessage bool
}

func (d *droppedSearches) add(id, reason string, fromToolMessage bool) {
search := droppedSearch{id: id, reason: reason, fromToolMessage: fromToolMessage}
if i, ok := d.index[id]; ok {
if d.list[i].fromToolMessage && !fromToolMessage {
d.list[i] = search
}
return
}
// Searches without an id cannot be told apart, so each gets a warning.
if id != "" {
if d.index == nil {
d.index = make(map[string]int)
}
d.index[id] = len(d.list)
}
d.list = append(d.list, search)
}

func (d *droppedSearches) warnings(replayed map[string]bool) []fantasy.CallWarning {
var warnings []fantasy.CallWarning
for _, search := range d.list {
if search.id != "" && replayed[search.id] {
continue
}
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeOther,
Message: fmt.Sprintf("dropping web search %q: %s", search.id, search.reason),
})
}
return warnings
}

// webSearchResult returns a provider-executed result's web search
// metadata, if any, and the id of its search. A caller-built result can
// leave the call ID empty while its metadata still names the search.
func webSearchResult(part fantasy.ToolResultPart) (*WebSearchCallMetadata, string) {
meta, _ := part.ProviderOptions[Name].(*WebSearchCallMetadata)
if part.ToolCallID == "" && meta != nil {
return meta, meta.ItemID
}
return meta, part.ToolCallID
}

// inlineWebSearchCallItem rebuilds a web search call from its result
// metadata, which unlike the call input keeps the status and the
// deprecated single query. The item has no id: with an id, a stored
// request resolves the stored item instead, which fails without its
// reasoning item right before it and can resolve another credential's
// item.
func inlineWebSearchCallItem(meta *WebSearchCallMetadata) (responses.ResponseInputItemUnionParam, error) {
if meta.Action == nil {
return responses.ResponseInputItemUnionParam{}, errors.New("metadata has no action")
}
// The API rejects the whole request for any other action type.
switch meta.Action.Type {
case "search", "open_page", "find_in_page":
Comment thread
mafredri marked this conversation as resolved.
default:
return responses.ResponseInputItemUnionParam{}, fmt.Errorf("action type %q cannot be replayed", meta.Action.Type)
}
// Sources are ignored on input, and copying only these fields keeps
// a field added to the stored metadata from breaking replay.
type action struct {
Type string `json:"type"`
Queries []string `json:"queries,omitempty"`
Query string `json:"query,omitempty"`
URL string `json:"url,omitempty"`
Pattern string `json:"pattern,omitempty"`
}
item := param.Override[responses.ResponseFunctionWebSearchParam](struct {
Type string `json:"type"`
Status string `json:"status,omitempty"`
Action action `json:"action"`
}{
Type: "web_search_call",
Status: meta.Status,
Action: action{
Type: meta.Action.Type,
Queries: meta.Action.Queries,
Query: meta.Action.Query,
URL: meta.Action.URL,
Pattern: meta.Action.Pattern,
},
})
return responses.ResponseInputItemUnionParam{OfWebSearchCall: &item}, nil
}

func validateResponsesInput(input responses.ResponseInputParam) error {
if err := validateResponsesFunctionCallOutputs(input); err != nil {
return err
Expand Down Expand Up @@ -1018,7 +1158,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 || items[i].OfItemReference != nil || items[i].OfReasoning != nil || items[i].OfComputerCall != nil {
if items[i].OfMessage != nil || items[i].OfFunctionCall != nil || items[i].OfItemReference != nil || items[i].OfReasoning != nil || items[i].OfComputerCall != nil || items[i].OfWebSearchCall != nil {
Comment thread
mafredri marked this conversation as resolved.
return true
}
}
Expand Down
3 changes: 2 additions & 1 deletion providers/openai/responses_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,8 @@ type WebSearchAction struct {
// WebSearchCallMetadata stores structured data from a web_search_call
// output item for round-tripping through multi-turn conversations.
// The ItemID is used with item_reference for efficient round-tripping
// when response storage is enabled.
// when response storage is enabled. When the call cannot be referenced,
// Status and Action rebuild it inline.
type WebSearchCallMetadata struct {
// ItemID is the server-side ID of the web_search_call output item.
ItemID string `json:"item_id"`
Expand Down
12 changes: 9 additions & 3 deletions providers/openai/responses_params_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -576,14 +576,17 @@ func TestPrepareParams_ValidatesFunctionCallOutputPairing(t *testing.T) {
require.Empty(t, warnings)
})

t.Run("provider executed output is skipped", func(t *testing.T) {
t.Run("provider executed output is skipped with a warning", func(t *testing.T) {
t.Parallel()

input, warnings, err := toResponsesPrompt(fantasy.Prompt{
testResponsesProviderToolResultMessage("ws_01"),
}, "system", false)
require.NoError(t, err)
require.Empty(t, warnings)
require.Equal(t, []fantasy.CallWarning{{
Type: fantasy.CallWarningTypeOther,
Message: `dropping web search "ws_01": it has no web search metadata`,
}}, warnings)
require.Empty(t, input)
})

Expand All @@ -595,7 +598,10 @@ func TestPrepareParams_ValidatesFunctionCallOutputPairing(t *testing.T) {
testResponsesProviderToolResultMessage("call_provider_result"),
}, nil))
require.EqualError(t, err, `openai responses prompt has function_call without function_call_output for call_id "call_provider_result"`)
require.Empty(t, warnings)
require.Equal(t, []fantasy.CallWarning{{
Type: fantasy.CallWarningTypeOther,
Message: `dropping web search "call_provider_result": it has no web search metadata`,
}}, warnings)
})
}

Expand Down
Loading
Loading