#2539·rig

bug: OpenAI Responses streaming discards `output_text` annotations — hosted `web_search` citations never reach consumers

Author: BANG404Created Sep 16, 2026Updated Sep 18, 2026

Description

When using a hosted search tool (web_search) with the OpenAI Responses API in streaming mode, the source citations OpenAI returns cannot be obtained through rig. The web_search_call item itself survives (thanks to #1546), but the annotations array carrying the actual source URLs is discarded.

The non-streaming path does preserve them, which suggests this is an oversight in the streaming decoder rather than an intentional omission.

Root cause

url_citation annotations live on the output_text content part:

json
{
  "type": "message",
  "id": "msg_1",
  "role": "assistant",
  "content": [{
    "type": "output_text",
    "text": "...",
    "annotations": [{
      "type": "url_citation",
      "url": "https://example.com",
      "title": "Example",
      "start_index": 0,
      "end_index": 7
    }]
  }]
}

In streaming mode they are lost through three independent paths.

1. response.output_text.annotation.added is not handled at all.

annotation does not appear anywhere under rig-core/src/providers/openai/ (the only Annotation type in the crate is Gemini's, in interactions_api). The SSE dispatcher matches a fixed list of event types and falls through to a catch-all at rig-core/src/providers/openai/responses_api/streaming.rs:526:

rust
_ => {}

2. On response.output_item.done, the message's content parts are discarded.

streaming.rs:378-380, in push_output_item_done:

rust
Output::Message(message) => {
    immediate.push(streaming::RawStreamingChoice::MessageId(message.id));
}

Only the id is emitted; OutputMessage.content — where the annotations are — is dropped.

3. On response.completed, the output array is discarded.

streaming.rs:320-331, record_response_chunk copies only usage and reasoning_metadata / reasoning_context.

Additionally, OutputTextChunk (streaming.rs:811) models only content_index, sequence_number, and text, so response.output_text.done cannot carry annotations through either.

The data does arrive — it is decoded and then thrown away

Text carries #[serde(flatten)] additional_params: Option<Value> (rig-core/src/completion/message.rs:350-356), which captures unknown fields. Deserializing the message above into OutputMessage yields:

OutputText(Text {
    text: "hi",
    additional_params: Some(Object {
        "annotations": Array [Object {
            "type": "url_citation",
            "url": "https://example.com",
            "title": "Example",
            "start_index": 0,
            "end_index": 7,
        }],
    }),
})

and it round-trips on serialize. So the annotations are already sitting on AssistantContent::OutputText(Text) after decode — the streaming accumulator simply drops them.

Expected behavior

Streaming consumers should be able to obtain output_text annotations, ideally both incrementally (via response.output_text.annotation.added) and in the final aggregated response.

Suggested fix

At minimum, handle the response.output_text.annotation.added event type in the dispatcher and surface it — either as a dedicated ItemChunkKind / RawStreamingChoice pair, or by reusing the RawStreamingChoice::Unknown path that already carries web_search_call items verbatim via Output::Unknown (streaming.rs:382-387). Handling the event type is required either way, since today it never reaches the decoder.

Environment

  • rig-core 0.41.0
  • OpenAI Responses API with hosted web_search
  • Also affects xAI, which reuses responses_api::{Output, ResponsesUsage} and its streaming types (rig-core/src/providers/xai/completion.rs:16-18)

Related

  • #1546 — same class of gap (hosted-tool streaming events silently dropped, breaking usage). Fixed by adding Output::Unknown; that fix explicitly noted that "adding explicit variants and exposing web search events via the streaming API would be a nice enhancement but is not required to fix the usage tracking regression".