From 46203291560960562ccfc341098d0564fc895e31 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 25 Mar 2026 09:14:08 -0400 Subject: [PATCH] fix(providers/openai): emit source parts for Responses API streaming annotations (#187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Responses API streaming path was missing a handler for "response.output_text.annotation.added" events. This meant that url_citation and file_citation annotations—which carry source URLs and titles for web search results—were silently dropped during streaming. The non-streaming Generate path and the Chat Completions API streaming path both handled annotations correctly; only the Responses API Stream path was affected. Add a case for "response.output_text.annotation.added" that parses the annotation map and yields StreamPartTypeSource parts for url_citation and file_citation types, matching the behavior of the existing Generate path and the Anthropic provider. Update TestResponsesStream_WebSearchResponse to include annotation.added events in the mock stream and assert that source parts are emitted with the correct URL, title, and type. --- providers/openai/openai_test.go | 19 +++++++++- providers/openai/responses_language_model.go | 37 ++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/providers/openai/openai_test.go b/providers/openai/openai_test.go index 9cf3d4bfac1959e143180e0f73884e73366068fc..07bcdc98109537b02446aeefca950579e2e3ede8 100644 --- a/providers/openai/openai_test.go +++ b/providers/openai/openai_test.go @@ -4061,8 +4061,12 @@ func TestResponsesStream_WebSearchResponse(t *testing.T) { `data: {"type":"response.output_item.added","output_index":1,"item":{"type":"message","id":"msg_01","role":"assistant","status":"in_progress","content":[]}}` + "\n\n", "event: response.output_text.delta\n" + `data: {"type":"response.output_text.delta","output_index":1,"content_index":0,"delta":"Here are the results."}` + "\n\n", + "event: response.output_text.annotation.added\n" + + `data: {"type":"response.output_text.annotation.added","annotation":{"type":"url_citation","url":"https://example.com/ai-news","title":"Latest AI News","start_index":0,"end_index":21},"annotation_index":0,"content_index":0,"item_id":"msg_01","output_index":1,"sequence_number":10}` + "\n\n", + "event: response.output_text.annotation.added\n" + + `data: {"type":"response.output_text.annotation.added","annotation":{"type":"url_citation","url":"https://example.com/more-news","title":"More AI News","start_index":22,"end_index":40},"annotation_index":1,"content_index":0,"item_id":"msg_01","output_index":1,"sequence_number":11}` + "\n\n", "event: response.output_item.done\n" + - `data: {"type":"response.output_item.done","output_index":1,"item":{"type":"message","id":"msg_01","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Here are the results.","annotations":[{"type":"url_citation","url":"https://example.com/ai-news","title":"Latest AI News","start_index":0,"end_index":21}]}]}}` + "\n\n", + `data: {"type":"response.output_item.done","output_index":1,"item":{"type":"message","id":"msg_01","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Here are the results.","annotations":[{"type":"url_citation","url":"https://example.com/ai-news","title":"Latest AI News","start_index":0,"end_index":21},{"type":"url_citation","url":"https://example.com/more-news","title":"More AI News","start_index":22,"end_index":40}]}]}}` + "\n\n", "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", } @@ -4090,6 +4094,7 @@ func TestResponsesStream_WebSearchResponse(t *testing.T) { toolCalls []fantasy.StreamPart toolResults []fantasy.StreamPart textDeltas []fantasy.StreamPart + sources []fantasy.StreamPart finishes []fantasy.StreamPart ) for _, p := range parts { @@ -4102,6 +4107,8 @@ func TestResponsesStream_WebSearchResponse(t *testing.T) { toolResults = append(toolResults, p) case fantasy.StreamPartTypeTextDelta: textDeltas = append(textDeltas, p) + case fantasy.StreamPartTypeSource: + sources = append(sources, p) case fantasy.StreamPartTypeFinish: finishes = append(finishes, p) } @@ -4123,6 +4130,16 @@ 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, sources, 2, "should have two source citations from annotation events") + require.Equal(t, fantasy.SourceTypeURL, sources[0].SourceType) + require.Equal(t, "https://example.com/ai-news", sources[0].URL) + require.Equal(t, "Latest AI News", sources[0].Title) + require.NotEmpty(t, sources[0].ID, "source should have an ID") + require.Equal(t, fantasy.SourceTypeURL, sources[1].SourceType) + require.Equal(t, "https://example.com/more-news", sources[1].URL) + require.Equal(t, "More AI News", sources[1].Title) + require.NotEmpty(t, sources[1].ID, "source should have an ID") + require.Len(t, finishes, 1) responsesMeta, ok := finishes[0].ProviderMetadata[Name].(*ResponsesProviderMetadata) require.True(t, ok) diff --git a/providers/openai/responses_language_model.go b/providers/openai/responses_language_model.go index 1fa9af515bf1426599ea5069af741addec26c2c8..eb027109e8cfab898d5a7e605415ed5596bbda01 100644 --- a/providers/openai/responses_language_model.go +++ b/providers/openai/responses_language_model.go @@ -1087,6 +1087,43 @@ func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) ( return } + case "response.output_text.annotation.added": + added := event.AsResponseOutputTextAnnotationAdded() + // The Annotation field is typed as `any` in the SDK; + // it deserializes as map[string]interface{} from JSON. + annotationMap, ok := added.Annotation.(map[string]interface{}) + if !ok { + break + } + annotationType, _ := annotationMap["type"].(string) + switch annotationType { + case "url_citation": + url, _ := annotationMap["url"].(string) + title, _ := annotationMap["title"].(string) + if !yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeSource, + ID: uuid.NewString(), + SourceType: fantasy.SourceTypeURL, + URL: url, + Title: title, + }) { + return + } + case "file_citation": + title := "Document" + if fn, ok := annotationMap["filename"].(string); ok && fn != "" { + title = fn + } + if !yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeSource, + ID: uuid.NewString(), + SourceType: fantasy.SourceTypeDocument, + Title: title, + }) { + return + } + } + case "response.reasoning_summary_part.added": added := event.AsResponseReasoningSummaryPartAdded() state := activeReasoning[added.ItemID]