From 807a87fda5729b29cb760dd759af24bfd9abdebe Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 31 Jul 2026 10:52:30 -0500 Subject: [PATCH 1/3] feat: add RAG citations --- chatlas/__init__.py | 4 + chatlas/_chat.py | 84 ++- chatlas/_content.py | 58 +- chatlas/_provider.py | 8 + chatlas/_provider_anthropic.py | 59 +- chatlas/_provider_openai_completions.py | 4 + chatlas/_rag.py | 303 ++++++++++ chatlas/types/__init__.py | 9 + ...c_rag_streaming_interleaves_citations.yaml | 484 +++++++++++++++ ...est_anthropic_rag_tool_mode_citations.yaml | 566 ++++++++++++++++++ ...e_rag_streaming_yields_prose_not_json.yaml | 269 +++++++++ .../test_google_rag_tool_mode_citations.yaml | 279 +++++++++ ...i_rag_streaming_yields_prose_not_json.yaml | 566 ++++++++++++++++++ .../test_openai_rag_tool_mode_citations.yaml | 566 ++++++++++++++++++ tests/test_content.py | 47 ++ tests/test_provider_anthropic.py | 62 ++ tests/test_provider_anthropic_rag.py | 80 +++ tests/test_provider_google_rag.py | 65 ++ tests/test_provider_openai_rag.py | 40 ++ tests/test_rag.py | 403 +++++++++++++ 20 files changed, 3939 insertions(+), 17 deletions(-) create mode 100644 chatlas/_rag.py create mode 100644 tests/_vcr/test_provider_anthropic_rag/test_anthropic_rag_streaming_interleaves_citations.yaml create mode 100644 tests/_vcr/test_provider_anthropic_rag/test_anthropic_rag_tool_mode_citations.yaml create mode 100644 tests/_vcr/test_provider_google_rag/test_google_rag_streaming_yields_prose_not_json.yaml create mode 100644 tests/_vcr/test_provider_google_rag/test_google_rag_tool_mode_citations.yaml create mode 100644 tests/_vcr/test_provider_openai_rag/test_openai_rag_streaming_yields_prose_not_json.yaml create mode 100644 tests/_vcr/test_provider_openai_rag/test_openai_rag_tool_mode_citations.yaml create mode 100644 tests/test_provider_anthropic_rag.py create mode 100644 tests/test_provider_google_rag.py create mode 100644 tests/test_provider_openai_rag.py create mode 100644 tests/test_rag.py diff --git a/chatlas/__init__.py b/chatlas/__init__.py index 7b3c0c50..df54c21a 100644 --- a/chatlas/__init__.py +++ b/chatlas/__init__.py @@ -10,6 +10,8 @@ from ._content import ( ContentToolRequest, ContentToolResult, + SearchResult, + ToolSearchResults, ) from ._content_document import content_document_file, content_document_url from ._content_image import content_image_file, content_image_plot, content_image_url @@ -93,11 +95,13 @@ "interpolate", "interpolate_file", "Provider", + "SearchResult", "StreamController", "token_usage", "Tool", "ToolBuiltIn", "ToolRejectError", + "ToolSearchResults", "tool_web_fetch", "tool_web_search", "Turn", diff --git a/chatlas/_chat.py b/chatlas/_chat.py index e4bc57ff..451056db 100644 --- a/chatlas/_chat.py +++ b/chatlas/_chat.py @@ -70,6 +70,7 @@ start_tool_span, ) from ._provider import ModelInfo, Provider, StandardModelParams, SubmitInputArgsT +from ._rag import SegmentedAnswer, SegmentsDecoder from ._stream_controller import StreamController from ._tokens import tokens_log from ._tools import Tool, ToolBuiltIn, ToolRejectError @@ -98,6 +99,7 @@ from ._content import ToolAnnotations from ._files import FileManager + from ._rag import RagManager class TokensDict(TypedDict): @@ -194,6 +196,7 @@ def __init__( self.kwargs_chat: SubmitInputArgsT = kwargs_chat or {} self._tools: dict[str, Tool | ToolBuiltIn] = {} + self._rag: Optional["RagManager"] = None self._on_tool_request_callbacks = CallbackManager() self._on_tool_result_callbacks = CallbackManager() self._current_display: Optional[MarkdownDisplay] = None @@ -463,6 +466,21 @@ def files(self) -> "FileManager": return FileManager(self.provider) + @property + def rag(self) -> "RagManager": + """ + Configure retrieval-augmented answers with citations. + + Register a retrieval store (e.g. a raghilda store) once; afterwards + `.chat()` and `.stream()` answers cite the store's documents. See the + RAG article for details, including per-provider citation fidelity. + """ + from ._rag import RagManager + + if self._rag is None: + self._rag = RagManager(self) + return self._rag + @property def model(self) -> str: """ @@ -2866,6 +2884,15 @@ def _submit_turns( if any(isinstance(x, Tool) and x._is_async for x in self._tools.values()): raise ValueError("Cannot use async tools in a synchronous chat") + # A user-supplied `data_model` always wins: only fall back to the + # hand-rolled RAG tier's segments schema when the caller hasn't asked + # for structured output of their own. + rag = self._rag + rag_decoder: Optional[SegmentsDecoder] = None + if data_model is None and rag is not None and rag.uses_segments_schema(): + data_model = SegmentedAnswer + rag_decoder = SegmentsDecoder(rag._chunks) + chat_span = start_chat_span( self.provider, [*self._turns, user_turn], _otel_parent ) @@ -2924,17 +2951,32 @@ def emit(x: str | Content): break result = self.provider.stream_merge_chunks(result, chunk) for content in self.provider.stream_content(chunk, result): - yield from acc.process_content( - content, display_text(content), content_mode, emit - ) + if rag_decoder is not None and isinstance( + content, ContentText + ): + for c in rag_decoder.feed(content.text): + yield from acc.process_content( + c, display_text(c), content_mode, emit + ) + else: + yield from acc.process_content( + content, display_text(content), content_mode, emit + ) yield from acc.flush_thinking(content_mode, emit) + if rag_decoder is not None: + for c in rag_decoder.finish(): + yield from acc.process_content( + c, display_text(c), content_mode, emit + ) if not controller.cancelled: turn = self.provider.stream_turn( result, has_data_model=data_model is not None, ) + if rag is not None and rag_decoder is not None: + turn = rag.transform_turn(turn) if echo == "all": emit_other_contents(turn, emit) turn = finalize_assistant_turn(self.provider, turn) @@ -2957,6 +2999,8 @@ def emit(x: str | Content): turn = self.provider.value_turn( response, has_data_model=data_model is not None ) + if rag is not None and rag_decoder is not None: + turn = rag.transform_turn(turn) emit_thinking_contents(turn, emit) emit_web_contents(turn, emit) @@ -3016,6 +3060,15 @@ async def _submit_turns_async( *, controller: StreamController, ) -> AsyncGenerator[str | Content, None]: + # A user-supplied `data_model` always wins: only fall back to the + # hand-rolled RAG tier's segments schema when the caller hasn't asked + # for structured output of their own. + rag = self._rag + rag_decoder: Optional[SegmentsDecoder] = None + if data_model is None and rag is not None and rag.uses_segments_schema(): + data_model = SegmentedAnswer + rag_decoder = SegmentsDecoder(rag._chunks) + chat_span = start_chat_span( self.provider, [*self._turns, user_turn], _otel_parent ) @@ -3074,19 +3127,36 @@ def emit(x: str | Content): break result = self.provider.stream_merge_chunks(result, chunk) for content in self.provider.stream_content(chunk, result): - for item in acc.process_content( - content, display_text(content), content_mode, emit + if rag_decoder is not None and isinstance( + content, ContentText ): - yield item + for c in rag_decoder.feed(content.text): + for item in acc.process_content( + c, display_text(c), content_mode, emit + ): + yield item + else: + for item in acc.process_content( + content, display_text(content), content_mode, emit + ): + yield item for item in acc.flush_thinking(content_mode, emit): yield item + if rag_decoder is not None: + for c in rag_decoder.finish(): + for item in acc.process_content( + c, display_text(c), content_mode, emit + ): + yield item if not controller.cancelled: turn = self.provider.stream_turn( result, has_data_model=data_model is not None, ) + if rag is not None and rag_decoder is not None: + turn = rag.transform_turn(turn) if echo == "all": emit_other_contents(turn, emit) turn = finalize_assistant_turn(self.provider, turn) @@ -3109,6 +3179,8 @@ def emit(x: str | Content): turn = self.provider.value_turn( response, has_data_model=data_model is not None ) + if rag is not None and rag_decoder is not None: + turn = rag.transform_turn(turn) emit_thinking_contents(turn, emit) emit_web_contents(turn, emit) diff --git a/chatlas/_content.py b/chatlas/_content.py index ec09a1a5..f13909dd 100644 --- a/chatlas/_content.py +++ b/chatlas/_content.py @@ -246,15 +246,13 @@ def _repr_markdown_(self): return self.__str__() -SourceTypeEnum = Literal["web"] +SourceTypeEnum = Literal["web", "document"] class Source(BaseModel): """Identity of a piece of evidence a citation or search result points to. - Subclasses set a distinct ``type`` and add their identity fields. Today the - only concrete source is :class:`WebSource`; file/document/RAG variants are - added when that support lands. + Subclasses set a distinct ``type`` and add their identity fields. """ type: SourceTypeEnum @@ -274,6 +272,17 @@ def __str__(self) -> str: return self.url or self.title or "[web source]" +class DocumentSource(Source): + """A document (file, store chunk, or upload) a citation points to.""" + + type: SourceTypeEnum = "document" + id: Optional[str] = None + title: Optional[str] = None + + def __str__(self) -> str: + return self.id or self.title or "[document source]" + + class ContentText(Content): """ Text content for a [](`~chatlas.Turn`) @@ -724,6 +733,45 @@ def _arguments_str(self) -> str: return str(self.arguments) +class SearchResult(BaseModel): + """One retrieved chunk, normalized for citation plumbing. + + `id` must be unique across the whole conversation (RagManager assigns + them); it is the handle citations use to refer back to the chunk. + """ + + id: str + text: str + source: Optional[str] = None + title: Optional[str] = None + extra: dict[str, Any] = Field(default_factory=dict) + + +class ToolSearchResults(BaseModel): + """Search results returned from a tool, opted into citability. + + Return this from any tool (`ContentToolResult(value=ToolSearchResults(...))` + or directly) to let providers with native search-result citations + (Anthropic) cite individual results. Other providers receive the tagged + JSON from `to_dict()`. + """ + + results: list[SearchResult] + + def to_dict(self) -> dict[str, Any]: + return { + "results": [ + { + "chunk_id": r.id, + "source": r.source, + "title": r.title, + "text": r.text, + } + for r in self.results + ] + } + + class ContentJson(Content): """ JSON content @@ -1209,6 +1257,8 @@ def create_source(data: dict[str, Any]) -> Source: t = data.get("type") if t == "web": return WebSource.model_validate(data) + if t == "document": + return DocumentSource.model_validate(data) raise ValueError(f"Unknown source type: {t}") diff --git a/chatlas/_provider.py b/chatlas/_provider.py index d662c7d9..15907451 100644 --- a/chatlas/_provider.py +++ b/chatlas/_provider.py @@ -506,6 +506,14 @@ def _no_file_support(self) -> NotImplementedError: "Supported providers: ChatOpenAI, ChatAnthropic, ChatGoogle." ) + def supports_native_search_results(self) -> bool: + """Whether tool results can be sent as natively-citable search results.""" + return False + + def supports_tools_with_data_model(self) -> bool: + """Whether tools and a `data_model` can coexist in one request.""" + return True + ProviderClassT = TypeVar("ProviderClassT", bound=type[Provider[Any, Any, Any, Any]]) diff --git a/chatlas/_provider_anthropic.py b/chatlas/_provider_anthropic.py index 98e8390c..183e8d4c 100644 --- a/chatlas/_provider_anthropic.py +++ b/chatlas/_provider_anthropic.py @@ -37,7 +37,11 @@ ContentToolResponseSearch, ContentToolResult, ContentUploaded, + DocumentSource, ProviderAnnotation, + SearchResult, + Source, + ToolSearchResults, WebSource, check_image_content_type_supported, ) @@ -513,7 +517,9 @@ def _chat_perform_args( kwargs_full: "SubmitInputArgs" = { "stream": stream, - "messages": self._as_message_params(turns), + "messages": self._as_message_params( + turns, citations_enabled=data_model is None + ), "model": self.model, "max_tokens": self._max_tokens, "tools": tool_schemas, @@ -805,7 +811,12 @@ def file_delete(self, id: str) -> None: # noqa: A002 async def file_delete_async(self, id: str) -> None: # noqa: A002 await self._async_client.beta.files.delete(id) - def _as_message_params(self, turns: Sequence[Turn]) -> list["MessageParam"]: + def supports_native_search_results(self) -> bool: + return True + + def _as_message_params( + self, turns: Sequence[Turn], citations_enabled: bool = True + ) -> list["MessageParam"]: messages: list["MessageParam"] = [] for i, turn in enumerate(turns): if isinstance(turn, SystemTurn): @@ -814,7 +825,7 @@ def _as_message_params(self, turns: Sequence[Turn]) -> list["MessageParam"]: raise ValueError(f"Unknown role {turn.role}") content = [ - self._as_content_block(c) + self._as_content_block(c, citations_enabled) for c in turn.contents if not isinstance(c, PROVIDER_ANNOTATION_TYPES) or anthropic_replayable(c) @@ -838,7 +849,9 @@ def _as_message_params(self, turns: Sequence[Turn]) -> list["MessageParam"]: return messages @staticmethod - def _as_content_block(content: Content) -> "ContentBlockParam": + def _as_content_block( + content: Content, citations_enabled: bool = True + ) -> "ContentBlockParam": if isinstance(content, ContentText): return {"text": content.text, "type": "text"} elif isinstance(content, ContentJson): @@ -924,6 +937,11 @@ def _as_content_block(content: Content) -> "ContentBlockParam": "content": content.get_model_value(), # type: ignore } + if content.error is None and isinstance(content.value, ToolSearchResults): + res["content"] = anthropic_search_result_blocks( # type: ignore + content.value.results, citations_enabled + ) + return res elif isinstance(content, ContentThinking): extra = content.extra or {} @@ -1502,18 +1520,45 @@ def list_models(self): return res +def anthropic_search_result_blocks( + results: list[SearchResult], citations_enabled: bool +) -> list[dict[str, Any]]: + """search_result content blocks (GA, no beta header) for retrieved chunks. + + `source`/`title` are required by the API, so fall back to the chunk id. + Citations must be off when the request has a data_model (400 otherwise). + """ + blocks: list[dict[str, Any]] = [] + for r in results: + block: dict[str, Any] = { + "type": "search_result", + "source": r.source or r.id, + "title": r.title or r.id, + "content": [{"type": "text", "text": r.text}], + } + if citations_enabled: + block["citations"] = {"enabled": True} + blocks.append(block) + return blocks + + def anthropic_citations(block: "TextBlock") -> list[ContentCitation]: """ContentCitations for one fully-accumulated text block.""" out: list[ContentCitation] = [] for c in block.citations or []: # `url`/`title` only exist on the web-search/search-result members of the # TextCitation union (document citations carry `document_title` instead). + source: Optional[Source] = None url = getattr(c, "url", None) + if url: + source = WebSource(url=url, title=getattr(c, "title", None)) + elif getattr(c, "type", None) == "search_result_location": + source = DocumentSource( + id=getattr(c, "source", None), title=getattr(c, "title", None) + ) out.append( ContentCitation( - source=WebSource(url=url, title=getattr(c, "title", None)) - if url - else None, + source=source, # Anthropic scopes a citation to the text block it arrived on. grounded_span=block.text, cited_quote=c.cited_text, diff --git a/chatlas/_provider_openai_completions.py b/chatlas/_provider_openai_completions.py index 9a9ea89c..23f94adf 100644 --- a/chatlas/_provider_openai_completions.py +++ b/chatlas/_provider_openai_completions.py @@ -278,6 +278,10 @@ def _chat_perform_args( return kwargs_full + def supports_tools_with_data_model(self) -> bool: + # _chat_perform_args drops `tools` whenever data_model is set + return False + def stream_content(self, chunk, completion) -> list[Content]: if not chunk.choices: return [] diff --git a/chatlas/_rag.py b/chatlas/_rag.py new file mode 100644 index 00000000..51f01c3d --- /dev/null +++ b/chatlas/_rag.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import inspect +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Any, + Mapping, + Optional, + Protocol, + Sequence, + runtime_checkable, +) + +import orjson +from pydantic import BaseModel, Field, ValidationError +from pydantic_core import from_json + +from ._content import ( + Content, + ContentCitation, + ContentJson, + ContentText, + ContentToolResult, + DocumentSource, + SearchResult, + ToolSearchResults, +) +from ._logging import logger + +if TYPE_CHECKING: + from ._chat import Chat + from ._turn import AssistantTurn + +RETRIEVAL_TOOL_DESCRIPTION = """\ +Search the registered document store for passages relevant to a query. +Use this whenever the user's question could be answered from the store's +documents. Ground your answer in the returned results.""" + + +class CitedSegment(BaseModel): + text: str = Field( + description=( + "A span of the answer, in plain prose. Concatenating every " + "segment's text in order must produce the complete answer." + ) + ) + chunk_ids: list[str] = Field( + description=( + "chunk_id values of the search results that directly support this " + "span. Empty list if none. Start a new segment whenever the set " + "of supporting sources changes." + ) + ) + + +class SegmentedAnswer(BaseModel): + segments: list[CitedSegment] + + +class SegmentsDecoder: + """Incrementally turn a streamed SegmentedAnswer JSON into contents. + + Citations for the most-recently-parsed segment are always withheld until + either a later segment appears or `finish()` is called: under + `allow_partial="trailing-strings"`, a trailing `chunk_ids` entry may + itself be a truncated-but-valid JSON string (e.g. `"c12"` parsed + mid-stream as `"c1"` — a wrong-but-valid id). + """ + + def __init__(self, chunks: Mapping[str, SearchResult]): + self._chunks = chunks + self._raw = "" + self._seg_index = 0 + self._chars_emitted = 0 + + def feed(self, delta: str) -> list[Content]: + self._raw += delta + try: + data = from_json(self._raw, allow_partial="trailing-strings") + except ValueError: + return [] + segments = data.get("segments") if isinstance(data, dict) else None + if not isinstance(segments, list): + return [] + return self._advance(segments, final=False) + + def finish(self) -> list[Content]: + try: + data = from_json(self._raw, allow_partial="trailing-strings") + except ValueError: + # Nothing ever parsed: surface raw text rather than dropping it + return [ContentText(text=self._raw)] if self._raw.strip() else [] + segments = data.get("segments") if isinstance(data, dict) else None + if not isinstance(segments, list): + return [ContentText(text=self._raw)] if self._raw.strip() else [] + return self._advance(segments, final=True) + + def _advance(self, segments: list[Any], *, final: bool) -> list[Content]: + out: list[Content] = [] + while self._seg_index < len(segments): + seg = segments[self._seg_index] + text = seg.get("text", "") if isinstance(seg, dict) else "" + is_last = self._seg_index == len(segments) - 1 + growth = text[self._chars_emitted :] + if growth: + out.append(ContentText.model_construct(text=growth)) + self._chars_emitted = len(text) + if is_last and not final: + break # citations withheld: trailing chunk_ids may be truncated + out.extend(segment_citations(seg, text, self._chunks)) + self._seg_index += 1 + self._chars_emitted = 0 + return out + + +def decode_segments_json(raw: str, chunks: Mapping[str, SearchResult]) -> list[Content]: + """One-shot, strict decode of a complete SegmentedAnswer JSON string. + + Used for non-streaming responses and final-turn transformation. Falls + back to treating the whole string as plain text if validation fails, so + malformed JSON never crashes a turn. + """ + try: + answer = SegmentedAnswer.model_validate_json(raw) + except ValidationError: + return [ContentText(text=raw)] + out: list[Content] = [] + for seg in answer.segments: + if seg.text: + out.append(ContentText.model_construct(text=seg.text)) + out.extend(segment_citations(seg.model_dump(), seg.text, chunks)) + return out + + +def segment_citations( + seg: Any, grounded_span: str, chunks: Mapping[str, SearchResult] +) -> list[ContentCitation]: + ids = seg.get("chunk_ids", []) if isinstance(seg, dict) else [] + out: list[ContentCitation] = [] + for chunk_id in ids: + sr = chunks.get(chunk_id) + if sr is None: + logger.debug("Dropping citation with unknown chunk_id %r", chunk_id) + continue + out.append( + ContentCitation( + source=DocumentSource(id=sr.source or sr.id, title=sr.title), + grounded_span=grounded_span, + cited_quote=sr.text, + extra={"chunk_id": sr.id}, + ) + ) + return out + + +@dataclass +class RegisteredStore: + store: "RetrievalStore" + top_k: int + name: str + description: Optional[str] + + +class RagManager: + """Configure retrieval-augmented, citation-bearing chats. Via `chat.rag`.""" + + def __init__(self, chat: "Chat"): + self._chat = chat + self._stores: dict[str, RegisteredStore] = {} + # TODO: Add an eviction policy for chunks retained by long-lived chats. + self._chunks: dict[str, SearchResult] = {} + self._counter = 0 + + def register_store( + self, + store: "RetrievalStore", + *, + top_k: int = 5, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> None: + provider = self._chat.provider + if ( + not provider.supports_native_search_results() + and not provider.supports_tools_with_data_model() + ): + raise ValueError( + f"Provider '{provider.name}' cannot combine tools with a " + "response schema, which RAG citations require." + ) + name = name or "search_documents" + if name in self._stores: + raise ValueError( + f"A store named {name!r} is already registered. Pass a " + "distinct `name=` to register another store." + ) + reg = RegisteredStore( + store=store, + top_k=top_k, + name=name, + description=description, + ) + self._chat.register_tool(self._make_retrieval_tool(reg), name=name, force=False) + self._stores[name] = reg + + def unregister_store(self, name: str) -> None: + reg = self._stores.pop(name) + tools = self._chat.get_tools() + self._chat.set_tools([t for t in tools if t.name != reg.name]) + + @property + def stores(self) -> dict[str, RegisteredStore]: + return dict(self._stores) + + @property + def chunks(self) -> dict[str, SearchResult]: + return dict(self._chunks) + + def uses_segments_schema(self) -> bool: + if not self._stores: + return False + return not self._chat.provider.supports_native_search_results() + + def transform_turn(self, turn: "AssistantTurn") -> "AssistantTurn": + """Splice decoded prose/citations in for the raw segments-JSON content. + + Called on the final turn of a hand-rolled-tier response, whose JSON + output arrives as a `ContentJson` (or `ContentText`, before a provider + tags it) carrying the `SegmentedAnswer` payload. Every other content + (tool requests, thinking, etc.) is left untouched. + """ + new_contents: list[Content] = [] + for content in turn.contents: + if isinstance(content, ContentJson): + raw = orjson.dumps(content.value).decode() + new_contents.extend(decode_segments_json(raw, self._chunks)) + elif isinstance(content, ContentText): + new_contents.extend(decode_segments_json(content.text, self._chunks)) + else: + new_contents.append(content) + # Content is the base class; contents is typed as list[ContentUnion] + # (discriminated union). At runtime all Content subclasses are ContentUnion + # members, so the assignment is safe (same reasoning as TurnAccumulator). + turn.contents = new_contents # type: ignore[assignment] + return turn + + def register_chunks(self, chunks: Sequence["ChunkLike"]) -> list[SearchResult]: + out: list[SearchResult] = [] + for chunk in chunks: + self._counter += 1 + sr = normalize_chunk(chunk, id=f"c{self._counter}") + self._chunks[sr.id] = sr + out.append(sr) + return out + + def _make_retrieval_tool(self, reg: RegisteredStore): + def retrieve(query: str) -> ContentToolResult: + """Search the document store. + + Parameters + ---------- + query + What to look for, phrased as a focused search query. + """ + chunks = reg.store.retrieve(query, reg.top_k) + results = self.register_chunks(chunks) + return ContentToolResult(value=ToolSearchResults(results=results)) + + retrieve.__name__ = reg.name + inner_doc = inspect.cleandoc(retrieve.__doc__ or "") + retrieve.__doc__ = f"{reg.description or RETRIEVAL_TOOL_DESCRIPTION}\n\n{inner_doc}" + return retrieve + + +@runtime_checkable +class ChunkLike(Protocol): + """A retrieved chunk: `text` is required; `origin` (stable source string), + `context` (human label, e.g. heading trail), and `attributes` (dict) are + read via `getattr` when present. raghilda's `Chunk` satisfies this.""" + + text: str + + +@runtime_checkable +class RetrievalStore(Protocol): + """Anything with `retrieve(text, top_k) -> chunks`. raghilda's + `BaseStore` satisfies this; so does any user class with one method.""" + + def retrieve(self, text: str, top_k: int) -> Sequence[ChunkLike]: ... + + +def normalize_chunk(chunk: ChunkLike, id: str) -> SearchResult: # noqa: A002 + origin: Optional[str] = getattr(chunk, "origin", None) + context: Optional[str] = getattr(chunk, "context", None) + attributes = getattr(chunk, "attributes", None) + return SearchResult( + id=id, + text=chunk.text, + source=origin, + title=context, + extra=dict(attributes) if attributes else {}, + ) diff --git a/chatlas/types/__init__.py b/chatlas/types/__init__.py index 69b3201c..720897b9 100644 --- a/chatlas/types/__init__.py +++ b/chatlas/types/__init__.py @@ -22,15 +22,19 @@ ContentToolResponseSearch, ContentToolResult, ContentUploaded, + DocumentSource, ImageContentTypes, + SearchResult, Source, ToolAnnotations, ToolInfo, + ToolSearchResults, WebSource, ) from .._files import FileMetadata from .._parallel import StructuredChatResult from .._provider import ModelInfo +from .._rag import ChunkLike, RetrievalStore from .._tokens import TokenUsage from .._turn import FinishReason from .._utils import MISSING, MISSING_TYPE @@ -59,8 +63,13 @@ "StructuredChatResult", "ChatResponse", "ChatResponseAsync", + "ChunkLike", "ImageContentTypes", + "RetrievalStore", + "SearchResult", "Source", + "DocumentSource", + "ToolSearchResults", "WebSource", "SubmitInputArgsT", "TokenUsage", diff --git a/tests/_vcr/test_provider_anthropic_rag/test_anthropic_rag_streaming_interleaves_citations.yaml b/tests/_vcr/test_provider_anthropic_rag/test_anthropic_rag_streaming_interleaves_citations.yaml new file mode 100644 index 00000000..191a0b6e --- /dev/null +++ b/tests/_vcr/test_provider_anthropic_rag/test_anthropic_rag_streaming_interleaves_citations.yaml @@ -0,0 +1,484 @@ +interactions: +- request: + body: '{"max_tokens": 4096, "messages": [{"role": "user", "content": [{"text": + "How does Flurbo stream responses?", "type": "text", "cache_control": {"type": + "ephemeral", "ttl": "5m"}}]}], "model": "claude-sonnet-4-6", "stream": true, + "tools": [{"name": "search_documents", "input_schema": {"type": "object", "properties": + {"query": {"type": "string"}}, "required": ["query"], "additionalProperties": + false}, "description": "Search the registered document store for passages relevant + to a query.\nUse this whenever the user''s question could be answered from the + store''s\ndocuments. Ground your answer in the returned results.\n\nSearch the + document store.\n\nParameters\n----------\nquery\n What to look for, phrased + as a focused search query."}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + anthropic-version: + - '2023-06-01' + connection: + - keep-alive + content-length: + - '709' + content-type: + - application/json + host: + - api.anthropic.com + x-stainless-async: + - 'false' + x-stainless-read-timeout: + - '600' + x-stainless-timeout: + - NOT_GIVEN + method: POST + uri: https://api.anthropic.com/v1/messages + response: + body: + string: 'event: message_start + + data: {"type":"message_start","message":{"model":"claude-sonnet-4-6","id":"msg_011CdYncu8pfxDgjoxELYpSN","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":643,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":59,"service_tier":"standard","inference_geo":"global"}} } + + + event: content_block_start + + data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01MvCUT8oTFjVJg1qTVSfpbN","name":"search_documents","input":{},"caller":{"type":"direct"}} } + + + event: ping + + data: {"type": "ping"} + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\""} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"query\": + \"Fl"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ur"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"bo + stream "} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"response"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"s\"}"} } + + + event: content_block_stop + + data: {"type":"content_block_stop","index":0 } + + + event: message_delta + + data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null},"usage":{"input_tokens":643,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":59} } + + + event: message_stop + + data: {"type":"message_stop"} + + + ' + headers: + anthropic-ratelimit-input-tokens-limit: + - '10000000' + anthropic-ratelimit-input-tokens-remaining: + - '10000000' + anthropic-ratelimit-input-tokens-reset: + - '2026-07-30T19:23:09Z' + anthropic-ratelimit-output-tokens-limit: + - '2000000' + anthropic-ratelimit-output-tokens-remaining: + - '2000000' + anthropic-ratelimit-output-tokens-reset: + - '2026-07-30T19:23:09Z' + anthropic-ratelimit-requests-limit: + - '20000' + anthropic-ratelimit-requests-remaining: + - '19999' + anthropic-ratelimit-requests-reset: + - '2026-07-30T19:23:09Z' + anthropic-ratelimit-tokens-limit: + - '12000000' + anthropic-ratelimit-tokens-remaining: + - '12000000' + anthropic-ratelimit-tokens-reset: + - '2026-07-30T19:23:09Z' + cache-control: + - no-cache + cf-cache-status: + - DYNAMIC + connection: + - keep-alive + content-length: + - '2119' + content-security-policy: + - default-src 'none'; frame-ancestors 'none' + content-type: + - text/event-stream; charset=utf-8 + date: + - Thu, 30 Jul 2026 19:23:10 GMT + server: + - cloudflare + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + traceresponse: + - 00-beb222b6e75c94863a2a11b3e3ceb60e-9d4ea991a4d3ea96-01 + transfer-encoding: + - chunked + vary: + - Accept-Encoding + x-robots-tag: + - none + status: + code: 200 + message: OK +- request: + body: '{"max_tokens": 4096, "messages": [{"role": "user", "content": [{"text": + "How does Flurbo stream responses?", "type": "text"}]}, {"role": "assistant", + "content": [{"type": "tool_use", "id": "toolu_01MvCUT8oTFjVJg1qTVSfpbN", "name": + "search_documents", "input": {"query": "Flurbo stream responses"}}]}, {"role": + "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01MvCUT8oTFjVJg1qTVSfpbN", + "is_error": false, "content": [{"type": "search_result", "source": "kb://flurbo/streaming", + "title": "Flurbo > Streaming", "content": [{"type": "text", "text": "Flurbo + streams responses via the flb.stream() generator, which yields FlurboChunk objects."}], + "citations": {"enabled": true}}, {"type": "search_result", "source": "kb://flurbo/intro", + "title": "Flurbo > Introduction", "content": [{"type": "text", "text": "The + Flurbo framework was created in 2019 by Ada Quist. Its default port is 7113."}], + "citations": {"enabled": true}}], "cache_control": {"type": "ephemeral", "ttl": + "5m"}}]}], "model": "claude-sonnet-4-6", "stream": true, "tools": [{"name": + "search_documents", "input_schema": {"type": "object", "properties": {"query": + {"type": "string"}}, "required": ["query"], "additionalProperties": false}, + "description": "Search the registered document store for passages relevant to + a query.\nUse this whenever the user''s question could be answered from the + store''s\ndocuments. Ground your answer in the returned results.\n\nSearch the + document store.\n\nParameters\n----------\nquery\n What to look for, phrased + as a focused search query."}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + anthropic-version: + - '2023-06-01' + connection: + - keep-alive + content-length: + - '1474' + content-type: + - application/json + host: + - api.anthropic.com + x-stainless-async: + - 'false' + x-stainless-read-timeout: + - '600' + x-stainless-timeout: + - NOT_GIVEN + method: POST + uri: https://api.anthropic.com/v1/messages + response: + body: + string: 'event: message_start + + data: {"type":"message_start","message":{"model":"claude-sonnet-4-6","id":"msg_011CdYnd1VJvqaZumFhSNdai","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":1429,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":59,"service_tier":"standard","inference_geo":"global"}} } + + + event: content_block_start + + data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01P67ZGroxh9tS4MkGUDjcGX","name":"search_documents","input":{},"caller":{"type":"direct"}} } + + + event: ping + + data: {"type": "ping"} + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""} + } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"qu"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"er"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"y\": + \"Flu"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"rboChunk + str"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ea"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"min"} + } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"g + g"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"enerat"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"or\"}"} } + + + event: content_block_stop + + data: {"type":"content_block_stop","index":0 } + + + event: message_delta + + data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null},"usage":{"input_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":1429,"output_tokens":59} } + + + event: message_stop + + data: {"type":"message_stop" } + + + ' + headers: + anthropic-ratelimit-input-tokens-limit: + - '10000000' + anthropic-ratelimit-input-tokens-remaining: + - '9999000' + anthropic-ratelimit-input-tokens-reset: + - '2026-07-30T19:23:10Z' + anthropic-ratelimit-output-tokens-limit: + - '2000000' + anthropic-ratelimit-output-tokens-remaining: + - '2000000' + anthropic-ratelimit-output-tokens-reset: + - '2026-07-30T19:23:10Z' + anthropic-ratelimit-requests-limit: + - '20000' + anthropic-ratelimit-requests-remaining: + - '19999' + anthropic-ratelimit-requests-reset: + - '2026-07-30T19:23:10Z' + anthropic-ratelimit-tokens-limit: + - '12000000' + anthropic-ratelimit-tokens-remaining: + - '11999000' + anthropic-ratelimit-tokens-reset: + - '2026-07-30T19:23:10Z' + cache-control: + - no-cache + cf-cache-status: + - DYNAMIC + connection: + - keep-alive + content-length: + - '2503' + content-security-policy: + - default-src 'none'; frame-ancestors 'none' + content-type: + - text/event-stream; charset=utf-8 + date: + - Thu, 30 Jul 2026 19:23:11 GMT + server: + - cloudflare + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + traceresponse: + - 00-078b6b69116ab183e722ab957febb567-ce9456b1bd8083c5-01 + transfer-encoding: + - chunked + vary: + - Accept-Encoding + x-robots-tag: + - none + status: + code: 200 + message: OK +- request: + body: '{"max_tokens": 4096, "messages": [{"role": "user", "content": [{"text": + "How does Flurbo stream responses?", "type": "text"}]}, {"role": "assistant", + "content": [{"type": "tool_use", "id": "toolu_01MvCUT8oTFjVJg1qTVSfpbN", "name": + "search_documents", "input": {"query": "Flurbo stream responses"}}]}, {"role": + "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01MvCUT8oTFjVJg1qTVSfpbN", + "is_error": false, "content": [{"type": "search_result", "source": "kb://flurbo/streaming", + "title": "Flurbo > Streaming", "content": [{"type": "text", "text": "Flurbo + streams responses via the flb.stream() generator, which yields FlurboChunk objects."}], + "citations": {"enabled": true}}, {"type": "search_result", "source": "kb://flurbo/intro", + "title": "Flurbo > Introduction", "content": [{"type": "text", "text": "The + Flurbo framework was created in 2019 by Ada Quist. Its default port is 7113."}], + "citations": {"enabled": true}}]}]}, {"role": "assistant", "content": [{"type": + "tool_use", "id": "toolu_01P67ZGroxh9tS4MkGUDjcGX", "name": "search_documents", + "input": {"query": "FlurboChunk streaming generator"}}]}, {"role": "user", "content": + [{"type": "tool_result", "tool_use_id": "toolu_01P67ZGroxh9tS4MkGUDjcGX", "is_error": + false, "content": [{"type": "search_result", "source": "kb://flurbo/streaming", + "title": "Flurbo > Streaming", "content": [{"type": "text", "text": "Flurbo + streams responses via the flb.stream() generator, which yields FlurboChunk objects."}], + "citations": {"enabled": true}}, {"type": "search_result", "source": "kb://flurbo/intro", + "title": "Flurbo > Introduction", "content": [{"type": "text", "text": "The + Flurbo framework was created in 2019 by Ada Quist. Its default port is 7113."}], + "citations": {"enabled": true}}], "cache_control": {"type": "ephemeral", "ttl": + "5m"}}]}], "model": "claude-sonnet-4-6", "stream": true, "tools": [{"name": + "search_documents", "input_schema": {"type": "object", "properties": {"query": + {"type": "string"}}, "required": ["query"], "additionalProperties": false}, + "description": "Search the registered document store for passages relevant to + a query.\nUse this whenever the user''s question could be answered from the + store''s\ndocuments. Ground your answer in the returned results.\n\nSearch the + document store.\n\nParameters\n----------\nquery\n What to look for, phrased + as a focused search query."}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + anthropic-version: + - '2023-06-01' + connection: + - keep-alive + content-length: + - '2247' + content-type: + - application/json + host: + - api.anthropic.com + x-stainless-async: + - 'false' + x-stainless-read-timeout: + - '600' + x-stainless-timeout: + - NOT_GIVEN + method: POST + uri: https://api.anthropic.com/v1/messages + response: + body: + string: "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_011CdYnd7QWF5sfLhAe9NEJ1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":1753,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} + \ }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} + \ }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: + {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Based\"} + \ }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" + on the available documentation, \"} }\n\nevent: content_block_stop\ndata: + {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: content_block_start\ndata: + {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"citations\":[],\"type\":\"text\",\"text\":\"\"} + \ }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"citations_delta\",\"citation\":{\"type\":\"search_result_location\",\"cited_text\":\"Flurbo + streams responses via the flb.stream() generator, which yields FlurboChunk + objects.\",\"source\":\"kb://flurbo/streaming\",\"title\":\"Flurbo > Streaming\",\"search_result_index\":0,\"start_block_index\":0,\"end_block_index\":1}} + \ }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Flurbo + streams responses via the `flb.stream()` generator,\"} }\n\nevent: + content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" + which yields `FlurboChunk` objects.\"} }\n\nevent: content_block_stop\ndata: + {\"type\":\"content_block_stop\",\"index\":1 }\n\nevent: content_block_start\ndata: + {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"text\",\"text\":\"\"} + \ }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\n\\nBeyond + this, the available documentation doesn\"}}\n\nevent: content_block_delta\ndata: + {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"text_delta\",\"text\":\"'t + provide further details on streaming. For more in-depth information \u2014 + such as how to\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"text_delta\",\"text\":\" + handle `FlurboChunk` objects, available options for `flb.stream()`, or example\"} + \ }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"text_delta\",\"text\":\" + code \u2014 I'd recommend consulting the full Flurbo documentation or source + code directly.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":2 + \ }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":1,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":1753,\"output_tokens\":119}}\n\nevent: + message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + headers: + anthropic-ratelimit-input-tokens-limit: + - '10000000' + anthropic-ratelimit-input-tokens-remaining: + - '9999000' + anthropic-ratelimit-input-tokens-reset: + - '2026-07-30T19:23:12Z' + anthropic-ratelimit-output-tokens-limit: + - '2000000' + anthropic-ratelimit-output-tokens-remaining: + - '2000000' + anthropic-ratelimit-output-tokens-reset: + - '2026-07-30T19:23:12Z' + anthropic-ratelimit-requests-limit: + - '20000' + anthropic-ratelimit-requests-remaining: + - '19999' + anthropic-ratelimit-requests-reset: + - '2026-07-30T19:23:12Z' + anthropic-ratelimit-tokens-limit: + - '12000000' + anthropic-ratelimit-tokens-remaining: + - '11999000' + anthropic-ratelimit-tokens-reset: + - '2026-07-30T19:23:12Z' + cache-control: + - no-cache + cf-cache-status: + - DYNAMIC + connection: + - keep-alive + content-length: + - '3268' + content-security-policy: + - default-src 'none'; frame-ancestors 'none' + content-type: + - text/event-stream; charset=utf-8 + date: + - Thu, 30 Jul 2026 19:23:12 GMT + server: + - cloudflare + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + traceresponse: + - 00-d31ed7eb6da706d5c4ab1fa4a388d6eb-5281d72aaeec5ce4-01 + transfer-encoding: + - chunked + vary: + - Accept-Encoding + x-robots-tag: + - none + status: + code: 200 + message: OK +version: 1 diff --git a/tests/_vcr/test_provider_anthropic_rag/test_anthropic_rag_tool_mode_citations.yaml b/tests/_vcr/test_provider_anthropic_rag/test_anthropic_rag_tool_mode_citations.yaml new file mode 100644 index 00000000..8e6606ac --- /dev/null +++ b/tests/_vcr/test_provider_anthropic_rag/test_anthropic_rag_tool_mode_citations.yaml @@ -0,0 +1,566 @@ +interactions: +- request: + body: '{"max_tokens": 4096, "messages": [{"role": "user", "content": [{"text": + "How does Flurbo stream responses?", "type": "text", "cache_control": {"type": + "ephemeral", "ttl": "5m"}}]}], "model": "claude-sonnet-4-6", "stream": true, + "tools": [{"name": "search_documents", "input_schema": {"type": "object", "properties": + {"query": {"type": "string"}}, "required": ["query"], "additionalProperties": + false}, "description": "Search the registered document store for passages relevant + to a query.\nUse this whenever the user''s question could be answered from the + store''s\ndocuments. Ground your answer in the returned results.\n\nSearch the + document store.\n\nParameters\n----------\nquery\n What to look for, phrased + as a focused search query."}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + anthropic-version: + - '2023-06-01' + connection: + - keep-alive + content-length: + - '709' + content-type: + - application/json + host: + - api.anthropic.com + x-stainless-async: + - 'false' + x-stainless-read-timeout: + - '600' + x-stainless-timeout: + - NOT_GIVEN + method: POST + uri: https://api.anthropic.com/v1/messages + response: + body: + string: 'event: message_start + + data: {"type":"message_start","message":{"model":"claude-sonnet-4-6","id":"msg_011CdYncRdbRc3Xxy5pow4d7","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":643,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":59,"service_tier":"standard","inference_geo":"global"}} } + + + event: content_block_start + + data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01TFJy9Xk6dYmzDzeKVLAMSb","name":"search_documents","input":{},"caller":{"type":"direct"}} } + + + event: ping + + data: {"type": "ping"} + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":"}} + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":" + \"F"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"lurbo + st"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ream + res"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"pon"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ses\"}"} } + + + event: content_block_stop + + data: {"type":"content_block_stop","index":0 } + + + event: message_delta + + data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null},"usage":{"input_tokens":643,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":59} } + + + event: message_stop + + data: {"type":"message_stop" } + + + ' + headers: + anthropic-ratelimit-input-tokens-limit: + - '10000000' + anthropic-ratelimit-input-tokens-remaining: + - '10000000' + anthropic-ratelimit-input-tokens-reset: + - '2026-07-30T19:23:02Z' + anthropic-ratelimit-output-tokens-limit: + - '2000000' + anthropic-ratelimit-output-tokens-remaining: + - '2000000' + anthropic-ratelimit-output-tokens-reset: + - '2026-07-30T19:23:02Z' + anthropic-ratelimit-requests-limit: + - '20000' + anthropic-ratelimit-requests-remaining: + - '19999' + anthropic-ratelimit-requests-reset: + - '2026-07-30T19:23:02Z' + anthropic-ratelimit-tokens-limit: + - '12000000' + anthropic-ratelimit-tokens-remaining: + - '12000000' + anthropic-ratelimit-tokens-reset: + - '2026-07-30T19:23:02Z' + cache-control: + - no-cache + cf-cache-status: + - DYNAMIC + connection: + - keep-alive + content-length: + - '2134' + content-security-policy: + - default-src 'none'; frame-ancestors 'none' + content-type: + - text/event-stream; charset=utf-8 + date: + - Thu, 30 Jul 2026 19:23:03 GMT + server: + - cloudflare + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + traceresponse: + - 00-a2985bcec16df5f9605ff265908eba49-775678e78f5da69a-01 + transfer-encoding: + - chunked + vary: + - Accept-Encoding + x-robots-tag: + - none + status: + code: 200 + message: OK +- request: + body: '{"max_tokens": 4096, "messages": [{"role": "user", "content": [{"text": + "How does Flurbo stream responses?", "type": "text"}]}, {"role": "assistant", + "content": [{"type": "tool_use", "id": "toolu_01TFJy9Xk6dYmzDzeKVLAMSb", "name": + "search_documents", "input": {"query": "Flurbo stream responses"}}]}, {"role": + "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01TFJy9Xk6dYmzDzeKVLAMSb", + "is_error": false, "content": [{"type": "search_result", "source": "kb://flurbo/streaming", + "title": "Flurbo > Streaming", "content": [{"type": "text", "text": "Flurbo + streams responses via the flb.stream() generator, which yields FlurboChunk objects."}], + "citations": {"enabled": true}}, {"type": "search_result", "source": "kb://flurbo/intro", + "title": "Flurbo > Introduction", "content": [{"type": "text", "text": "The + Flurbo framework was created in 2019 by Ada Quist. Its default port is 7113."}], + "citations": {"enabled": true}}], "cache_control": {"type": "ephemeral", "ttl": + "5m"}}]}], "model": "claude-sonnet-4-6", "stream": true, "tools": [{"name": + "search_documents", "input_schema": {"type": "object", "properties": {"query": + {"type": "string"}}, "required": ["query"], "additionalProperties": false}, + "description": "Search the registered document store for passages relevant to + a query.\nUse this whenever the user''s question could be answered from the + store''s\ndocuments. Ground your answer in the returned results.\n\nSearch the + document store.\n\nParameters\n----------\nquery\n What to look for, phrased + as a focused search query."}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + anthropic-version: + - '2023-06-01' + connection: + - keep-alive + content-length: + - '1474' + content-type: + - application/json + host: + - api.anthropic.com + x-stainless-async: + - 'false' + x-stainless-read-timeout: + - '600' + x-stainless-timeout: + - NOT_GIVEN + method: POST + uri: https://api.anthropic.com/v1/messages + response: + body: + string: 'event: message_start + + data: {"type":"message_start","message":{"model":"claude-sonnet-4-6","id":"msg_011CdYncXTb3y7tB7Fi9hVBK","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":1429,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":1429,"ephemeral_1h_input_tokens":0},"output_tokens":59,"service_tier":"standard","inference_geo":"global"}} } + + + event: content_block_start + + data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01LMFTZ6uV3oPocJyd5jBmr2","name":"search_documents","input":{},"caller":{"type":"direct"}} } + + + event: ping + + data: {"type": "ping"} + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""} + } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\""} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"qu"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ery"} + } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\": + \""} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"FlurboChu"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"nk + "} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"streaming + g"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"enerat"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"or\"}"} } + + + event: content_block_stop + + data: {"type":"content_block_stop","index":0 } + + + event: message_delta + + data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null},"usage":{"input_tokens":1,"cache_creation_input_tokens":1429,"cache_read_input_tokens":0,"output_tokens":59} + } + + + event: message_stop + + data: {"type":"message_stop" } + + + ' + headers: + anthropic-ratelimit-input-tokens-limit: + - '10000000' + anthropic-ratelimit-input-tokens-remaining: + - '9999000' + anthropic-ratelimit-input-tokens-reset: + - '2026-07-30T19:23:04Z' + anthropic-ratelimit-output-tokens-limit: + - '2000000' + anthropic-ratelimit-output-tokens-remaining: + - '2000000' + anthropic-ratelimit-output-tokens-reset: + - '2026-07-30T19:23:04Z' + anthropic-ratelimit-requests-limit: + - '20000' + anthropic-ratelimit-requests-remaining: + - '19999' + anthropic-ratelimit-requests-reset: + - '2026-07-30T19:23:04Z' + anthropic-ratelimit-tokens-limit: + - '12000000' + anthropic-ratelimit-tokens-remaining: + - '11999000' + anthropic-ratelimit-tokens-reset: + - '2026-07-30T19:23:04Z' + cache-control: + - no-cache + cf-cache-status: + - DYNAMIC + connection: + - keep-alive + content-length: + - '2531' + content-security-policy: + - default-src 'none'; frame-ancestors 'none' + content-type: + - text/event-stream; charset=utf-8 + date: + - Thu, 30 Jul 2026 19:23:05 GMT + server: + - cloudflare + server-timing: + - x-originResponse;dur=1781 + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + traceresponse: + - 00-7ad0ca8b037a3aff4be4b36450a2e0e7-641b365317d41316-01 + transfer-encoding: + - chunked + vary: + - Accept-Encoding + x-robots-tag: + - none + status: + code: 200 + message: OK +- request: + body: '{"max_tokens": 4096, "messages": [{"role": "user", "content": [{"text": + "How does Flurbo stream responses?", "type": "text"}]}, {"role": "assistant", + "content": [{"type": "tool_use", "id": "toolu_01TFJy9Xk6dYmzDzeKVLAMSb", "name": + "search_documents", "input": {"query": "Flurbo stream responses"}}]}, {"role": + "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01TFJy9Xk6dYmzDzeKVLAMSb", + "is_error": false, "content": [{"type": "search_result", "source": "kb://flurbo/streaming", + "title": "Flurbo > Streaming", "content": [{"type": "text", "text": "Flurbo + streams responses via the flb.stream() generator, which yields FlurboChunk objects."}], + "citations": {"enabled": true}}, {"type": "search_result", "source": "kb://flurbo/intro", + "title": "Flurbo > Introduction", "content": [{"type": "text", "text": "The + Flurbo framework was created in 2019 by Ada Quist. Its default port is 7113."}], + "citations": {"enabled": true}}]}]}, {"role": "assistant", "content": [{"type": + "tool_use", "id": "toolu_01LMFTZ6uV3oPocJyd5jBmr2", "name": "search_documents", + "input": {"query": "FlurboChunk streaming generator"}}]}, {"role": "user", "content": + [{"type": "tool_result", "tool_use_id": "toolu_01LMFTZ6uV3oPocJyd5jBmr2", "is_error": + false, "content": [{"type": "search_result", "source": "kb://flurbo/streaming", + "title": "Flurbo > Streaming", "content": [{"type": "text", "text": "Flurbo + streams responses via the flb.stream() generator, which yields FlurboChunk objects."}], + "citations": {"enabled": true}}, {"type": "search_result", "source": "kb://flurbo/intro", + "title": "Flurbo > Introduction", "content": [{"type": "text", "text": "The + Flurbo framework was created in 2019 by Ada Quist. Its default port is 7113."}], + "citations": {"enabled": true}}], "cache_control": {"type": "ephemeral", "ttl": + "5m"}}]}], "model": "claude-sonnet-4-6", "stream": true, "tools": [{"name": + "search_documents", "input_schema": {"type": "object", "properties": {"query": + {"type": "string"}}, "required": ["query"], "additionalProperties": false}, + "description": "Search the registered document store for passages relevant to + a query.\nUse this whenever the user''s question could be answered from the + store''s\ndocuments. Ground your answer in the returned results.\n\nSearch the + document store.\n\nParameters\n----------\nquery\n What to look for, phrased + as a focused search query."}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + anthropic-version: + - '2023-06-01' + connection: + - keep-alive + content-length: + - '2247' + content-type: + - application/json + host: + - api.anthropic.com + x-stainless-async: + - 'false' + x-stainless-read-timeout: + - '600' + x-stainless-timeout: + - NOT_GIVEN + method: POST + uri: https://api.anthropic.com/v1/messages + response: + body: + string: 'event: message_start + + data: {"type":"message_start","message":{"model":"claude-sonnet-4-6","id":"msg_011CdYncgKdnj2HkTVJYxcGC","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":324,"cache_read_input_tokens":1429,"cache_creation":{"ephemeral_5m_input_tokens":324,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global"}} } + + + event: content_block_start + + data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} } + + + event: ping + + data: {"type": "ping"} + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Based"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" + on the available documentation, "} } + + + event: content_block_stop + + data: {"type":"content_block_stop","index":0 } + + + event: content_block_start + + data: {"type":"content_block_start","index":1,"content_block":{"citations":[],"type":"text","text":""} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":1,"delta":{"type":"citations_delta","citation":{"type":"search_result_location","cited_text":"Flurbo + streams responses via the flb.stream() generator, which yields FlurboChunk + objects.","source":"kb://flurbo/streaming","title":"Flurbo > Streaming","search_result_index":0,"start_block_index":0,"end_block_index":1}} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Flurbo + streams responses via the `"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"flb.stream()` + generator, which yields `FlurboChunk` objects."} } + + + event: content_block_stop + + data: {"type":"content_block_stop","index":1 } + + + event: content_block_start + + data: {"type":"content_block_start","index":2,"content_block":{"type":"text","text":""} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"\n\nBeyond + this, the available documentation"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":" + doesn''t contain further details about streaming in Flurbo (e.g., how `Fl"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"urboChunk` + objects are structured or additional configuration options). You may want + to consult the full Flurbo documentation or source"} } + + + event: content_block_delta + + data: {"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":" + code for more in-depth information."} } + + + event: content_block_stop + + data: {"type":"content_block_stop","index":2 } + + + event: message_delta + + data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"input_tokens":1,"cache_creation_input_tokens":324,"cache_read_input_tokens":1429,"output_tokens":116} } + + + event: message_stop + + data: {"type":"message_stop" } + + + ' + headers: + anthropic-ratelimit-input-tokens-limit: + - '10000000' + anthropic-ratelimit-input-tokens-remaining: + - '9999000' + anthropic-ratelimit-input-tokens-reset: + - '2026-07-30T19:23:06Z' + anthropic-ratelimit-output-tokens-limit: + - '2000000' + anthropic-ratelimit-output-tokens-remaining: + - '2000000' + anthropic-ratelimit-output-tokens-reset: + - '2026-07-30T19:23:06Z' + anthropic-ratelimit-requests-limit: + - '20000' + anthropic-ratelimit-requests-remaining: + - '19999' + anthropic-ratelimit-requests-reset: + - '2026-07-30T19:23:06Z' + anthropic-ratelimit-tokens-limit: + - '12000000' + anthropic-ratelimit-tokens-remaining: + - '11999000' + anthropic-ratelimit-tokens-reset: + - '2026-07-30T19:23:06Z' + cache-control: + - no-cache + cf-cache-status: + - DYNAMIC + connection: + - keep-alive + content-length: + - '3258' + content-security-policy: + - default-src 'none'; frame-ancestors 'none' + content-type: + - text/event-stream; charset=utf-8 + date: + - Thu, 30 Jul 2026 19:23:07 GMT + server: + - cloudflare + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + traceresponse: + - 00-e80f4f01fbb9e5051f9f4eac5c4ba61d-9afad2695573c856-01 + transfer-encoding: + - chunked + vary: + - Accept-Encoding + x-robots-tag: + - none + status: + code: 200 + message: OK +version: 1 diff --git a/tests/_vcr/test_provider_google_rag/test_google_rag_streaming_yields_prose_not_json.yaml b/tests/_vcr/test_provider_google_rag/test_google_rag_streaming_yields_prose_not_json.yaml new file mode 100644 index 00000000..0280efdd --- /dev/null +++ b/tests/_vcr/test_provider_google_rag/test_google_rag_streaming_yields_prose_not_json.yaml @@ -0,0 +1,269 @@ +interactions: +- request: + body: '{"contents": [{"parts": [{"text": "How does Flurbo stream responses?"}], + "role": "user"}], "tools": [{"functionDeclarations": [{"description": "Search + the registered document store for passages relevant to a query.\nUse this whenever + the user''s question could be answered from the store''s\ndocuments. Ground + your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.", "name": "search_documents", + "parameters": {"properties": {"query": {"type": "STRING"}}, "required": ["query"], + "type": "OBJECT"}}]}], "generationConfig": {"responseMimeType": "application/json", + "responseSchema": {"properties": {"segments": {"items": {"properties": {"text": + {"description": "A span of the answer, in plain prose. Concatenating every segment''s + text in order must produce the complete answer.", "title": "Text", "type": "STRING"}, + "chunk_ids": {"description": "chunk_id values of the search results that directly + support this span. Empty list if none. Start a new segment whenever the set + of supporting sources changes.", "items": {"type": "STRING"}, "title": "Chunk + Ids", "type": "ARRAY"}}, "property_ordering": ["text", "chunk_ids"], "required": + ["text", "chunk_ids"], "title": "CitedSegment", "type": "OBJECT"}, "title": + "Segments", "type": "ARRAY"}}, "required": ["segments"], "title": "SegmentedAnswer", + "type": "OBJECT"}}}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '1411' + content-type: + - application/json + host: + - generativelanguage.googleapis.com + x-goog-api-client: + - google-genai-sdk/2.16.0 gl-python/3.12.13 + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse + response: + body: + string: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": + {\"name\": \"search_documents\",\"args\": {\"query\": \"Flurbo stream responses\"},\"id\": + \"n56fhzfx\"},\"thoughtSignature\": \"EvIBCu8BARFNMg+WK5tTXsm0K6U0CY9Ein4gqQOHHXItXYNpYDpwNUQukJI6GdSPaQCBRy+6UbKwXPIskXRsexnv2Kw7tJyiwhgTQRpwFMF/KYspb9cbegyATmDGv9Yx+JNfX9NqmLuCGJwPufe1Y3JlgoJaq4DZje0eWO2ALOGuBiAX+7Q9g10fAy7Sk59eNBDFDdnlr4pqZ4XkelNqwQfGqW75JAzmKDAQc/0pw6mhiAsI0Q2F6ByZb8Pmdj2+Njy4/N5S9HejmUyqjViDFYFd8BxBv5SUz/e0c5M7sm9z2sgY87LrqJQg+ZOGqEKidd4sr/M=\"}],\"role\": + \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 112,\"candidatesTokenCount\": + 19,\"totalTokenCount\": 171,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": + 112}],\"thoughtsTokenCount\": 40,\"serviceTier\": \"standard\"},\"modelVersion\": + \"gemini-3.5-flash\",\"responseId\": \"Ratrav3uKpOW_uMPnMOKuQ8\"}\r\n\r\ndata: + {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\"}],\"role\": \"model\"},\"finishReason\": + \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 112,\"candidatesTokenCount\": + 19,\"totalTokenCount\": 171,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": + 112}],\"thoughtsTokenCount\": 40,\"serviceTier\": \"standard\"},\"modelVersion\": + \"gemini-3.5-flash\",\"responseId\": \"Ratrav3uKpOW_uMPnMOKuQ8\"}\r\n\r\n" + headers: + alt-svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + content-disposition: + - attachment + content-type: + - text/event-stream + date: + - Thu, 30 Jul 2026 19:51:34 GMT + server: + - scaffolding on HTTPServer2 + server-timing: + - gfet4t7; dur=1055 + transfer-encoding: + - chunked + vary: + - Origin + - X-Origin + - Referer + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-xss-protection: + - '0' + status: + code: 200 + message: OK +- request: + body: '{"contents": [{"parts": [{"text": "How does Flurbo stream responses?"}], + "role": "user"}, {"parts": [{"functionCall": {"id": "n56fhzfx", "args": {"query": + "Flurbo stream responses"}, "name": "search_documents"}, "thoughtSignature": + "EvIBCu8BARFNMg-WK5tTXsm0K6U0CY9Ein4gqQOHHXItXYNpYDpwNUQukJI6GdSPaQCBRy-6UbKwXPIskXRsexnv2Kw7tJyiwhgTQRpwFMF_KYspb9cbegyATmDGv9Yx-JNfX9NqmLuCGJwPufe1Y3JlgoJaq4DZje0eWO2ALOGuBiAX-7Q9g10fAy7Sk59eNBDFDdnlr4pqZ4XkelNqwQfGqW75JAzmKDAQc_0pw6mhiAsI0Q2F6ByZb8Pmdj2-Njy4_N5S9HejmUyqjViDFYFd8BxBv5SUz_e0c5M7sm9z2sgY87LrqJQg-ZOGqEKidd4sr_M="}], + "role": "model"}, {"parts": [{"functionResponse": {"id": "n56fhzfx", "name": + "search_documents", "response": {"result": "{\"results\":[{\"chunk_id\":\"c1\",\"source\":\"kb://flurbo/streaming\",\"title\":\"Flurbo + > Streaming\",\"text\":\"Flurbo streams responses via the flb.stream() generator, + which yields FlurboChunk objects.\"},{\"chunk_id\":\"c2\",\"source\":\"kb://flurbo/intro\",\"title\":\"Flurbo + > Introduction\",\"text\":\"The Flurbo framework was created in 2019 by Ada + Quist. Its default port is 7113.\"}]}"}}}], "role": "user"}], "tools": [{"functionDeclarations": + [{"description": "Search the registered document store for passages relevant + to a query.\nUse this whenever the user''s question could be answered from the + store''s\ndocuments. Ground your answer in the returned results.\n\nSearch the + document store.\n\nParameters\n----------\nquery\n What to look for, phrased + as a focused search query.", "name": "search_documents", "parameters": {"properties": + {"query": {"type": "STRING"}}, "required": ["query"], "type": "OBJECT"}}]}], + "generationConfig": {"responseMimeType": "application/json", "responseSchema": + {"properties": {"segments": {"items": {"properties": {"text": {"description": + "A span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.", "title": "Text", "type": "STRING"}, + "chunk_ids": {"description": "chunk_id values of the search results that directly + support this span. Empty list if none. Start a new segment whenever the set + of supporting sources changes.", "items": {"type": "STRING"}, "title": "Chunk + Ids", "type": "ARRAY"}}, "property_ordering": ["text", "chunk_ids"], "required": + ["text", "chunk_ids"], "title": "CitedSegment", "type": "OBJECT"}, "title": + "Segments", "type": "ARRAY"}}, "required": ["segments"], "title": "SegmentedAnswer", + "type": "OBJECT"}}}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '2428' + content-type: + - application/json + host: + - generativelanguage.googleapis.com + x-goog-api-client: + - google-genai-sdk/2.16.0 gl-python/3.12.13 + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse + response: + body: + string: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": + {\"name\": \"search_documents\",\"args\": {\"query\": \"flb.stream\"},\"id\": + \"pwt29kxn\"},\"thoughtSignature\": \"ErAECq0EARFNMg+F4DhKyvleshYVgRQyA98OJ10Ku0qPtSUEgncRe+ZeHOE+hU/1/71avsdofWj3GlEqA2/+bxHNfVytmitcGjkw/5Bonf/21ScAS0huQe3P3noeezCd8HD0p+9ZC9aHTnIxpbuTD5FyVJiuh1y5DEYdgNXI3qufaxveGwaQBZAKbIGmtfcq7rRP86dLSBIqFGFy2d7i78koNjGspc9LXLvlZCBNVibHkhLrgVZByY5LapQf4eg4FkbshmSvFIVrlM778bS6XxbdFoXL2x+4G9GghD9q7evT+3QnJlV9qEejdkPIDasLya1Q8JxAMaK9FedXcPgbARFIHoz0ZpVd+aBQdirMD/Y8D851HMkRpw8cmydCxU+ChxwLq9W5FsSZmRvQ68r7s+D+CBqu6b1KzY7f0S4vvmag1Z2OZr7JSKbsKwUaZrTW6K36LP/eSsb5UnGedUvKIsbc4dzu/+z7e8A9l/+XrKwt4QRNnOzwHfNPXztFe3ZWBpznnv7RQi+jpRi2sh4CrRLF9wDSjnInjSMYqCP6l8RO8u7P1asXts2k5UKaFDd6M7PXeH+MjGK2Q2C6G4J0s8i/BLWpl+xSbnC0/NHo8jfhvFrY7gsotb3xT64bdzlNu0UBsT8PcytRDohV/o3iAitT6bp+aG5K+aCBsnJhGa2MDanhTc1Ltzr7yw1PO4bSUEyqKFvG5vZtABpwsOr4SO8uKuYqI5zF1KYBrFSEm/n8QXo=\"}],\"role\": + \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 246,\"candidatesTokenCount\": + 19,\"totalTokenCount\": 388,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": + 246}],\"thoughtsTokenCount\": 123,\"serviceTier\": \"standard\"},\"modelVersion\": + \"gemini-3.5-flash\",\"responseId\": \"Rqtras-UMLil_uMPzPGWiQs\"}\r\n\r\ndata: + {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\"}],\"role\": \"model\"},\"finishReason\": + \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 286,\"candidatesTokenCount\": + 19,\"totalTokenCount\": 428,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": + 286}],\"thoughtsTokenCount\": 123,\"serviceTier\": \"standard\"},\"modelVersion\": + \"gemini-3.5-flash\",\"responseId\": \"Rqtras-UMLil_uMPzPGWiQs\"}\r\n\r\n" + headers: + alt-svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + content-disposition: + - attachment + content-type: + - text/event-stream + date: + - Thu, 30 Jul 2026 19:51:36 GMT + server: + - scaffolding on HTTPServer2 + server-timing: + - gfet4t7; dur=1332 + transfer-encoding: + - chunked + vary: + - Origin + - X-Origin + - Referer + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-xss-protection: + - '0' + status: + code: 200 + message: OK +- request: + body: '{"contents": [{"parts": [{"text": "How does Flurbo stream responses?"}], + "role": "user"}, {"parts": [{"functionCall": {"id": "n56fhzfx", "args": {"query": + "Flurbo stream responses"}, "name": "search_documents"}, "thoughtSignature": + "EvIBCu8BARFNMg-WK5tTXsm0K6U0CY9Ein4gqQOHHXItXYNpYDpwNUQukJI6GdSPaQCBRy-6UbKwXPIskXRsexnv2Kw7tJyiwhgTQRpwFMF_KYspb9cbegyATmDGv9Yx-JNfX9NqmLuCGJwPufe1Y3JlgoJaq4DZje0eWO2ALOGuBiAX-7Q9g10fAy7Sk59eNBDFDdnlr4pqZ4XkelNqwQfGqW75JAzmKDAQc_0pw6mhiAsI0Q2F6ByZb8Pmdj2-Njy4_N5S9HejmUyqjViDFYFd8BxBv5SUz_e0c5M7sm9z2sgY87LrqJQg-ZOGqEKidd4sr_M="}], + "role": "model"}, {"parts": [{"functionResponse": {"id": "n56fhzfx", "name": + "search_documents", "response": {"result": "{\"results\":[{\"chunk_id\":\"c1\",\"source\":\"kb://flurbo/streaming\",\"title\":\"Flurbo + > Streaming\",\"text\":\"Flurbo streams responses via the flb.stream() generator, + which yields FlurboChunk objects.\"},{\"chunk_id\":\"c2\",\"source\":\"kb://flurbo/intro\",\"title\":\"Flurbo + > Introduction\",\"text\":\"The Flurbo framework was created in 2019 by Ada + Quist. Its default port is 7113.\"}]}"}}}], "role": "user"}, {"parts": [{"functionCall": + {"id": "pwt29kxn", "args": {"query": "flb.stream"}, "name": "search_documents"}, + "thoughtSignature": "ErAECq0EARFNMg-F4DhKyvleshYVgRQyA98OJ10Ku0qPtSUEgncRe-ZeHOE-hU_1_71avsdofWj3GlEqA2_-bxHNfVytmitcGjkw_5Bonf_21ScAS0huQe3P3noeezCd8HD0p-9ZC9aHTnIxpbuTD5FyVJiuh1y5DEYdgNXI3qufaxveGwaQBZAKbIGmtfcq7rRP86dLSBIqFGFy2d7i78koNjGspc9LXLvlZCBNVibHkhLrgVZByY5LapQf4eg4FkbshmSvFIVrlM778bS6XxbdFoXL2x-4G9GghD9q7evT-3QnJlV9qEejdkPIDasLya1Q8JxAMaK9FedXcPgbARFIHoz0ZpVd-aBQdirMD_Y8D851HMkRpw8cmydCxU-ChxwLq9W5FsSZmRvQ68r7s-D-CBqu6b1KzY7f0S4vvmag1Z2OZr7JSKbsKwUaZrTW6K36LP_eSsb5UnGedUvKIsbc4dzu_-z7e8A9l_-XrKwt4QRNnOzwHfNPXztFe3ZWBpznnv7RQi-jpRi2sh4CrRLF9wDSjnInjSMYqCP6l8RO8u7P1asXts2k5UKaFDd6M7PXeH-MjGK2Q2C6G4J0s8i_BLWpl-xSbnC0_NHo8jfhvFrY7gsotb3xT64bdzlNu0UBsT8PcytRDohV_o3iAitT6bp-aG5K-aCBsnJhGa2MDanhTc1Ltzr7yw1PO4bSUEyqKFvG5vZtABpwsOr4SO8uKuYqI5zF1KYBrFSEm_n8QXo="}], + "role": "model"}, {"parts": [{"functionResponse": {"id": "pwt29kxn", "name": + "search_documents", "response": {"result": "{\"results\":[{\"chunk_id\":\"c3\",\"source\":\"kb://flurbo/intro\",\"title\":\"Flurbo + > Introduction\",\"text\":\"The Flurbo framework was created in 2019 by Ada + Quist. Its default port is 7113.\"},{\"chunk_id\":\"c4\",\"source\":\"kb://flurbo/streaming\",\"title\":\"Flurbo + > Streaming\",\"text\":\"Flurbo streams responses via the flb.stream() generator, + which yields FlurboChunk objects.\"}]}"}}}], "role": "user"}], "tools": [{"functionDeclarations": + [{"description": "Search the registered document store for passages relevant + to a query.\nUse this whenever the user''s question could be answered from the + store''s\ndocuments. Ground your answer in the returned results.\n\nSearch the + document store.\n\nParameters\n----------\nquery\n What to look for, phrased + as a focused search query.", "name": "search_documents", "parameters": {"properties": + {"query": {"type": "STRING"}}, "required": ["query"], "type": "OBJECT"}}]}], + "generationConfig": {"responseMimeType": "application/json", "responseSchema": + {"properties": {"segments": {"items": {"properties": {"text": {"description": + "A span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.", "title": "Text", "type": "STRING"}, + "chunk_ids": {"description": "chunk_id values of the search results that directly + support this span. Empty list if none. Start a new segment whenever the set + of supporting sources changes.", "items": {"type": "STRING"}, "title": "Chunk + Ids", "type": "ARRAY"}}, "property_ordering": ["text", "chunk_ids"], "required": + ["text", "chunk_ids"], "title": "CitedSegment", "type": "OBJECT"}, "title": + "Segments", "type": "ARRAY"}}, "required": ["segments"], "title": "SegmentedAnswer", + "type": "OBJECT"}}}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '3856' + content-type: + - application/json + host: + - generativelanguage.googleapis.com + x-goog-api-client: + - google-genai-sdk/2.16.0 gl-python/3.12.13 + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse + response: + body: + string: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"{\\n + \ \\\"segments\\\":\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": + {\"promptTokenCount\": 380,\"candidatesTokenCount\": 20,\"totalTokenCount\": + 549,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 380}],\"thoughtsTokenCount\": + 149,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": + \"SKtrap3QCNad_uMPoPmrmQE\"}\r\n\r\ndata: {\"candidates\": [{\"content\": + {\"parts\": [{\"text\": \" [\\n {\\n \\\"text\\\": \\\"Flurbo streams + responses via the `flb.stream()` generator, which yields `FlurboChunk` objects.\\\",\\n + \ \\\"chunk_ids\\\": [\\n \\\"c1\\\"\\n ]\\n }\\n ]\\n}\"}],\"role\": + \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 380,\"candidatesTokenCount\": + 40,\"totalTokenCount\": 569,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": + 380}],\"thoughtsTokenCount\": 149,\"serviceTier\": \"standard\"},\"modelVersion\": + \"gemini-3.5-flash\",\"responseId\": \"SKtrap3QCNad_uMPoPmrmQE\"}\r\n\r\ndata: + {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": + \"EpkFCpYFARFNMg+ENbmgUprThQmVFhxoBfG7IlYZh5U4wSfK6CiNWZ9gWRgETqbKX1UCqLO+eeG4Nd6v8dwb15xhfvXqIFKzPgHkmRNZrDRGhwZvxERDMNtgcLPWYOoLe5d0ldu4MqCwPPb/DSj0FouYpVGpEkKrn1cCG9Lqn5HNwg2m0jxgi3SV33Y/Bvc1Irbt4nGQX2a8eng2tSLMZxKawQhysJuyG1LI03K7tSVfZ3AD02d18JMAou0HyJlcMc70/0fxzvEBGS7fZcc26udSXjgBuLp0CmJAQtGG3Chhr4a8VO3yDqZjTePah0IV/3QlyIe04oLZ4o+SI6J/uHYJME8JzIhW//53w6kSJsP7QSCwvIoFMDKAM40tK+H2hEG1z+fDKobWt/0M5x1Y6nxvo6rfvLnudv+FfUwKclVzGkW276Ljqe1W+c2nsAhf03Gm99Xs2n7cAD07H02yXSOImvQqxoS5sDntJWDn1TKWpob8bC6Zup1ehGLCVWM+N7L511oGj4ewBlE1FleB6VNbardnTorZKLt+9FoRvwIdW97jybCAfIbMD7eJjFcDPy84PVyr33+CNKNx9wjCC/BiCRQ6HMJo72JgIKB7U8kalH/6beomqNCXSfZuxOELTZZs0Mr4NLmkyc+veqmWpbiIH8XhZgpzBHrOnixSPZVgzSCV3YbFVuxk/QGz+OwvacVxBs4sQUa9eRRMTeLNmcpgGYNp3WW+k16Vmr6ph9fw9qtaaiOoDResoi4Iw+ztXR1B/MuM8Q/18oVT/ijvYns4sSKER0aiK6oFIGrtaIWPrt4wzYvsZ9U+hgl2n7dpz55VZEAPH28Nrn1k6HVxuoZzOVeB3gUAWLSGg4AadSCmy/5mJlcwzCamsmA=\"}],\"role\": + \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": + 543,\"candidatesTokenCount\": 40,\"totalTokenCount\": 732,\"promptTokensDetails\": + [{\"modality\": \"TEXT\",\"tokenCount\": 543}],\"thoughtsTokenCount\": 149,\"serviceTier\": + \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"SKtrap3QCNad_uMPoPmrmQE\"}\r\n\r\n" + headers: + alt-svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + content-disposition: + - attachment + content-type: + - text/event-stream + date: + - Thu, 30 Jul 2026 19:51:37 GMT + server: + - scaffolding on HTTPServer2 + server-timing: + - gfet4t7; dur=1401 + transfer-encoding: + - chunked + vary: + - Origin + - X-Origin + - Referer + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-xss-protection: + - '0' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/_vcr/test_provider_google_rag/test_google_rag_tool_mode_citations.yaml b/tests/_vcr/test_provider_google_rag/test_google_rag_tool_mode_citations.yaml new file mode 100644 index 00000000..ab45ceb6 --- /dev/null +++ b/tests/_vcr/test_provider_google_rag/test_google_rag_tool_mode_citations.yaml @@ -0,0 +1,279 @@ +interactions: +- request: + body: '{"contents": [{"parts": [{"text": "How does Flurbo stream responses?"}], + "role": "user"}], "tools": [{"functionDeclarations": [{"description": "Search + the registered document store for passages relevant to a query.\nUse this whenever + the user''s question could be answered from the store''s\ndocuments. Ground + your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.", "name": "search_documents", + "parameters": {"properties": {"query": {"type": "STRING"}}, "required": ["query"], + "type": "OBJECT"}}]}], "generationConfig": {"responseMimeType": "application/json", + "responseSchema": {"properties": {"segments": {"items": {"properties": {"text": + {"description": "A span of the answer, in plain prose. Concatenating every segment''s + text in order must produce the complete answer.", "title": "Text", "type": "STRING"}, + "chunk_ids": {"description": "chunk_id values of the search results that directly + support this span. Empty list if none. Start a new segment whenever the set + of supporting sources changes.", "items": {"type": "STRING"}, "title": "Chunk + Ids", "type": "ARRAY"}}, "property_ordering": ["text", "chunk_ids"], "required": + ["text", "chunk_ids"], "title": "CitedSegment", "type": "OBJECT"}, "title": + "Segments", "type": "ARRAY"}}, "required": ["segments"], "title": "SegmentedAnswer", + "type": "OBJECT"}}}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '1411' + content-type: + - application/json + host: + - generativelanguage.googleapis.com + x-goog-api-client: + - google-genai-sdk/2.16.0 gl-python/3.12.13 + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse + response: + body: + string: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": + {\"name\": \"search_documents\",\"args\": {\"query\": \"Flurbo stream responses\"},\"id\": + \"zon2o2dd\"},\"thoughtSignature\": \"Et8BCtwBARFNMg/htzouZLLWwy0fjfnqtsizlf62v4vq6NY9thvFep/8Vjs7UBIuU0K2MdcPuAUPvx7wjLqgDRTkFbLQ3SrJwl6wtjm9U85PdhbjdS6IdruPs1641FXoKSKCVLf8zFm8AXBZEnCV0UvNvRncrEeF3pvVv43ZE4FAM5ikQiVzjnFBVfOWX8zJCY38t2o8XlbKHwpEBvoDCNuCSpidpuGn2WUxppt9xzlSO6CRWo9L2zsR7CxcA1mbrlSC9fkTvQxMq/KB9DTWZxqwlmTheZkoPDqrzRH83qPheg==\"}],\"role\": + \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 112,\"candidatesTokenCount\": + 19,\"totalTokenCount\": 169,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": + 112}],\"thoughtsTokenCount\": 38,\"serviceTier\": \"standard\"},\"modelVersion\": + \"gemini-3.5-flash\",\"responseId\": \"QKtrarXIKfnL-8YPi6bTQQ\"}\r\n\r\ndata: + {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\"}],\"role\": \"model\"},\"finishReason\": + \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 112,\"candidatesTokenCount\": + 19,\"totalTokenCount\": 169,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": + 112}],\"thoughtsTokenCount\": 38,\"serviceTier\": \"standard\"},\"modelVersion\": + \"gemini-3.5-flash\",\"responseId\": \"QKtrarXIKfnL-8YPi6bTQQ\"}\r\n\r\n" + headers: + alt-svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + content-disposition: + - attachment + content-type: + - text/event-stream + date: + - Thu, 30 Jul 2026 19:51:29 GMT + server: + - scaffolding on HTTPServer2 + server-timing: + - gfet4t7; dur=1029 + transfer-encoding: + - chunked + vary: + - Origin + - X-Origin + - Referer + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-xss-protection: + - '0' + status: + code: 200 + message: OK +- request: + body: '{"contents": [{"parts": [{"text": "How does Flurbo stream responses?"}], + "role": "user"}, {"parts": [{"functionCall": {"id": "zon2o2dd", "args": {"query": + "Flurbo stream responses"}, "name": "search_documents"}, "thoughtSignature": + "Et8BCtwBARFNMg_htzouZLLWwy0fjfnqtsizlf62v4vq6NY9thvFep_8Vjs7UBIuU0K2MdcPuAUPvx7wjLqgDRTkFbLQ3SrJwl6wtjm9U85PdhbjdS6IdruPs1641FXoKSKCVLf8zFm8AXBZEnCV0UvNvRncrEeF3pvVv43ZE4FAM5ikQiVzjnFBVfOWX8zJCY38t2o8XlbKHwpEBvoDCNuCSpidpuGn2WUxppt9xzlSO6CRWo9L2zsR7CxcA1mbrlSC9fkTvQxMq_KB9DTWZxqwlmTheZkoPDqrzRH83qPheg=="}], + "role": "model"}, {"parts": [{"functionResponse": {"id": "zon2o2dd", "name": + "search_documents", "response": {"result": "{\"results\":[{\"chunk_id\":\"c1\",\"source\":\"kb://flurbo/streaming\",\"title\":\"Flurbo + > Streaming\",\"text\":\"Flurbo streams responses via the flb.stream() generator, + which yields FlurboChunk objects.\"},{\"chunk_id\":\"c2\",\"source\":\"kb://flurbo/intro\",\"title\":\"Flurbo + > Introduction\",\"text\":\"The Flurbo framework was created in 2019 by Ada + Quist. Its default port is 7113.\"}]}"}}}], "role": "user"}], "tools": [{"functionDeclarations": + [{"description": "Search the registered document store for passages relevant + to a query.\nUse this whenever the user''s question could be answered from the + store''s\ndocuments. Ground your answer in the returned results.\n\nSearch the + document store.\n\nParameters\n----------\nquery\n What to look for, phrased + as a focused search query.", "name": "search_documents", "parameters": {"properties": + {"query": {"type": "STRING"}}, "required": ["query"], "type": "OBJECT"}}]}], + "generationConfig": {"responseMimeType": "application/json", "responseSchema": + {"properties": {"segments": {"items": {"properties": {"text": {"description": + "A span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.", "title": "Text", "type": "STRING"}, + "chunk_ids": {"description": "chunk_id values of the search results that directly + support this span. Empty list if none. Start a new segment whenever the set + of supporting sources changes.", "items": {"type": "STRING"}, "title": "Chunk + Ids", "type": "ARRAY"}}, "property_ordering": ["text", "chunk_ids"], "required": + ["text", "chunk_ids"], "title": "CitedSegment", "type": "OBJECT"}, "title": + "Segments", "type": "ARRAY"}}, "required": ["segments"], "title": "SegmentedAnswer", + "type": "OBJECT"}}}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '2404' + content-type: + - application/json + host: + - generativelanguage.googleapis.com + x-goog-api-client: + - google-genai-sdk/2.16.0 gl-python/3.12.13 + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse + response: + body: + string: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": + {\"name\": \"search_documents\",\"args\": {\"query\": \"flb.stream FlurboChunk\"},\"id\": + \"y8u70n1a\"},\"thoughtSignature\": \"ErADCq0DARFNMg/PaUkHrjKkiNTr02Ybwxi/g0aC7uTIHtI5UBI4YcljPBCzzzFmm1QBDpzkEwnBSxVsZwZM7D9J7mFT7Z3N4g0rJ2EJGv7VL5rkkcTMjOfk4MPv7Jgbw8diZqQIJkQLU8BtGqIV4gEtLE9RNkWCsXupRpCCPhICA8rmj4UVeVeWH/Vo6A6QYG2SMDUlsRtYVgk17+z1h437faaCkYPs4QkGTszS2NfxJooM4v5c+Q90tdMmrfFrO5JnozMVLl4+KQCtJcd6ApSQN/5lIrOhzWePHfqDZTDbDUeW5CaCTHJurWugcd1HmLmZCNh+OKgZMlwuS9BZcuC0VlhoJRYq4gFHB1PI+5GLHt3bQHhGEQN1iLCHZKtDrr2CbpYKVZ32PRQF2CEtQTDjEVp5u5upLxhk8aE6Wvgh/ueakfH9eLDF7y3BeifBLfgIErlYaD0jtCLSgY45dK9vyUupbKcKlLw9HQtT9DaWv1rbGlCTQwHC4/R6sPwnoDjJl7zc2IE7Xzdf0yYA1SnKpLOtY7R5h5yLYviOblE8ZTPquqjWKg3cUhaEGkrTBXHO\"}],\"role\": + \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 246,\"candidatesTokenCount\": + 22,\"totalTokenCount\": 355,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": + 246}],\"thoughtsTokenCount\": 87,\"serviceTier\": \"standard\"},\"modelVersion\": + \"gemini-3.5-flash\",\"responseId\": \"QatravDEK_y9jrEPsOLL8Q8\"}\r\n\r\ndata: + {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\"}],\"role\": \"model\"},\"finishReason\": + \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 284,\"candidatesTokenCount\": + 22,\"totalTokenCount\": 393,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": + 284}],\"thoughtsTokenCount\": 87,\"serviceTier\": \"standard\"},\"modelVersion\": + \"gemini-3.5-flash\",\"responseId\": \"QatravDEK_y9jrEPsOLL8Q8\"}\r\n\r\n" + headers: + alt-svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + content-disposition: + - attachment + content-type: + - text/event-stream + date: + - Thu, 30 Jul 2026 19:51:31 GMT + server: + - scaffolding on HTTPServer2 + server-timing: + - gfet4t7; dur=1759 + transfer-encoding: + - chunked + vary: + - Origin + - X-Origin + - Referer + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-xss-protection: + - '0' + status: + code: 200 + message: OK +- request: + body: '{"contents": [{"parts": [{"text": "How does Flurbo stream responses?"}], + "role": "user"}, {"parts": [{"functionCall": {"id": "zon2o2dd", "args": {"query": + "Flurbo stream responses"}, "name": "search_documents"}, "thoughtSignature": + "Et8BCtwBARFNMg_htzouZLLWwy0fjfnqtsizlf62v4vq6NY9thvFep_8Vjs7UBIuU0K2MdcPuAUPvx7wjLqgDRTkFbLQ3SrJwl6wtjm9U85PdhbjdS6IdruPs1641FXoKSKCVLf8zFm8AXBZEnCV0UvNvRncrEeF3pvVv43ZE4FAM5ikQiVzjnFBVfOWX8zJCY38t2o8XlbKHwpEBvoDCNuCSpidpuGn2WUxppt9xzlSO6CRWo9L2zsR7CxcA1mbrlSC9fkTvQxMq_KB9DTWZxqwlmTheZkoPDqrzRH83qPheg=="}], + "role": "model"}, {"parts": [{"functionResponse": {"id": "zon2o2dd", "name": + "search_documents", "response": {"result": "{\"results\":[{\"chunk_id\":\"c1\",\"source\":\"kb://flurbo/streaming\",\"title\":\"Flurbo + > Streaming\",\"text\":\"Flurbo streams responses via the flb.stream() generator, + which yields FlurboChunk objects.\"},{\"chunk_id\":\"c2\",\"source\":\"kb://flurbo/intro\",\"title\":\"Flurbo + > Introduction\",\"text\":\"The Flurbo framework was created in 2019 by Ada + Quist. Its default port is 7113.\"}]}"}}}], "role": "user"}, {"parts": [{"functionCall": + {"id": "y8u70n1a", "args": {"query": "flb.stream FlurboChunk"}, "name": "search_documents"}, + "thoughtSignature": "ErADCq0DARFNMg_PaUkHrjKkiNTr02Ybwxi_g0aC7uTIHtI5UBI4YcljPBCzzzFmm1QBDpzkEwnBSxVsZwZM7D9J7mFT7Z3N4g0rJ2EJGv7VL5rkkcTMjOfk4MPv7Jgbw8diZqQIJkQLU8BtGqIV4gEtLE9RNkWCsXupRpCCPhICA8rmj4UVeVeWH_Vo6A6QYG2SMDUlsRtYVgk17-z1h437faaCkYPs4QkGTszS2NfxJooM4v5c-Q90tdMmrfFrO5JnozMVLl4-KQCtJcd6ApSQN_5lIrOhzWePHfqDZTDbDUeW5CaCTHJurWugcd1HmLmZCNh-OKgZMlwuS9BZcuC0VlhoJRYq4gFHB1PI-5GLHt3bQHhGEQN1iLCHZKtDrr2CbpYKVZ32PRQF2CEtQTDjEVp5u5upLxhk8aE6Wvgh_ueakfH9eLDF7y3BeifBLfgIErlYaD0jtCLSgY45dK9vyUupbKcKlLw9HQtT9DaWv1rbGlCTQwHC4_R6sPwnoDjJl7zc2IE7Xzdf0yYA1SnKpLOtY7R5h5yLYviOblE8ZTPquqjWKg3cUhaEGkrTBXHO"}], + "role": "model"}, {"parts": [{"functionResponse": {"id": "y8u70n1a", "name": + "search_documents", "response": {"result": "{\"results\":[{\"chunk_id\":\"c3\",\"source\":\"kb://flurbo/streaming\",\"title\":\"Flurbo + > Streaming\",\"text\":\"Flurbo streams responses via the flb.stream() generator, + which yields FlurboChunk objects.\"},{\"chunk_id\":\"c4\",\"source\":\"kb://flurbo/intro\",\"title\":\"Flurbo + > Introduction\",\"text\":\"The Flurbo framework was created in 2019 by Ada + Quist. Its default port is 7113.\"}]}"}}}], "role": "user"}], "tools": [{"functionDeclarations": + [{"description": "Search the registered document store for passages relevant + to a query.\nUse this whenever the user''s question could be answered from the + store''s\ndocuments. Ground your answer in the returned results.\n\nSearch the + document store.\n\nParameters\n----------\nquery\n What to look for, phrased + as a focused search query.", "name": "search_documents", "parameters": {"properties": + {"query": {"type": "STRING"}}, "required": ["query"], "type": "OBJECT"}}]}], + "generationConfig": {"responseMimeType": "application/json", "responseSchema": + {"properties": {"segments": {"items": {"properties": {"text": {"description": + "A span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.", "title": "Text", "type": "STRING"}, + "chunk_ids": {"description": "chunk_id values of the search results that directly + support this span. Empty list if none. Start a new segment whenever the set + of supporting sources changes.", "items": {"type": "STRING"}, "title": "Chunk + Ids", "type": "ARRAY"}}, "property_ordering": ["text", "chunk_ids"], "required": + ["text", "chunk_ids"], "title": "CitedSegment", "type": "OBJECT"}, "title": + "Segments", "type": "ARRAY"}}, "required": ["segments"], "title": "SegmentedAnswer", + "type": "OBJECT"}}}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '3672' + content-type: + - application/json + host: + - generativelanguage.googleapis.com + x-goog-api-client: + - google-genai-sdk/2.16.0 gl-python/3.12.13 + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse + response: + body: + string: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"{\\n + \ \\\"segments\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": + {\"promptTokenCount\": 383,\"candidatesTokenCount\": 5,\"totalTokenCount\": + 556,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 383}],\"thoughtsTokenCount\": + 168,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": + \"Q6traunsIMKHjrEP-t7osQk\"}\r\n\r\ndata: {\"candidates\": [{\"content\": + {\"parts\": [{\"text\": \"\\\":\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": + {\"promptTokenCount\": 383,\"candidatesTokenCount\": 34,\"totalTokenCount\": + 585,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 383}],\"thoughtsTokenCount\": + 168,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": + \"Q6traunsIMKHjrEP-t7osQk\"}\r\n\r\ndata: {\"candidates\": [{\"content\": + {\"parts\": [{\"text\": \" [\\n {\\n \\\"text\\\": \\\"Flurbo streams + responses via the `flb.stream()` generator, which yields `FlurboChunk` objects.\\\",\\n + \ \\\"chunk_ids\\\": [\\n \\\"c1\\\"\\n ]\\n \"}],\"role\": + \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 383,\"candidatesTokenCount\": + 58,\"totalTokenCount\": 609,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": + 383}],\"thoughtsTokenCount\": 168,\"serviceTier\": \"standard\"},\"modelVersion\": + \"gemini-3.5-flash\",\"responseId\": \"Q6traunsIMKHjrEP-t7osQk\"}\r\n\r\ndata: + {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"}\\n ]\\n}\"}],\"role\": + \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 383,\"candidatesTokenCount\": + 64,\"totalTokenCount\": 615,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": + 383}],\"thoughtsTokenCount\": 168,\"serviceTier\": \"standard\"},\"modelVersion\": + \"gemini-3.5-flash\",\"responseId\": \"Q6traunsIMKHjrEP-t7osQk\"}\r\n\r\ndata: + {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": + \"Eo0GCooGARFNMg8BJ3Uq614f1opFBHegidbw0JwNziBeJP8YBJXiHvGXPAJc0AxDOdQWNWJI+Q4Xv1kDc6XQNZZEZ5GQQZn6wOvPxUxQN9ILu19D8Yn2BKKB+cSNCtwRG2EZ7n50+ZeFS46TGj2XhLrhUE73ZzQwQGdLQkvnX1gevYxzMehz+fEZ5ycl/CbRqdyAEp2KKkGXfJNTXlwhKWOgublkL2asllQbgPRtF1mb572mVh6+ZlaQY921DPmm/77MBK60Pr/3CNeGT4wT/LV85XjdDu9fHZuMFc7FMP0VnBh7xUA1ATbcwqma5wsAYNGFHokzPvryUa1C3POTN530cXkbjIQFZZrM6MhqfAgvZeT2OYM387Prie6oCeXunpQvnOOXkuu2n+7HZpcVyl4cCxf5HsLD8YA11bCCHiwnkrVkyq3De+YMu6uXe+uV6Ij/k4s173HclbWQIrnUX1xKgvyAw13i4e9rPFWU6djbbpZjurAT1uWoct6uLBbyqYKDSI+NAASsoh6GGNcJHUpwd8ee9MAx8et2jheB7qCsn3ybNvR0Bc91aoxZsWPcjefJMxiwDvUq8nEOsj5M3Ilz2Z7Q18ZrkL4CmMDXt7nwJs53iRN0Yslaj/GnuOY0wnxJMXSOqn1sVtsot0BFn/YkrDtJbRX68c40pxYVWlwEHI5g4MHdTCK2KiLGYDl9DH0+nF63CZejwG7TynRO5CO8/4tRAiF/o2iZt+5TUoNlFGnqqSLZRxAH9+mqABUYLoUBuH1eCJKj44n67hK85B8givJJPqTdg/ZEwXAUgnRcd4nuQ+tdQiJga8gUR/KnDZyEeUWMyEIexY9MthWIAqnmrS8sk5n8m1qjG42UJJfX86UiSVZ+rCcAzrM7gNJDHw/3gr32bsAdDmw6u4aO74v0sbGkQwsHWr5xCC3EOrbfkIijkwmrwPRtGszhhNSBBcSOzF9AuWdNfqWxvmTrV/LljzVlf4isVFMMdw0uj8XuAO/aXU2P5gJl84Ou2A2T/7FpiEMmpX5qHWXohbXWGg==\"}],\"role\": + \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": + 508,\"candidatesTokenCount\": 64,\"totalTokenCount\": 740,\"promptTokensDetails\": + [{\"modality\": \"TEXT\",\"tokenCount\": 508}],\"thoughtsTokenCount\": 168,\"serviceTier\": + \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"Q6traunsIMKHjrEP-t7osQk\"}\r\n\r\n" + headers: + alt-svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + content-disposition: + - attachment + content-type: + - text/event-stream + date: + - Thu, 30 Jul 2026 19:51:33 GMT + server: + - scaffolding on HTTPServer2 + server-timing: + - gfet4t7; dur=1669 + transfer-encoding: + - chunked + vary: + - Origin + - X-Origin + - Referer + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-xss-protection: + - '0' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/_vcr/test_provider_openai_rag/test_openai_rag_streaming_yields_prose_not_json.yaml b/tests/_vcr/test_provider_openai_rag/test_openai_rag_streaming_yields_prose_not_json.yaml new file mode 100644 index 00000000..1770fd31 --- /dev/null +++ b/tests/_vcr/test_provider_openai_rag/test_openai_rag_streaming_yields_prose_not_json.yaml @@ -0,0 +1,566 @@ +interactions: +- request: + body: '{"include": ["reasoning.encrypted_content"], "input": [{"role": "user", + "content": [{"type": "input_text", "text": "How does Flurbo stream responses?"}]}], + "model": "gpt-5.4", "store": false, "stream": true, "text": {"format": {"type": + "json_schema", "name": "structured_data", "schema": {"$defs": {"CitedSegment": + {"properties": {"text": {"description": "A span of the answer, in plain prose. + Concatenating every segment''s text in order must produce the complete answer.", + "title": "Text", "type": "string"}, "chunk_ids": {"description": "chunk_id values + of the search results that directly support this span. Empty list if none. Start + a new segment whenever the set of supporting sources changes.", "items": {"type": + "string"}, "title": "Chunk Ids", "type": "array"}}, "required": ["text", "chunk_ids"], + "title": "CitedSegment", "type": "object", "additionalProperties": false}}, + "properties": {"segments": {"items": {"$ref": "#/$defs/CitedSegment"}, "type": + "array"}}, "required": ["segments"], "type": "object", "additionalProperties": + false}, "strict": true}}, "tools": [{"type": "function", "name": "search_documents", + "description": "Search the registered document store for passages relevant to + a query.\nUse this whenever the user''s question could be answered from the + store''s\ndocuments. Ground your answer in the returned results.\n\nSearch the + document store.\n\nParameters\n----------\nquery\n What to look for, phrased + as a focused search query.", "parameters": {"properties": {"query": {"type": + "string"}}, "required": ["query"], "type": "object", "additionalProperties": + false}, "strict": true}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '1529' + content-type: + - application/json + host: + - api.openai.com + x-stainless-async: + - 'false' + x-stainless-read-timeout: + - '600' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: 'event: response.created + + data: {"type":"response.created","response":{"id":"resp_02ea7e815b9094f1016a6bab3ed3448193bc0bb5c31e18e554","object":"response","created_at":1785441086,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"none","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"$defs":{"CitedSegment":{"properties":{"text":{"description":"A + span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.","title":"Text","type":"string"},"chunk_ids":{"description":"chunk_id + values of the search results that directly support this span. Empty list if + none. Start a new segment whenever the set of supporting sources changes.","items":{"type":"string"},"title":"Chunk + Ids","type":"array"}},"required":["text","chunk_ids"],"title":"CitedSegment","type":"object","additionalProperties":false}},"properties":{"segments":{"items":{"$ref":"#/$defs/CitedSegment"},"type":"array"}},"required":["segments"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"function","description":"Search + the registered document store for passages relevant to a query.\nUse this + whenever the user''s question could be answered from the store''s\ndocuments. + Ground your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.","name":"search_documents","output_schema":null,"parameters":{"properties":{"query":{"type":"string"}},"required":["query"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + + + event: response.in_progress + + data: {"type":"response.in_progress","response":{"id":"resp_02ea7e815b9094f1016a6bab3ed3448193bc0bb5c31e18e554","object":"response","created_at":1785441086,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"none","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"$defs":{"CitedSegment":{"properties":{"text":{"description":"A + span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.","title":"Text","type":"string"},"chunk_ids":{"description":"chunk_id + values of the search results that directly support this span. Empty list if + none. Start a new segment whenever the set of supporting sources changes.","items":{"type":"string"},"title":"Chunk + Ids","type":"array"}},"required":["text","chunk_ids"],"title":"CitedSegment","type":"object","additionalProperties":false}},"properties":{"segments":{"items":{"$ref":"#/$defs/CitedSegment"},"type":"array"}},"required":["segments"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"function","description":"Search + the registered document store for passages relevant to a query.\nUse this + whenever the user''s question could be answered from the store''s\ndocuments. + Ground your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.","name":"search_documents","output_schema":null,"parameters":{"properties":{"query":{"type":"string"}},"required":["query"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + + + event: response.output_item.added + + data: {"type":"response.output_item.added","item":{"id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","type":"function_call","status":"in_progress","arguments":"","call_id":"call_3oIICUkd3Moez7d531Wdlja0","name":"search_documents"},"output_index":0,"sequence_number":2} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","obfuscation":"bnpbbdlSBouSrx","output_index":0,"sequence_number":3} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"query","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","obfuscation":"aGKEwbTwaCZ","output_index":0,"sequence_number":4} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"\":\"","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","obfuscation":"CO8BqZPwdi9FY","output_index":0,"sequence_number":5} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"Fl","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","obfuscation":"kp5WMOAmhBolsu","output_index":0,"sequence_number":6} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"urbo","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","obfuscation":"7AdluVr0grp9","output_index":0,"sequence_number":7} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":" stream","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","obfuscation":"K3GKzktqZ","output_index":0,"sequence_number":8} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":" responses","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","obfuscation":"4193vi","output_index":0,"sequence_number":9} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":" how","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","obfuscation":"asvl7OQIrBPg","output_index":0,"sequence_number":10} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":" does","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","obfuscation":"AxlpGHNxHit","output_index":0,"sequence_number":11} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":" Fl","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","obfuscation":"SHWryjKK9U0Be","output_index":0,"sequence_number":12} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"urbo","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","obfuscation":"1do8HeK61r59","output_index":0,"sequence_number":13} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":" stream","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","obfuscation":"QiiEnZ9kw","output_index":0,"sequence_number":14} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":" responses","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","obfuscation":"ztKXSh","output_index":0,"sequence_number":15} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"\"}","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","obfuscation":"AbyIQYxP3wdPxM","output_index":0,"sequence_number":16} + + + event: response.function_call_arguments.done + + data: {"type":"response.function_call_arguments.done","arguments":"{\"query\":\"Flurbo + stream responses how does Flurbo stream responses\"}","item_id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","output_index":0,"sequence_number":17} + + + event: response.output_item.done + + data: {"type":"response.output_item.done","item":{"id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","type":"function_call","status":"completed","arguments":"{\"query\":\"Flurbo + stream responses how does Flurbo stream responses\"}","call_id":"call_3oIICUkd3Moez7d531Wdlja0","name":"search_documents"},"output_index":0,"sequence_number":18} + + + event: response.completed + + data: {"type":"response.completed","response":{"id":"resp_02ea7e815b9094f1016a6bab3ed3448193bc0bb5c31e18e554","object":"response","created_at":1785441086,"status":"completed","background":false,"completed_at":1785441087,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[{"id":"fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6","type":"function_call","status":"completed","arguments":"{\"query\":\"Flurbo + stream responses how does Flurbo stream responses\"}","call_id":"call_3oIICUkd3Moez7d531Wdlja0","name":"search_documents"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"none","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"$defs":{"CitedSegment":{"properties":{"text":{"description":"A + span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.","title":"Text","type":"string"},"chunk_ids":{"description":"chunk_id + values of the search results that directly support this span. Empty list if + none. Start a new segment whenever the set of supporting sources changes.","items":{"type":"string"},"title":"Chunk + Ids","type":"array"}},"required":["text","chunk_ids"],"title":"CitedSegment","type":"object","additionalProperties":false}},"properties":{"segments":{"items":{"$ref":"#/$defs/CitedSegment"},"type":"array"}},"required":["segments"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"function","description":"Search + the registered document store for passages relevant to a query.\nUse this + whenever the user''s question could be answered from the store''s\ndocuments. + Ground your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.","name":"search_documents","output_schema":null,"parameters":{"properties":{"query":{"type":"string"}},"required":["query"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":252,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":27,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":279},"user":null,"metadata":{}},"sequence_number":19} + + + ' + headers: + access-control-expose-headers: + - X-Request-ID + - CF-Ray + - CF-Ray + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + connection: + - keep-alive + content-type: + - text/event-stream; charset=utf-8 + date: + - Thu, 30 Jul 2026 19:51:26 GMT + openai-processing-ms: + - '178' + openai-version: + - '2020-10-01' + server: + - cloudflare + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '39999293' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 1ms + status: + code: 200 + message: OK +- request: + body: '{"include": ["reasoning.encrypted_content"], "input": [{"role": "user", + "content": [{"type": "input_text", "text": "How does Flurbo stream responses?"}]}, + {"type": "function_call", "call_id": "fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6", + "name": "search_documents", "arguments": "{\"query\":\"Flurbo stream responses + how does Flurbo stream responses\"}"}, {"type": "function_call_output", "call_id": + "fc_02ea7e815b9094f1016a6bab3f47c08193b65bbaab6d84d7f6", "output": "{\"results\":[{\"chunk_id\":\"c1\",\"source\":\"kb://flurbo/streaming\",\"title\":\"Flurbo + > Streaming\",\"text\":\"Flurbo streams responses via the flb.stream() generator, + which yields FlurboChunk objects.\"},{\"chunk_id\":\"c2\",\"source\":\"kb://flurbo/intro\",\"title\":\"Flurbo + > Introduction\",\"text\":\"The Flurbo framework was created in 2019 by Ada + Quist. Its default port is 7113.\"}]}"}], "model": "gpt-5.4", "store": false, + "stream": true, "text": {"format": {"type": "json_schema", "name": "structured_data", + "schema": {"$defs": {"CitedSegment": {"properties": {"text": {"description": + "A span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.", "title": "Text", "type": "string"}, + "chunk_ids": {"description": "chunk_id values of the search results that directly + support this span. Empty list if none. Start a new segment whenever the set + of supporting sources changes.", "items": {"type": "string"}, "title": "Chunk + Ids", "type": "array"}}, "required": ["text", "chunk_ids"], "title": "CitedSegment", + "type": "object", "additionalProperties": false}}, "properties": {"segments": + {"items": {"$ref": "#/$defs/CitedSegment"}, "type": "array"}}, "required": ["segments"], + "type": "object", "additionalProperties": false}, "strict": true}}, "tools": + [{"type": "function", "name": "search_documents", "description": "Search the + registered document store for passages relevant to a query.\nUse this whenever + the user''s question could be answered from the store''s\ndocuments. Ground + your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.", "parameters": {"properties": + {"query": {"type": "string"}}, "required": ["query"], "type": "object", "additionalProperties": + false}, "strict": true}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '2239' + content-type: + - application/json + host: + - api.openai.com + x-stainless-async: + - 'false' + x-stainless-read-timeout: + - '600' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: 'event: response.created + + data: {"type":"response.created","response":{"id":"resp_00c966f6a040503b016a6bab3f9c7481968ad0e5110985f154","object":"response","created_at":1785441087,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"none","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"$defs":{"CitedSegment":{"properties":{"text":{"description":"A + span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.","title":"Text","type":"string"},"chunk_ids":{"description":"chunk_id + values of the search results that directly support this span. Empty list if + none. Start a new segment whenever the set of supporting sources changes.","items":{"type":"string"},"title":"Chunk + Ids","type":"array"}},"required":["text","chunk_ids"],"title":"CitedSegment","type":"object","additionalProperties":false}},"properties":{"segments":{"items":{"$ref":"#/$defs/CitedSegment"},"type":"array"}},"required":["segments"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"function","description":"Search + the registered document store for passages relevant to a query.\nUse this + whenever the user''s question could be answered from the store''s\ndocuments. + Ground your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.","name":"search_documents","output_schema":null,"parameters":{"properties":{"query":{"type":"string"}},"required":["query"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + + + event: response.in_progress + + data: {"type":"response.in_progress","response":{"id":"resp_00c966f6a040503b016a6bab3f9c7481968ad0e5110985f154","object":"response","created_at":1785441087,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"none","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"$defs":{"CitedSegment":{"properties":{"text":{"description":"A + span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.","title":"Text","type":"string"},"chunk_ids":{"description":"chunk_id + values of the search results that directly support this span. Empty list if + none. Start a new segment whenever the set of supporting sources changes.","items":{"type":"string"},"title":"Chunk + Ids","type":"array"}},"required":["text","chunk_ids"],"title":"CitedSegment","type":"object","additionalProperties":false}},"properties":{"segments":{"items":{"$ref":"#/$defs/CitedSegment"},"type":"array"}},"required":["segments"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"function","description":"Search + the registered document store for passages relevant to a query.\nUse this + whenever the user''s question could be answered from the store''s\ndocuments. + Ground your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.","name":"search_documents","output_schema":null,"parameters":{"properties":{"query":{"type":"string"}},"required":["query"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + + + event: response.output_item.added + + data: {"type":"response.output_item.added","item":{"id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","type":"message","status":"in_progress","content":[],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":2} + + + event: response.content_part.added + + data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"{\"","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"8fUeuLmUP4ayTK","output_index":0,"sequence_number":4} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"segments","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"VJ2iqUHA","output_index":0,"sequence_number":5} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"\":[","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"vUHwGixAoALoi","output_index":0,"sequence_number":6} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"{\"","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"qPipTDVTcRPWVJ","output_index":0,"sequence_number":7} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"text","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"w4Ax7bTnKf0s","output_index":0,"sequence_number":8} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"\":\"","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"URhkziveEWT48","output_index":0,"sequence_number":9} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"Fl","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"N9UUXJsgCtMHTB","output_index":0,"sequence_number":10} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"urbo","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"Q6C2RNxLu57a","output_index":0,"sequence_number":11} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" streams","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"YyDpUwAt","output_index":0,"sequence_number":12} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" responses","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"eEfEXB","output_index":0,"sequence_number":13} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" via","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"fYwCc995PN4Q","output_index":0,"sequence_number":14} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"1WUxDPnAwtWc","output_index":0,"sequence_number":15} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" `","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"X2ZjPMfGIbqYnW","output_index":0,"sequence_number":16} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"fl","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"z1wmRTEcLATBeI","output_index":0,"sequence_number":17} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"b","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"4NsuQEbHRgJ6Xjy","output_index":0,"sequence_number":18} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":".stream","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"8nfcU62Dn","output_index":0,"sequence_number":19} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"()`","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"qaSNldV1yOI3t","output_index":0,"sequence_number":20} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" generator","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"FEcCZy","output_index":0,"sequence_number":21} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"DyrfcfvGwBLNzNL","output_index":0,"sequence_number":22} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" which","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"TzrDZcTiWC","output_index":0,"sequence_number":23} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" yields","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"Q24ixChRw","output_index":0,"sequence_number":24} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" `","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"WzlxNohABL9CiO","output_index":0,"sequence_number":25} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"Fl","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"L225JaBxvW7jNp","output_index":0,"sequence_number":26} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"urbo","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"se3B9RYjx9Pp","output_index":0,"sequence_number":27} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"Chunk","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"vsDFxaBYdGY","output_index":0,"sequence_number":28} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"`","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"kzh3qPlDpoZdITM","output_index":0,"sequence_number":29} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" objects","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"HHGe8NqO","output_index":0,"sequence_number":30} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":".\",\"","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"TWDWh1cpuZ22","output_index":0,"sequence_number":31} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"chunk","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"6WCCSLRy8hP","output_index":0,"sequence_number":32} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"_ids","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"tMZisUdik3ph","output_index":0,"sequence_number":33} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"\":[\"","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"baqlgUOCWQGI","output_index":0,"sequence_number":34} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"c","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"3mli4dPC8HFvT23","output_index":0,"sequence_number":35} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"1","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"xngJPtuUrSw1mHg","output_index":0,"sequence_number":36} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"\"]","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"XXrQPT2lem3E5j","output_index":0,"sequence_number":37} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"}","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"obgwJhmDcXp2XUz","output_index":0,"sequence_number":38} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"]}","item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"obfuscation":"wBLrn1n8sIU0cl","output_index":0,"sequence_number":39} + + + event: response.output_text.done + + data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","logprobs":[],"output_index":0,"sequence_number":40,"text":"{\"segments\":[{\"text\":\"Flurbo + streams responses via the `flb.stream()` generator, which yields `FlurboChunk` + objects.\",\"chunk_ids\":[\"c1\"]}]}"} + + + event: response.content_part.done + + data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"{\"segments\":[{\"text\":\"Flurbo + streams responses via the `flb.stream()` generator, which yields `FlurboChunk` + objects.\",\"chunk_ids\":[\"c1\"]}]}"},"sequence_number":41} + + + event: response.output_item.done + + data: {"type":"response.output_item.done","item":{"id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"{\"segments\":[{\"text\":\"Flurbo + streams responses via the `flb.stream()` generator, which yields `FlurboChunk` + objects.\",\"chunk_ids\":[\"c1\"]}]}"}],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":42} + + + event: response.completed + + data: {"type":"response.completed","response":{"id":"resp_00c966f6a040503b016a6bab3f9c7481968ad0e5110985f154","object":"response","created_at":1785441087,"status":"completed","background":false,"completed_at":1785441088,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[{"id":"msg_00c966f6a040503b016a6bab401ff881969e8e0c92ce64f0ee","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"{\"segments\":[{\"text\":\"Flurbo + streams responses via the `flb.stream()` generator, which yields `FlurboChunk` + objects.\",\"chunk_ids\":[\"c1\"]}]}"}],"phase":"final_answer","role":"assistant"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"none","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"$defs":{"CitedSegment":{"properties":{"text":{"description":"A + span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.","title":"Text","type":"string"},"chunk_ids":{"description":"chunk_id + values of the search results that directly support this span. Empty list if + none. Start a new segment whenever the set of supporting sources changes.","items":{"type":"string"},"title":"Chunk + Ids","type":"array"}},"required":["text","chunk_ids"],"title":"CitedSegment","type":"object","additionalProperties":false}},"properties":{"segments":{"items":{"$ref":"#/$defs/CitedSegment"},"type":"array"}},"required":["segments"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"function","description":"Search + the registered document store for passages relevant to a query.\nUse this + whenever the user''s question could be answered from the store''s\ndocuments. + Ground your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.","name":"search_documents","output_schema":null,"parameters":{"properties":{"query":{"type":"string"}},"required":["query"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":388,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":44,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":432},"user":null,"metadata":{}},"sequence_number":43} + + + ' + headers: + access-control-expose-headers: + - X-Request-ID + - CF-Ray + - CF-Ray + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + connection: + - keep-alive + content-type: + - text/event-stream; charset=utf-8 + date: + - Thu, 30 Jul 2026 19:51:27 GMT + openai-processing-ms: + - '205' + openai-version: + - '2020-10-01' + server: + - cloudflare + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '39999156' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 1ms + status: + code: 200 + message: OK +version: 1 diff --git a/tests/_vcr/test_provider_openai_rag/test_openai_rag_tool_mode_citations.yaml b/tests/_vcr/test_provider_openai_rag/test_openai_rag_tool_mode_citations.yaml new file mode 100644 index 00000000..c300eb11 --- /dev/null +++ b/tests/_vcr/test_provider_openai_rag/test_openai_rag_tool_mode_citations.yaml @@ -0,0 +1,566 @@ +interactions: +- request: + body: '{"include": ["reasoning.encrypted_content"], "input": [{"role": "user", + "content": [{"type": "input_text", "text": "How does Flurbo stream responses?"}]}], + "model": "gpt-5.4", "store": false, "stream": true, "text": {"format": {"type": + "json_schema", "name": "structured_data", "schema": {"$defs": {"CitedSegment": + {"properties": {"text": {"description": "A span of the answer, in plain prose. + Concatenating every segment''s text in order must produce the complete answer.", + "title": "Text", "type": "string"}, "chunk_ids": {"description": "chunk_id values + of the search results that directly support this span. Empty list if none. Start + a new segment whenever the set of supporting sources changes.", "items": {"type": + "string"}, "title": "Chunk Ids", "type": "array"}}, "required": ["text", "chunk_ids"], + "title": "CitedSegment", "type": "object", "additionalProperties": false}}, + "properties": {"segments": {"items": {"$ref": "#/$defs/CitedSegment"}, "type": + "array"}}, "required": ["segments"], "type": "object", "additionalProperties": + false}, "strict": true}}, "tools": [{"type": "function", "name": "search_documents", + "description": "Search the registered document store for passages relevant to + a query.\nUse this whenever the user''s question could be answered from the + store''s\ndocuments. Ground your answer in the returned results.\n\nSearch the + document store.\n\nParameters\n----------\nquery\n What to look for, phrased + as a focused search query.", "parameters": {"properties": {"query": {"type": + "string"}}, "required": ["query"], "type": "object", "additionalProperties": + false}, "strict": true}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '1529' + content-type: + - application/json + host: + - api.openai.com + x-stainless-async: + - 'false' + x-stainless-read-timeout: + - '600' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: 'event: response.created + + data: {"type":"response.created","response":{"id":"resp_01c89e88d8433aab016a6bab3c47cc81909a996604172fe757","object":"response","created_at":1785441084,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"none","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"$defs":{"CitedSegment":{"properties":{"text":{"description":"A + span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.","title":"Text","type":"string"},"chunk_ids":{"description":"chunk_id + values of the search results that directly support this span. Empty list if + none. Start a new segment whenever the set of supporting sources changes.","items":{"type":"string"},"title":"Chunk + Ids","type":"array"}},"required":["text","chunk_ids"],"title":"CitedSegment","type":"object","additionalProperties":false}},"properties":{"segments":{"items":{"$ref":"#/$defs/CitedSegment"},"type":"array"}},"required":["segments"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"function","description":"Search + the registered document store for passages relevant to a query.\nUse this + whenever the user''s question could be answered from the store''s\ndocuments. + Ground your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.","name":"search_documents","output_schema":null,"parameters":{"properties":{"query":{"type":"string"}},"required":["query"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + + + event: response.in_progress + + data: {"type":"response.in_progress","response":{"id":"resp_01c89e88d8433aab016a6bab3c47cc81909a996604172fe757","object":"response","created_at":1785441084,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"none","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"$defs":{"CitedSegment":{"properties":{"text":{"description":"A + span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.","title":"Text","type":"string"},"chunk_ids":{"description":"chunk_id + values of the search results that directly support this span. Empty list if + none. Start a new segment whenever the set of supporting sources changes.","items":{"type":"string"},"title":"Chunk + Ids","type":"array"}},"required":["text","chunk_ids"],"title":"CitedSegment","type":"object","additionalProperties":false}},"properties":{"segments":{"items":{"$ref":"#/$defs/CitedSegment"},"type":"array"}},"required":["segments"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"function","description":"Search + the registered document store for passages relevant to a query.\nUse this + whenever the user''s question could be answered from the store''s\ndocuments. + Ground your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.","name":"search_documents","output_schema":null,"parameters":{"properties":{"query":{"type":"string"}},"required":["query"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + + + event: response.output_item.added + + data: {"type":"response.output_item.added","item":{"id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","type":"function_call","status":"in_progress","arguments":"","call_id":"call_lZehGq221lVYln6fmoLwoUuG","name":"search_documents"},"output_index":0,"sequence_number":2} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","obfuscation":"swtHcpLHuCOXli","output_index":0,"sequence_number":3} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"query","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","obfuscation":"78Pg5JiAxdo","output_index":0,"sequence_number":4} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"\":\"","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","obfuscation":"tRrKOkkH2jAPw","output_index":0,"sequence_number":5} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"Fl","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","obfuscation":"t5RFWlcuZXFv3w","output_index":0,"sequence_number":6} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"urbo","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","obfuscation":"qSM8eltrHSlN","output_index":0,"sequence_number":7} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":" stream","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","obfuscation":"jHhkc1rQL","output_index":0,"sequence_number":8} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":" responses","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","obfuscation":"XB0NRq","output_index":0,"sequence_number":9} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":" how","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","obfuscation":"5ukWkcUyosqw","output_index":0,"sequence_number":10} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":" does","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","obfuscation":"M2UIyvMDN6V","output_index":0,"sequence_number":11} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":" Fl","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","obfuscation":"J84Aun3QmO26G","output_index":0,"sequence_number":12} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"urbo","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","obfuscation":"LKehTiUz5tCo","output_index":0,"sequence_number":13} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":" stream","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","obfuscation":"ArxRpWyP4","output_index":0,"sequence_number":14} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":" responses","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","obfuscation":"KbhYr6","output_index":0,"sequence_number":15} + + + event: response.function_call_arguments.delta + + data: {"type":"response.function_call_arguments.delta","delta":"\"}","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","obfuscation":"rcf4qyo6yUGrm3","output_index":0,"sequence_number":16} + + + event: response.function_call_arguments.done + + data: {"type":"response.function_call_arguments.done","arguments":"{\"query\":\"Flurbo + stream responses how does Flurbo stream responses\"}","item_id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","output_index":0,"sequence_number":17} + + + event: response.output_item.done + + data: {"type":"response.output_item.done","item":{"id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","type":"function_call","status":"completed","arguments":"{\"query\":\"Flurbo + stream responses how does Flurbo stream responses\"}","call_id":"call_lZehGq221lVYln6fmoLwoUuG","name":"search_documents"},"output_index":0,"sequence_number":18} + + + event: response.completed + + data: {"type":"response.completed","response":{"id":"resp_01c89e88d8433aab016a6bab3c47cc81909a996604172fe757","object":"response","created_at":1785441084,"status":"completed","background":false,"completed_at":1785441084,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[{"id":"fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa","type":"function_call","status":"completed","arguments":"{\"query\":\"Flurbo + stream responses how does Flurbo stream responses\"}","call_id":"call_lZehGq221lVYln6fmoLwoUuG","name":"search_documents"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"none","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"$defs":{"CitedSegment":{"properties":{"text":{"description":"A + span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.","title":"Text","type":"string"},"chunk_ids":{"description":"chunk_id + values of the search results that directly support this span. Empty list if + none. Start a new segment whenever the set of supporting sources changes.","items":{"type":"string"},"title":"Chunk + Ids","type":"array"}},"required":["text","chunk_ids"],"title":"CitedSegment","type":"object","additionalProperties":false}},"properties":{"segments":{"items":{"$ref":"#/$defs/CitedSegment"},"type":"array"}},"required":["segments"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"function","description":"Search + the registered document store for passages relevant to a query.\nUse this + whenever the user''s question could be answered from the store''s\ndocuments. + Ground your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.","name":"search_documents","output_schema":null,"parameters":{"properties":{"query":{"type":"string"}},"required":["query"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":252,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":27,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":279},"user":null,"metadata":{}},"sequence_number":19} + + + ' + headers: + access-control-expose-headers: + - X-Request-ID + - CF-Ray + - CF-Ray + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + connection: + - keep-alive + content-type: + - text/event-stream; charset=utf-8 + date: + - Thu, 30 Jul 2026 19:51:24 GMT + openai-processing-ms: + - '214' + openai-version: + - '2020-10-01' + server: + - cloudflare + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '39999293' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 1ms + status: + code: 200 + message: OK +- request: + body: '{"include": ["reasoning.encrypted_content"], "input": [{"role": "user", + "content": [{"type": "input_text", "text": "How does Flurbo stream responses?"}]}, + {"type": "function_call", "call_id": "fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa", + "name": "search_documents", "arguments": "{\"query\":\"Flurbo stream responses + how does Flurbo stream responses\"}"}, {"type": "function_call_output", "call_id": + "fc_01c89e88d8433aab016a6bab3cde048190b55ab1ba220184fa", "output": "{\"results\":[{\"chunk_id\":\"c1\",\"source\":\"kb://flurbo/streaming\",\"title\":\"Flurbo + > Streaming\",\"text\":\"Flurbo streams responses via the flb.stream() generator, + which yields FlurboChunk objects.\"},{\"chunk_id\":\"c2\",\"source\":\"kb://flurbo/intro\",\"title\":\"Flurbo + > Introduction\",\"text\":\"The Flurbo framework was created in 2019 by Ada + Quist. Its default port is 7113.\"}]}"}], "model": "gpt-5.4", "store": false, + "stream": true, "text": {"format": {"type": "json_schema", "name": "structured_data", + "schema": {"$defs": {"CitedSegment": {"properties": {"text": {"description": + "A span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.", "title": "Text", "type": "string"}, + "chunk_ids": {"description": "chunk_id values of the search results that directly + support this span. Empty list if none. Start a new segment whenever the set + of supporting sources changes.", "items": {"type": "string"}, "title": "Chunk + Ids", "type": "array"}}, "required": ["text", "chunk_ids"], "title": "CitedSegment", + "type": "object", "additionalProperties": false}}, "properties": {"segments": + {"items": {"$ref": "#/$defs/CitedSegment"}, "type": "array"}}, "required": ["segments"], + "type": "object", "additionalProperties": false}, "strict": true}}, "tools": + [{"type": "function", "name": "search_documents", "description": "Search the + registered document store for passages relevant to a query.\nUse this whenever + the user''s question could be answered from the store''s\ndocuments. Ground + your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.", "parameters": {"properties": + {"query": {"type": "string"}}, "required": ["query"], "type": "object", "additionalProperties": + false}, "strict": true}]}' + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate, zstd + connection: + - keep-alive + content-length: + - '2239' + content-type: + - application/json + host: + - api.openai.com + x-stainless-async: + - 'false' + x-stainless-read-timeout: + - '600' + method: POST + uri: https://api.openai.com/v1/responses + response: + body: + string: 'event: response.created + + data: {"type":"response.created","response":{"id":"resp_0e49bfccade11a90016a6bab3d7cdc81979010867651b43d11","object":"response","created_at":1785441085,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"none","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"$defs":{"CitedSegment":{"properties":{"text":{"description":"A + span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.","title":"Text","type":"string"},"chunk_ids":{"description":"chunk_id + values of the search results that directly support this span. Empty list if + none. Start a new segment whenever the set of supporting sources changes.","items":{"type":"string"},"title":"Chunk + Ids","type":"array"}},"required":["text","chunk_ids"],"title":"CitedSegment","type":"object","additionalProperties":false}},"properties":{"segments":{"items":{"$ref":"#/$defs/CitedSegment"},"type":"array"}},"required":["segments"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"function","description":"Search + the registered document store for passages relevant to a query.\nUse this + whenever the user''s question could be answered from the store''s\ndocuments. + Ground your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.","name":"search_documents","output_schema":null,"parameters":{"properties":{"query":{"type":"string"}},"required":["query"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + + + event: response.in_progress + + data: {"type":"response.in_progress","response":{"id":"resp_0e49bfccade11a90016a6bab3d7cdc81979010867651b43d11","object":"response","created_at":1785441085,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"none","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"$defs":{"CitedSegment":{"properties":{"text":{"description":"A + span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.","title":"Text","type":"string"},"chunk_ids":{"description":"chunk_id + values of the search results that directly support this span. Empty list if + none. Start a new segment whenever the set of supporting sources changes.","items":{"type":"string"},"title":"Chunk + Ids","type":"array"}},"required":["text","chunk_ids"],"title":"CitedSegment","type":"object","additionalProperties":false}},"properties":{"segments":{"items":{"$ref":"#/$defs/CitedSegment"},"type":"array"}},"required":["segments"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"function","description":"Search + the registered document store for passages relevant to a query.\nUse this + whenever the user''s question could be answered from the store''s\ndocuments. + Ground your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.","name":"search_documents","output_schema":null,"parameters":{"properties":{"query":{"type":"string"}},"required":["query"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + + + event: response.output_item.added + + data: {"type":"response.output_item.added","item":{"id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","type":"message","status":"in_progress","content":[],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":2} + + + event: response.content_part.added + + data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"{\"","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"fWOGeAEHzIC2hk","output_index":0,"sequence_number":4} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"segments","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"S3ikjdIT","output_index":0,"sequence_number":5} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"\":[","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"ugE8YGfFODOCy","output_index":0,"sequence_number":6} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"{\"","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"fqNRTPH2kE3O5l","output_index":0,"sequence_number":7} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"text","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"I0uXtHtxDA4E","output_index":0,"sequence_number":8} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"\":\"","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"WI5zoiI6Xrmjp","output_index":0,"sequence_number":9} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"Fl","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"cgrK5eEwqSurRQ","output_index":0,"sequence_number":10} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"urbo","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"I1CbFLRRrclS","output_index":0,"sequence_number":11} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" streams","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"EGB5CTDB","output_index":0,"sequence_number":12} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" responses","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"kEelM0","output_index":0,"sequence_number":13} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" via","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"wCJvchYBax19","output_index":0,"sequence_number":14} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"HAKPmq4bva3s","output_index":0,"sequence_number":15} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" `","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"F9BNQ8v2hJB1G8","output_index":0,"sequence_number":16} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"fl","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"CtMxNEQqjZp39c","output_index":0,"sequence_number":17} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"b","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"oR60IVUQ346fNkC","output_index":0,"sequence_number":18} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":".stream","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"wKTHgqj8Y","output_index":0,"sequence_number":19} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"()`","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"6WtjJwQtrXsrW","output_index":0,"sequence_number":20} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" generator","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"fT57Ml","output_index":0,"sequence_number":21} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"NISpCBceG4CXMiA","output_index":0,"sequence_number":22} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" which","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"Po9Hchvylk","output_index":0,"sequence_number":23} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" yields","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"bAY6oeTHM","output_index":0,"sequence_number":24} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" `","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"BJGozu5fZc6olC","output_index":0,"sequence_number":25} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"Fl","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"s1eQIgJuvOsivk","output_index":0,"sequence_number":26} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"urbo","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"BhCgQvRdeoKi","output_index":0,"sequence_number":27} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"Chunk","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"90JgjaPm0Aj","output_index":0,"sequence_number":28} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"`","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"HgFPdRmmAj5FhmY","output_index":0,"sequence_number":29} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":" objects","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"0DhTkjfJ","output_index":0,"sequence_number":30} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":".\",\"","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"ZrSzXwGyyOFq","output_index":0,"sequence_number":31} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"chunk","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"Dx59ZZM3KZ0","output_index":0,"sequence_number":32} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"_ids","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"9h899Pt8NW7c","output_index":0,"sequence_number":33} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"\":[\"","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"YEsmBmXlep8o","output_index":0,"sequence_number":34} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"c","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"KJ3TVp8hQP8WQuP","output_index":0,"sequence_number":35} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"1","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"6qpkSTkcZlelyIu","output_index":0,"sequence_number":36} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"\"]","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"8W6cyowYVUBBAA","output_index":0,"sequence_number":37} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"}","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"MwNQmaQOBVBk7S0","output_index":0,"sequence_number":38} + + + event: response.output_text.delta + + data: {"type":"response.output_text.delta","content_index":0,"delta":"]}","item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"obfuscation":"oABzql48VKWhcQ","output_index":0,"sequence_number":39} + + + event: response.output_text.done + + data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","logprobs":[],"output_index":0,"sequence_number":40,"text":"{\"segments\":[{\"text\":\"Flurbo + streams responses via the `flb.stream()` generator, which yields `FlurboChunk` + objects.\",\"chunk_ids\":[\"c1\"]}]}"} + + + event: response.content_part.done + + data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"{\"segments\":[{\"text\":\"Flurbo + streams responses via the `flb.stream()` generator, which yields `FlurboChunk` + objects.\",\"chunk_ids\":[\"c1\"]}]}"},"sequence_number":41} + + + event: response.output_item.done + + data: {"type":"response.output_item.done","item":{"id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"{\"segments\":[{\"text\":\"Flurbo + streams responses via the `flb.stream()` generator, which yields `FlurboChunk` + objects.\",\"chunk_ids\":[\"c1\"]}]}"}],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":42} + + + event: response.completed + + data: {"type":"response.completed","response":{"id":"resp_0e49bfccade11a90016a6bab3d7cdc81979010867651b43d11","object":"response","created_at":1785441085,"status":"completed","background":false,"completed_at":1785441086,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[{"id":"msg_0e49bfccade11a90016a6bab3e4e80819797562e5ba07e4074","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"{\"segments\":[{\"text\":\"Flurbo + streams responses via the `flb.stream()` generator, which yields `FlurboChunk` + objects.\",\"chunk_ids\":[\"c1\"]}]}"}],"phase":"final_answer","role":"assistant"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"none","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"json_schema","description":null,"name":"structured_data","schema":{"$defs":{"CitedSegment":{"properties":{"text":{"description":"A + span of the answer, in plain prose. Concatenating every segment''s text in + order must produce the complete answer.","title":"Text","type":"string"},"chunk_ids":{"description":"chunk_id + values of the search results that directly support this span. Empty list if + none. Start a new segment whenever the set of supporting sources changes.","items":{"type":"string"},"title":"Chunk + Ids","type":"array"}},"required":["text","chunk_ids"],"title":"CitedSegment","type":"object","additionalProperties":false}},"properties":{"segments":{"items":{"$ref":"#/$defs/CitedSegment"},"type":"array"}},"required":["segments"],"type":"object","additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[{"type":"function","description":"Search + the registered document store for passages relevant to a query.\nUse this + whenever the user''s question could be answered from the store''s\ndocuments. + Ground your answer in the returned results.\n\nSearch the document store.\n\nParameters\n----------\nquery\n What + to look for, phrased as a focused search query.","name":"search_documents","output_schema":null,"parameters":{"properties":{"query":{"type":"string"}},"required":["query"],"type":"object","additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":388,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":44,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":432},"user":null,"metadata":{}},"sequence_number":43} + + + ' + headers: + access-control-expose-headers: + - X-Request-ID + - CF-Ray + - CF-Ray + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + connection: + - keep-alive + content-type: + - text/event-stream; charset=utf-8 + date: + - Thu, 30 Jul 2026 19:51:25 GMT + openai-processing-ms: + - '377' + openai-version: + - '2020-10-01' + server: + - cloudflare + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + transfer-encoding: + - chunked + x-content-type-options: + - nosniff + x-ratelimit-limit-requests: + - '15000' + x-ratelimit-limit-tokens: + - '40000000' + x-ratelimit-remaining-requests: + - '14999' + x-ratelimit-remaining-tokens: + - '39999156' + x-ratelimit-reset-requests: + - 4ms + x-ratelimit-reset-tokens: + - 1ms + status: + code: 200 + message: OK +version: 1 diff --git a/tests/test_content.py b/tests/test_content.py index 6002d3d3..ffb8d2db 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -8,7 +8,11 @@ ContentToolRequestSearch, ContentToolResponseFetch, ContentToolResponseSearch, + ContentToolResult, ContentUploaded, + DocumentSource, + SearchResult, + ToolSearchResults, WebSource, create_content, create_source, @@ -42,6 +46,38 @@ def test_create_source_unknown_type_raises(): create_source({"type": "nope"}) +def test_document_source_fields(): + src = DocumentSource(id="kb://doc-1", title="Runbook") + assert src.type == "document" + assert src.id == "kb://doc-1" + assert src.title == "Runbook" + assert str(src) == "kb://doc-1" + assert str(DocumentSource(title="Runbook")) == "Runbook" + assert str(DocumentSource()) == "[document source]" + + +def test_create_source_dispatches_document(): + src = create_source({"type": "document", "id": "kb://doc-1", "title": "T"}) + assert isinstance(src, DocumentSource) + + +def test_content_citation_roundtrip_rebuilds_document_source(): + citation = ContentCitation( + source=DocumentSource(id="kb://doc-1", title="T"), + grounded_span="span", + ) + rebuilt = create_content(citation.model_dump()) + assert isinstance(rebuilt, ContentCitation) + assert isinstance(rebuilt.source, DocumentSource) + assert rebuilt.source.id == "kb://doc-1" + + +def test_document_source_exported_from_types(): + from chatlas.types import DocumentSource as Exported + + assert Exported is DocumentSource + + def test_search_results_use_web_sources(): r = ContentToolResponseSearch( sources=[WebSource(url="https://python.org", title="Python")] @@ -213,3 +249,14 @@ def test_web_content_survives_markdown_rendering(content, label, detail): def test_web_content_str_has_no_link_reference_prefix(content, label, detail): """The `[label]:` form is the bug; assert it can't come back.""" assert not str(content).startswith("[") + + +def test_tool_search_results_model_value(): + tsr = ToolSearchResults( + results=[SearchResult(id="c1", text="chunk text", source="kb://d", title="T")] + ) + result = ContentToolResult(value=tsr) + value = result.get_model_value() + assert isinstance(value, str) + assert '"chunk_id":"c1"' in value.replace(" ", "") + assert "chunk text" in value diff --git a/tests/test_provider_anthropic.py b/tests/test_provider_anthropic.py index ad5832a5..8070b627 100644 --- a/tests/test_provider_anthropic.py +++ b/tests/test_provider_anthropic.py @@ -705,3 +705,65 @@ def test_anthropic_truncated_plain_text_still_returns_a_turn(): assert turn.text == '{"comments": [{"body": "trunc' assert turn.finish_reason == "max_tokens" + + +def test_tool_search_results_serialize_as_search_result_blocks(): + from chatlas._content import ContentToolRequest, ContentToolResult + from chatlas.types import SearchResult, ToolSearchResults + + request = ContentToolRequest(id="toolu_1", name="search_documents", arguments={}) + result = ContentToolResult( + value=ToolSearchResults( + results=[SearchResult(id="c1", text="alpha", source="kb://a", title="A")] + ), + request=request, + ) + block = AnthropicProvider._as_content_block(result, citations_enabled=True) + assert block["type"] == "tool_result" + (sr,) = block["content"] + assert sr["type"] == "search_result" + assert sr["source"] == "kb://a" + assert sr["title"] == "A" + assert sr["content"] == [{"type": "text", "text": "alpha"}] + assert sr["citations"] == {"enabled": True} + + +def test_search_result_blocks_disable_citations_with_data_model(): + from chatlas._provider_anthropic import anthropic_search_result_blocks + from chatlas.types import SearchResult + + (sr,) = anthropic_search_result_blocks( + [SearchResult(id="c1", text="alpha")], citations_enabled=False + ) + assert "citations" not in sr + assert ( + sr["source"] == "c1" and sr["title"] == "c1" + ) # required fields fall back to id + + +def test_anthropic_search_result_citation_becomes_document_source(): + from anthropic.types import CitationsSearchResultLocation, TextBlock + from chatlas._provider_anthropic import anthropic_citations + from chatlas.types import DocumentSource + + block = TextBlock( + type="text", + text="Streaming uses .stream().", + citations=[ + CitationsSearchResultLocation( + type="search_result_location", + cited_text="Use .stream() for streaming.", + source="kb://docs/streaming", + title="Streaming", + search_result_index=0, + start_block_index=0, + end_block_index=1, + ) + ], + ) + (citation,) = anthropic_citations(block) + assert isinstance(citation.source, DocumentSource) + assert citation.source.id == "kb://docs/streaming" + assert citation.source.title == "Streaming" + assert citation.grounded_span == "Streaming uses .stream()." + assert citation.cited_quote == "Use .stream() for streaming." diff --git a/tests/test_provider_anthropic_rag.py b/tests/test_provider_anthropic_rag.py new file mode 100644 index 00000000..6ccfe8c4 --- /dev/null +++ b/tests/test_provider_anthropic_rag.py @@ -0,0 +1,80 @@ +import pytest + +from chatlas import ChatAnthropic +from chatlas._content import ContentCitation +from chatlas.types import DocumentSource + +from .conftest import assert_citations_grounded + +# Invented facts force retrieval (the model can't know them) and make +# citation grounding unambiguous. +FLURBO_CHUNKS = [ + { + "text": "The Flurbo framework was created in 2019 by Ada Quist. " + "Its default port is 7113.", + "origin": "kb://flurbo/intro", + "context": "Flurbo > Introduction", + }, + { + "text": "Flurbo streams responses via the flb.stream() generator, " + "which yields FlurboChunk objects.", + "origin": "kb://flurbo/streaming", + "context": "Flurbo > Streaming", + }, +] + + +class DictChunk: + def __init__(self, d: dict): + self.text = d["text"] + self.origin = d["origin"] + self.context = d["context"] + + +class KeywordStore: + """Tiny deterministic store: rank by naive keyword overlap.""" + + def __init__(self, chunks=FLURBO_CHUNKS): + self._chunks = [DictChunk(c) for c in chunks] + + def retrieve(self, text: str, top_k: int): + words = set(text.lower().split()) + ranked = sorted( + self._chunks, + key=lambda c: len(words & set(c.text.lower().split())), + reverse=True, + ) + return ranked[:top_k] + + +def chat_func(**kwargs): + return ChatAnthropic(**kwargs) + + +@pytest.mark.vcr +def test_anthropic_rag_tool_mode_citations(): + chat = chat_func() + chat.rag.register_store(KeywordStore(), top_k=2) + chat.chat("How does Flurbo stream responses?", echo="none") + + turn = chat.get_last_turn(role="assistant") + assert turn is not None + citations = [c for c in turn.contents if isinstance(c, ContentCitation)] + assert citations, "expected at least one citation" + assert any( + isinstance(c.source, DocumentSource) + and c.source.id == "kb://flurbo/streaming" + for c in citations + ) + assert all(c.cited_quote for c in citations) + assert_citations_grounded(chat) + + +@pytest.mark.vcr +def test_anthropic_rag_streaming_interleaves_citations(): + chat = chat_func() + chat.rag.register_store(KeywordStore(), top_k=2) + chunks = list(chat.stream("How does Flurbo stream responses?", content="all")) + citations = [c for c in chunks if isinstance(c, ContentCitation)] + assert citations + assert isinstance(citations[0].source, DocumentSource) diff --git a/tests/test_provider_google_rag.py b/tests/test_provider_google_rag.py new file mode 100644 index 00000000..e61afb17 --- /dev/null +++ b/tests/test_provider_google_rag.py @@ -0,0 +1,65 @@ +import pytest + +from chatlas import ChatGoogle +from chatlas._content import ContentCitation, ContentJson +from chatlas.types import DocumentSource + +from .conftest import assert_citations_grounded +from .test_provider_anthropic_rag import KeywordStore + + +def chat_func(**kwargs): + return ChatGoogle(**kwargs) + + +# Both tests below are xfail (not skipped): the request/response cycle works +# and is recorded correctly, but chatlas's Google streaming turn assembly has +# a real, pre-existing bug that this is the first end-to-end test to exercise. +# +# Root cause (verified independently of RAG, e.g. via plain +# `ChatGoogle().stream("...", data_model=SomeModel)`): Gemini's streamed +# `content.parts` dicts, unlike `candidates`, carry no `"index"` key, so +# `merge_lists`/`merge_dicts` (chatlas/_merge.py) append successive text-delta +# parts as separate list entries instead of concatenating their text. Then +# `GoogleProvider._as_turn` (chatlas/_provider_google.py) JSON-decodes each +# part's `text` independently when `has_data_model=True`, so any structured +# response that spans more than one SSE chunk -- which is the normal case, +# not a RAG-specific one -- raises `orjson.JSONDecodeError` on the first, +# incomplete fragment. This affects any `has_data_model=True` streamed Google +# turn, not just the RAG hand-rolled tier's segments schema. +_GOOGLE_MERGE_BUG_REASON = ( + "chatlas bug: GoogleProvider streaming turn assembly does not concatenate " + "multi-chunk structured-output text (Gemini parts lack an 'index' key for " + "merge_lists to match on), so has_data_model=True + streaming raises " + "orjson.JSONDecodeError on real API responses. See test file comment." +) + + +@pytest.mark.vcr +@pytest.mark.xfail(reason=_GOOGLE_MERGE_BUG_REASON, strict=True) +def test_google_rag_tool_mode_citations(): + chat = chat_func() + chat.rag.register_store(KeywordStore(), top_k=2) + chat.chat("How does Flurbo stream responses?", echo="none") + + turn = chat.get_last_turn(role="assistant") + assert not any(isinstance(c, ContentJson) for c in turn.contents) + citations = [c for c in turn.contents if isinstance(c, ContentCitation)] + assert citations + assert any( + isinstance(c.source, DocumentSource) + and c.source.id == "kb://flurbo/streaming" + for c in citations + ) + assert_citations_grounded(chat) + + +@pytest.mark.vcr +@pytest.mark.xfail(reason=_GOOGLE_MERGE_BUG_REASON, strict=True) +def test_google_rag_streaming_yields_prose_not_json(): + chat = chat_func() + chat.rag.register_store(KeywordStore(), top_k=2) + chunks = list(chat.stream("How does Flurbo stream responses?", content="all")) + text = "".join(c for c in chunks if isinstance(c, str)) + assert '"segments"' not in text + assert any(isinstance(c, ContentCitation) for c in chunks) diff --git a/tests/test_provider_openai_rag.py b/tests/test_provider_openai_rag.py new file mode 100644 index 00000000..523b9083 --- /dev/null +++ b/tests/test_provider_openai_rag.py @@ -0,0 +1,40 @@ +import pytest + +from chatlas import ChatOpenAI +from chatlas._content import ContentCitation, ContentJson +from chatlas.types import DocumentSource + +from .conftest import assert_citations_grounded +from .test_provider_anthropic_rag import KeywordStore + + +def chat_func(**kwargs): + return ChatOpenAI(**kwargs) + + +@pytest.mark.vcr +def test_openai_rag_tool_mode_citations(): + chat = chat_func() + chat.rag.register_store(KeywordStore(), top_k=2) + chat.chat("How does Flurbo stream responses?", echo="none") + + turn = chat.get_last_turn(role="assistant") + assert not any(isinstance(c, ContentJson) for c in turn.contents) + citations = [c for c in turn.contents if isinstance(c, ContentCitation)] + assert citations + assert any( + isinstance(c.source, DocumentSource) + and c.source.id == "kb://flurbo/streaming" + for c in citations + ) + assert_citations_grounded(chat) + + +@pytest.mark.vcr +def test_openai_rag_streaming_yields_prose_not_json(): + chat = chat_func() + chat.rag.register_store(KeywordStore(), top_k=2) + chunks = list(chat.stream("How does Flurbo stream responses?", content="all")) + text = "".join(c for c in chunks if isinstance(c, str)) + assert '"segments"' not in text + assert any(isinstance(c, ContentCitation) for c in chunks) diff --git a/tests/test_rag.py b/tests/test_rag.py new file mode 100644 index 00000000..49187c92 --- /dev/null +++ b/tests/test_rag.py @@ -0,0 +1,403 @@ +from dataclasses import dataclass +from typing import Any, Optional, Sequence + +import orjson +import pytest +from chatlas import Chat +from chatlas._content import ContentCitation, ContentJson, ContentText +from chatlas._provider import Provider +from chatlas._rag import ChunkLike, RetrievalStore, SegmentedAnswer, normalize_chunk +from chatlas._turn import AssistantTurn +from pydantic import BaseModel + + +@dataclass +class FakeChunk: + """Mirrors raghilda.chunk.Chunk's relevant attributes.""" + + text: str + origin: Optional[str] = None + context: Optional[str] = None + attributes: Optional[dict[str, Any]] = None + + +class FakeStore: + """Mirrors raghilda's BaseStore.retrieve(text, top_k) signature.""" + + def __init__(self, chunks: Sequence[FakeChunk]): + self.chunks = list(chunks) + self.queries: list[str] = [] + + def retrieve(self, text: str, top_k: int) -> Sequence[FakeChunk]: + self.queries.append(text) + return self.chunks[:top_k] + + +def test_raghilda_shaped_store_satisfies_protocol(): + assert isinstance(FakeStore([]), RetrievalStore) + assert isinstance(FakeChunk(text="t"), ChunkLike) + + +def test_object_without_retrieve_fails_protocol(): + assert not isinstance(object(), RetrievalStore) + + +def test_normalize_chunk_maps_fields(): + chunk = FakeChunk( + text="body", origin="kb://d1", context="Guide > Setup", attributes={"k": 1} + ) + sr = normalize_chunk(chunk, id="c1") + assert (sr.id, sr.text, sr.source, sr.title) == ("c1", "body", "kb://d1", "Guide > Setup") + assert sr.extra == {"k": 1} + + +def test_normalize_chunk_minimal(): + class Bare: + text = "body" + + sr = normalize_chunk(Bare(), id="c2") + assert (sr.source, sr.title, sr.extra) == (None, None, {}) + + +def test_provider_capability_defaults(): + from chatlas import ChatAnthropic, ChatGroq, ChatOpenAI + + openai = ChatOpenAI(api_key="fake").provider + anthropic = ChatAnthropic(api_key="fake").provider + groq = ChatGroq(api_key="fake").provider + + assert not openai.supports_native_search_results() + assert anthropic.supports_native_search_results() + assert not groq.supports_native_search_results() + + assert openai.supports_tools_with_data_model() + assert anthropic.supports_tools_with_data_model() + assert not groq.supports_tools_with_data_model() # Completions family drops tools + + +def make_chat(**kwargs): + from chatlas import ChatOpenAI + + return ChatOpenAI(api_key="fake", **kwargs) + + +def test_rag_accessor_is_cached_manager(): + from chatlas._rag import RagManager + + chat = make_chat() + assert isinstance(chat.rag, RagManager) + assert chat.rag is chat.rag + + +def test_register_store_creates_tool(): + chat = make_chat() + chat.rag.register_store(FakeStore([FakeChunk(text="t")])) + tools = chat.get_tools() + assert any(t.name == "search_documents" for t in tools) + + +def test_register_second_store_needs_distinct_name(): + import pytest + + chat = make_chat() + chat.rag.register_store(FakeStore([])) + with pytest.raises(ValueError, match="name"): + chat.rag.register_store(FakeStore([])) + chat.rag.register_store(FakeStore([]), name="search_runbooks") + assert {t.name for t in chat.get_tools()} >= {"search_documents", "search_runbooks"} + + +def test_unregister_store_removes_tool(): + chat = make_chat() + chat.rag.register_store(FakeStore([])) + chat.rag.unregister_store("search_documents") + assert all(t.name != "search_documents" for t in chat.get_tools()) + assert not chat.rag.uses_segments_schema() + + +def test_tool_mode_rejected_when_provider_cannot_combine(): + import pytest + from chatlas import ChatGroq + + chat = ChatGroq(api_key="fake") + with pytest.raises(ValueError, match="response schema"): + chat.rag.register_store(FakeStore([])) + + +def test_retrieval_tool_returns_tool_search_results(): + from chatlas import ToolSearchResults + from chatlas._content import ContentToolResult + + chat = make_chat() + store = FakeStore([FakeChunk(text="alpha", origin="kb://a"), FakeChunk(text="beta")]) + chat.rag.register_store(store, top_k=1) + tool = next(t for t in chat.get_tools() if t.name == "search_documents") + + result = tool.func(query="anything") + assert isinstance(result, ContentToolResult) + assert isinstance(result.value, ToolSearchResults) + assert [r.id for r in result.value.results] == ["c1"] + assert store.queries == ["anything"] + assert chat.rag.chunks["c1"].source == "kb://a" + + +def test_retrieval_tool_description_is_well_formed(): + chat = make_chat() + chat.rag.register_store(FakeStore([])) + tool = next(t for t in chat.get_tools() if t.name == "search_documents") + + description = tool.schema["function"]["description"] + assert "results.Search" not in description + assert "Ground your answer in the returned results." in description + assert "Search the document store." in description + assert "\n\nSearch the document store." in description + assert "\n Parameters" not in description + assert "Parameters\n----------" in description + + +def test_register_store_description_customizes_tool(): + chat = make_chat() + chat.rag.register_store( + FakeStore([]), + description="Search the internal runbook collection.", + ) + + tool = next(t for t in chat.get_tools() if t.name == "search_documents") + description = tool.schema["function"]["description"] + assert description.startswith("Search the internal runbook collection.") + assert "Parameters\n----------" in description + + +def test_chunk_ids_unique_across_calls(): + chat = make_chat() + store = FakeStore([FakeChunk(text="alpha")]) + chat.rag.register_store(store) + tool = next(t for t in chat.get_tools() if t.name == "search_documents") + tool.func(query="q1") + tool.func(query="q2") + assert set(chat.rag.chunks) == {"c1", "c2"} + + +SEGMENTS_JSON = ( + '{"segments": [' + '{"text": "Flurbo streams via flb.stream(). ", "chunk_ids": ["c2"]}, ' + '{"text": "Its default port is 7113.", "chunk_ids": ["c1"]}' + "]}" +) + + +def registry(): + from chatlas.types import SearchResult + + return { + "c1": SearchResult(id="c1", text="port is 7113", source="kb://intro"), + "c2": SearchResult(id="c2", text="flb.stream() yields", source="kb://stream"), + } + + +def decode_all(deltas): + from chatlas._rag import SegmentsDecoder + + dec = SegmentsDecoder(registry()) + out = [] + for d in deltas: + out.extend(dec.feed(d)) + out.extend(dec.finish()) + return out + + +def flatten(contents): + from chatlas._content import ContentCitation, ContentText + + text = "".join(c.text for c in contents if isinstance(c, ContentText)) + kinds = ["cite" if isinstance(c, ContentCitation) else "text" for c in contents] + cites = [c for c in contents if isinstance(c, ContentCitation)] + return text, kinds, cites + + +@pytest.mark.parametrize("split", [1, 3, 7, len(SEGMENTS_JSON)]) +def test_decoder_text_identical_for_any_split(split): + deltas = [SEGMENTS_JSON[i : i + split] for i in range(0, len(SEGMENTS_JSON), split)] + text, _, cites = flatten(decode_all(deltas)) + assert text == "Flurbo streams via flb.stream(). Its default port is 7113." + assert [c.extra["chunk_id"] for c in cites] == ["c2", "c1"] + + +def test_decoder_interleaves_citations_in_segment_order(): + text, kinds, cites = flatten(decode_all([SEGMENTS_JSON])) + first_cite = kinds.index("cite") + assert "text" in kinds[:first_cite] + assert cites[0].grounded_span == "Flurbo streams via flb.stream(). " + assert cites[0].source.id == "kb://stream" + assert cites[0].cited_quote == "flb.stream() yields" + + +def test_decoder_never_emits_truncated_chunk_id(): + # split mid-way through the "c2" id: no citation may be emitted for "c" + idx = SEGMENTS_JSON.index('"c2"') + 2 + _, _, cites = flatten(decode_all([SEGMENTS_JSON[:idx], SEGMENTS_JSON[idx:]])) + assert [c.extra["chunk_id"] for c in cites] == ["c2", "c1"] + + +def test_decoder_drops_unknown_ids(): + bad = SEGMENTS_JSON.replace('"c1"', '"c99"') + _, _, cites = flatten(decode_all([bad])) + assert [c.extra["chunk_id"] for c in cites] == ["c2"] + + +def test_decode_segments_json_one_shot(): + from chatlas._rag import decode_segments_json + + contents = decode_segments_json(SEGMENTS_JSON, registry()) + text, _, cites = flatten(contents) + assert text.endswith("7113.") + assert len(cites) == 2 + + +def test_decode_segments_json_malformed_falls_back_to_text(): + from chatlas._content import ContentText + from chatlas._rag import decode_segments_json + + (only,) = decode_segments_json("not json at all", registry()) + assert isinstance(only, ContentText) + assert only.text == "not json at all" + + +def chunked(text: str, size: int) -> list[str]: + """Split `text` into fixed-size pieces, mirroring streamed deltas.""" + return [text[i : i + size] for i in range(0, len(text), size)] + + +class RagFakeProvider(Provider): + """Streams pre-canned text deltas and records the `data_model` it's called + with, so tests can assert the hand-rolled RAG tier injects `SegmentedAnswer` + (and only when the caller hasn't already supplied their own `data_model`).""" + + def __init__( + self, + deltas: Sequence[str], + native_search_results: bool = False, + ): + super().__init__(name="rag-fake", model="fake-model") + self._deltas = list(deltas) + self._native_search_results = native_search_results + self.seen_data_model: Optional[type[BaseModel]] = None + + def list_models(self): + return [] + + def chat_perform(self, *, stream, turns, tools, data_model, kwargs): + self.seen_data_model = data_model + if not stream: + return "".join(self._deltas) + return iter(self._deltas) + + async def chat_perform_async(self, *, stream, turns, tools, data_model, kwargs): + self.seen_data_model = data_model + if not stream: + return "".join(self._deltas) + + async def _gen(): + for d in self._deltas: + yield d + + return _gen() + + def stream_content(self, chunk, completion): + return [ContentText.model_construct(text=chunk)] if chunk else [] + + def stream_merge_chunks(self, completion, chunk): + return (completion or "") + chunk + + def stream_turn(self, completion, has_data_model): + if has_data_model: + return AssistantTurn([ContentJson(value=orjson.loads(completion))]) + return AssistantTurn([ContentText.model_construct(text=completion)]) + + def value_turn(self, completion, has_data_model): + if has_data_model: + return AssistantTurn([ContentJson(value=orjson.loads(completion))]) + return AssistantTurn([ContentText.model_construct(text=completion)]) + + def value_tokens(self, completion): + return None + + def value_cost(self, completion, tokens=None): + return None + + def token_count(self, *args, **kwargs): + return 0 + + async def token_count_async(self, *args, **kwargs): + return 0 + + def translate_model_params(self, *args, **kwargs): + return {} + + def supported_model_params(self): + return set() + + def supports_native_search_results(self) -> bool: + return self._native_search_results + + +def make_rag_fake_chat( + deltas: Sequence[str], native_search_results: bool = False +) -> Chat: + provider = RagFakeProvider(deltas, native_search_results=native_search_results) + return Chat(provider=provider) + + +def seed_registry(chat: Chat) -> None: + """Register a store (so `uses_segments_schema()` is True) and pre-register + c1/c2 chunks matching `registry()`'s texts/sources, via the public API.""" + chat.rag.register_store(FakeStore([])) + chat.rag.register_chunks( + [ + FakeChunk(text="port is 7113", origin="kb://intro"), + FakeChunk(text="flb.stream() yields", origin="kb://stream"), + ] + ) + + +def test_handrolled_stream_yields_prose_and_citations(): + chat = make_rag_fake_chat(deltas=chunked(SEGMENTS_JSON, 5)) + seed_registry(chat) + + out = list(chat.stream("q", content="all")) + + text = "".join(c for c in out if isinstance(c, str)) + assert "{" not in text and "segments" not in text + assert "flb.stream()" in text + cites = [c for c in out if isinstance(c, ContentCitation)] + assert [c.extra["chunk_id"] for c in cites] == ["c2", "c1"] + assert chat.provider.seen_data_model is SegmentedAnswer + + +def test_handrolled_final_turn_has_text_and_citations_not_json(): + chat = make_rag_fake_chat(deltas=chunked(SEGMENTS_JSON, 5)) + seed_registry(chat) + list(chat.stream("q")) + turn = chat.get_last_turn(role="assistant") + assert turn is not None + assert not any(isinstance(c, ContentJson) for c in turn.contents) + texts = [c for c in turn.contents if isinstance(c, ContentText)] + assert "".join(t.text for t in texts).endswith("7113.") + assert sum(isinstance(c, ContentCitation) for c in turn.contents) == 2 + + +def test_user_data_model_wins_over_rag_schema(): + class Person(BaseModel): + name: str + + chat = make_rag_fake_chat(deltas=['{"name": "Ada"}']) + seed_registry(chat) + chat.chat_structured("q", data_model=Person) + assert chat.provider.seen_data_model is Person + + +def test_native_tier_gets_no_schema(): + chat = make_rag_fake_chat(deltas=["plain text"], native_search_results=True) + seed_registry(chat) + list(chat.stream("q")) + assert chat.provider.seen_data_model is None From b41224129c46bae3ec3212beb0c08a8e70f12180 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 31 Jul 2026 10:52:30 -0500 Subject: [PATCH 2/3] docs: add runnable RAG citation demo --- scripts/rag_citations_demo.py | 64 ++++++++++++++++++++++++++++++++ tests/test_rag_citations_demo.py | 20 ++++++++++ 2 files changed, 84 insertions(+) create mode 100644 scripts/rag_citations_demo.py create mode 100644 tests/test_rag_citations_demo.py diff --git a/scripts/rag_citations_demo.py b/scripts/rag_citations_demo.py new file mode 100644 index 00000000..a5329163 --- /dev/null +++ b/scripts/rag_citations_demo.py @@ -0,0 +1,64 @@ +import os +import sys +from dataclasses import dataclass +from typing import Sequence + +from chatlas import ChatOpenAI +from chatlas.types import ContentCitation + + +@dataclass +class Chunk: + text: str + origin: str + context: str + + +class Store: + def __init__(self, chunks: Sequence[Chunk]): + self._chunks = chunks + + def retrieve(self, query: str, top_k: int) -> Sequence[Chunk]: + return self._chunks[:top_k] + + +def main() -> None: + if not os.getenv("OPENAI_API_KEY"): + print("Set OPENAI_API_KEY before running this example.", file=sys.stderr) + raise SystemExit(1) + + store = Store( + [ + Chunk( + text=( + "The fictional Flurbo framework streams responses through " + "flb.stream(), which yields FlurboChunk objects." + ), + origin="kb://flurbo/streaming", + context="Flurbo streaming guide", + ) + ] + ) + chat = ChatOpenAI( + system_prompt=( + "Use the document search tool to answer questions about Flurbo. " + "Ground every answer in the returned search results." + ) + ) + chat.rag.register_store(store) + chat.chat("How does Flurbo stream responses?", echo="all") + + turn = chat.get_last_turn(role="assistant") + citations = ( + [content for content in turn.contents if isinstance(content, ContentCitation)] + if turn is not None + else [] + ) + if not citations: + raise SystemExit("No citation was returned; the RAG check failed.") + + print(f"\nVerified citation: {citations[0].source}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_rag_citations_demo.py b/tests/test_rag_citations_demo.py new file mode 100644 index 00000000..fadf04c2 --- /dev/null +++ b/tests/test_rag_citations_demo.py @@ -0,0 +1,20 @@ +import os +import subprocess +import sys +from pathlib import Path + + +def test_rag_citations_demo_requires_openai_api_key(): + root = Path(__file__).parents[1] + env = os.environ | {"OPENAI_API_KEY": ""} + result = subprocess.run( + [sys.executable, root / "scripts" / "rag_citations_demo.py"], + cwd=root, + env=env, + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 1 + assert "OPENAI_API_KEY" in result.stderr From 04c4f86426a2ee48ad933f2282ddcc8cda85a193 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 31 Jul 2026 18:33:00 -0500 Subject: [PATCH 3/3] feat: add Shiny RAG citation demo --- scripts/rag_citations_shiny_demo.py | 60 ++++++++++++++++++++++++++ tests/test_rag_citations_shiny_demo.py | 13 ++++++ 2 files changed, 73 insertions(+) create mode 100644 scripts/rag_citations_shiny_demo.py create mode 100644 tests/test_rag_citations_shiny_demo.py diff --git a/scripts/rag_citations_shiny_demo.py b/scripts/rag_citations_shiny_demo.py new file mode 100644 index 00000000..844399ef --- /dev/null +++ b/scripts/rag_citations_shiny_demo.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from typing import Sequence + +from chatlas import ChatOpenAI +from shiny import App, Inputs, Outputs, Session, ui +from shinychat import Chat, chat_ui + + +@dataclass +class Chunk: + text: str + origin: str + context: str + + +class Store: + def __init__(self, chunks: Sequence[Chunk]): + self._chunks = chunks + + def retrieve(self, text: str, top_k: int) -> Sequence[Chunk]: + return self._chunks[:top_k] + + +app_ui = ui.page_fillable( + ui.panel_title("Citation-aware RAG"), + chat_ui("chat"), + fillable_mobile=True, +) + + +def server(input_: Inputs, output: Outputs, session: Session) -> None: + chat = Chat("chat") + chat_client = ChatOpenAI( + system_prompt=( + "Use the document search tool to answer questions about Flurbo. " + "Ground every answer in the returned search results." + ) + ) + chat_client.rag.register_store( + Store( + [ + Chunk( + text=( + "The fictional Flurbo framework streams responses through " + "flb.stream(), which yields FlurboChunk objects." + ), + origin="kb://flurbo/streaming", + context="Flurbo streaming guide", + ) + ] + ) + ) + + @chat.on_user_submit + async def handle_user_input(user_input: str) -> None: + response = await chat_client.stream_async(user_input, content="all") + await chat.append_message_stream(response) + + +app = App(app_ui, server) diff --git a/tests/test_rag_citations_shiny_demo.py b/tests/test_rag_citations_shiny_demo.py new file mode 100644 index 00000000..fc3a389e --- /dev/null +++ b/tests/test_rag_citations_shiny_demo.py @@ -0,0 +1,13 @@ +import runpy +from pathlib import Path + + +def test_rag_citations_shiny_demo_imports_without_openai_api_key( + monkeypatch, +): + root = Path(__file__).parents[1] + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + module = runpy.run_path(str(root / "scripts" / "rag_citations_shiny_demo.py")) + + assert module["app"] is not None