diff --git a/docs/protocols/anthropic-messages.md b/docs/protocols/anthropic-messages.md index 9009e69..09514fb 100644 --- a/docs/protocols/anthropic-messages.md +++ b/docs/protocols/anthropic-messages.md @@ -360,7 +360,7 @@ Recognized errors use the Anthropic SDK namespace and the [shared error contract Declare `{"type":"web_search_20250305","name":"web_search"}` in `tools`. Translation to Responses supports allowed domains, approximate location, and search budgets; explicit blocked domains are rejected. -With Responses upstream, Messages receives server-tool blocks. `Molto.messages(..., citation_policy="buffer")` also preserves URL citations with reduced source metadata by withholding every text part until completion, including uncited parts. The default `"drop"` streams text without citations. Both modes preserve request-history attribution; native Messages passthrough is unaffected. With Chat upstream, the policy is a no-op: text streams immediately with unchanged chunk boundaries in both modes. See [Hosted web search](../web-search.md) for mappings, placeholders, and continuation limits. +With Responses upstream, Messages receives server-tool blocks. `Molto.messages(..., citation_policy="buffer")` also preserves URL citations with reduced source metadata by withholding every text part until completion, including uncited parts. `"auto"` buffers only when the normalized request declares hosted search or carries search or citation history; otherwise it streams text without citations. Opaque `extra_body` extensions do not select automatic buffering. The default `"drop"` always streams text without citations. All policies preserve request-history attribution; native Messages passthrough is unaffected. With Chat upstream, the policy is a no-op: text streams immediately with unchanged chunk boundaries. See [Hosted web search](../web-search.md) for mappings, placeholders, and continuation limits. When Messages is the translated upstream and the provider is Anthropic, missing, empty, or `"N/A"` encrypted search-source or citation state raises `UnsupportedInput` before transport. Preserve the original state through the Responses metadata extension or explicitly choose text-only history. Native Messages continues to forward history for provider validation; other provider profiles retain reduced-history support. diff --git a/docs/web-search.md b/docs/web-search.md index f4413d6..e1b0ff7 100644 --- a/docs/web-search.md +++ b/docs/web-search.md @@ -10,7 +10,7 @@ Molto preserves provider-hosted web search on native routes and translates basic Translated Chat cannot preserve reasoning signatures or encrypted reasoning. The OpenAI Responses streaming search recording contains encrypted reasoning, and Anthropic Messages search turns can contain signed thinking; these turns raise `ValueError("Chat Completions cannot preserve reasoning signatures.")`. Use Responses or Messages downstream to retain that output. Search without reasoning or with unsigned raw reasoning remains representable. The `vllm` profile supports unsigned raw reasoning; provider support for hosted search is separate. Rejection depends on the returned reasoning data, not whether search was requested. Native Chat passthrough is unaffected. -Messages callers receive translated URL citations only with `Molto.messages(..., citation_policy="buffer")`. The default `"drop"` streams text without citations; native Messages passthrough preserves citations in either mode. +Messages callers receive translated URL citations with `Molto.messages(..., citation_policy="buffer")`, or with `citation_policy="auto"` when the normalized request declares hosted search or carries search or citation history. The default `"drop"` streams text without citations; native Messages passthrough preserves citations under every policy. Both streaming and non-streaming calls use this contract. Native routes preserve provider fields and events. Translated non-streaming results collect the same projected events as streaming calls. @@ -45,7 +45,7 @@ Budget exhaustion remains provider-specific. A Messages `max_uses_exceeded` resu Responses URL citations identify a range within a text part. Translated Messages splits that part at citation boundaries and attaches each citation to the corresponding blocks. Concatenating those blocks preserves the answer exactly, including whitespace. Overlapping ranges can associate one source with several blocks. Messages citations map back to ranges spanning their associated text blocks, with offsets local to each projected Responses part. -Messages rejects empty and whitespace-only text blocks in conversation history. Citation projection folds blank fragments into the preceding block, or into the first following block for leading whitespace. Merging can widen a citation's associated block, while its original excerpt remains unchanged. Zero-length annotations remain representable in the normalized output and attach to the block containing their offset; a zero-length annotation at the end of the answer attaches to the final block. Their fallback `cited_text` remains empty because they cover no answer characters. An entirely blank text part has no valid Messages block that can preserve its content and attribution, so projection raises `ValueError`; assistant-history requests reject it with `UnsupportedInput` before transport. +Messages rejects empty and whitespace-only text blocks in conversation history. Citation projection folds blank fragments into the preceding block, or into the first following block for leading whitespace. Merging can widen a citation's associated block, while its original excerpt remains unchanged. Zero-length annotations do not split the text and attach to the block containing their offset; a zero-length annotation at the end of the answer attaches to the final block. Their fallback `cited_text` uses that block's text because they cover no answer characters. An original provider-supplied `cited_text` remains unchanged. An entirely blank text part has no valid Messages block that can preserve its content and attribution, so projection raises `ValueError`; assistant-history requests reject it with `UnsupportedInput` before transport. For example, this upstream Responses text part contains answer `"Alpha. Beta."` with a citation on `[7, 12)`: @@ -86,11 +86,17 @@ It becomes these illustrative Messages content blocks: ] ``` -`cited_text` on this translated route contains the associated **answer range**, which may paraphrase sources. It is not a verified source quotation. `encrypted_index: "N/A"` means citation state is unavailable; it cannot restore provider continuation state. The Anthropic Messages-to-Responses route retains the original excerpt and encrypted reference in the [provider metadata extension](#preserving-anthropic-search-history). Native Messages preserves both directly. Missing or null titles become `""` when the translated target requires a string. +`cited_text` on this translated route contains the associated **answer range**, or the containing block for a zero-length annotation, which may paraphrase sources. It is not a verified source quotation. `encrypted_index: "N/A"` means citation state is unavailable; it cannot restore provider continuation state. The Anthropic Messages-to-Responses route retains the original excerpt and encrypted reference in the [provider metadata extension](#preserving-anthropic-search-history). Native Messages preserves both directly. Missing or null titles become `""` when the translated target requires a string. Offsets use Python string characters without normalization. Recordings verify ASCII ranges; synthetic Unicode tests do not establish the provider's Unicode indexing convention. -With Responses upstream, `citation_policy="buffer"` buffers every Messages text part until completion, including uncited text, then splits it at citation boundaries while preserving part order. This increases text latency and changes chunk boundaries. The default `"drop"` streams text and discards both citation deltas and snapshot citations. Both modes retain server-tool calls and results, whose buffering is needed to obtain complete actions and queries, and preserve input citations during request construction. The policy is a no-op with a Chat upstream: its normalization drops annotations, and both modes preserve immediate text streaming and chunk boundaries. It does not apply to Responses or Chat downstream. A future `"auto"` mode may consult only the normalized request; it is not implemented. +With Responses upstream, `citation_policy` controls translated Messages text: + +- `"drop"` (default) streams text and discards both citation deltas and snapshot citations. +- `"buffer"` waits for every text part to complete, including uncited text, then splits it at citation boundaries while preserving part order. +- `"auto"` behaves like `"buffer"` when the normalized request declares a hosted-search tool or carries search calls, search results, or text with citations in its history. Otherwise it behaves like `"drop"`, including when unexpected citations arrive later. It does not inspect `extra_body`. + +Buffering increases text latency and changes chunk boundaries. All policies retain server-tool calls and results, whose buffering is needed to obtain complete actions and queries, and preserve input citations during request construction. The policy is a no-op with a Chat upstream: its normalization drops annotations, and all policies preserve immediate text streaming and chunk boundaries. It does not apply to Responses or Chat downstream. Each buffered Messages text block starts with empty text and its citations, then emits the full block text in one `text_delta` before stopping. This preserves compatibility with the Anthropic SDK's `text_stream` iterator. @@ -190,7 +196,7 @@ An [independent continuation recording](../tests/cassettes/README.md#anthropic-h ## Follow-ups and recovery -Keep the upstream and provider fixed across a conversation. Native routes preserve search history. The Anthropic Responses route preserves opaque search state when clients retain its metadata extension; other translated Responses/Messages history follows the reductions above. Translated Chat history retains only visible text and supported function calls. History can carry citations without redeclaring search. Citation buffering is never inferred from history or tools. A Messages caller who wants attribution selects `citation_policy="buffer"`, including when search context is supplied only through `extra_body.input` or `extra_body.tools`. +Keep the upstream and provider fixed across a conversation. Native routes preserve search history. The Anthropic Responses route preserves opaque search state when clients retain its metadata extension; other translated Responses/Messages history follows the reductions above. Translated Chat history retains only visible text and supported function calls. History can carry citations without redeclaring search, and `citation_policy="auto"` uses that normalized history to select buffering. A Messages caller who wants attribution regardless of normalized tools or history selects `citation_policy="buffer"`, including when search context is supplied only through `extra_body.input` or `extra_body.tools`. Opaque transport extensions cannot trigger automatic buffering. When translating history to Messages with `provider="anthropic"` (the endpoint default), Molto rejects missing, empty, or `"N/A"` source `encrypted_content` and citation `encrypted_index` with `UnsupportedInput` before transport. Anthropic requires the original encrypted state for continuation; a display placeholder cannot restore it. Preserve the metadata extension or explicitly choose text-only history. This check applies even without new search tools. Empty results, unknown results, and search errors do not require source ciphertext. Other provider profiles retain their existing reduced-history behavior, and native Messages passthrough leaves validation to the provider. Molto does not inspect other opaque values or validate their issuer; transport overrides remain opaque. diff --git a/python/ARCHITECTURE.md b/python/ARCHITECTURE.md index e4d8311..c9e1283 100644 --- a/python/ARCHITECTURE.md +++ b/python/ARCHITECTURE.md @@ -350,7 +350,7 @@ The normalized termination reason maps shared semantics explicitly: normal compl Chat projection rejects reasoning signatures and encrypted reasoning based on output data, including signature-only parts. Search declarations and transport overrides do not change that rule; turns without reasoning or with unsigned raw reasoning remain representable. -`TextOutput.citations` holds `WebCitation` ranges local to each text part. `CitationDelta` adds attribution, and the final snapshot is authoritative. Citation buffering belongs to the Messages adapter, exposed by the `Molto.messages()` argument `citation_policy`, which accepts `"buffer"` or `"drop"`. With Responses upstream, the default `"drop"` streams text and discards attribution; `"buffer"` waits for every text part's completed snapshot, including uncited text, before splitting it at citation boundaries. The coordinator selects `"drop"` for Chat upstream regardless of the requested policy, preserving immediate text streaming and chunk boundaries because Chat normalization discards annotations. The policy never changes request construction or native passthrough. The Responses normalizer and Messages projector buffer unresolved search parts in start order, allowing ordinary parts to stream when they reach the front. No upstream client predicts citation buffering, and opaque extensions cannot influence it. Any future `"auto"` policy must inspect only the normalized request; callers supplying search context only through `extra_body` must explicitly select `"buffer"` for attribution. Projection assigns output indices because parts can expand or disappear. +`TextOutput.citations` holds `WebCitation` ranges local to each text part. `CitationDelta` adds attribution, and the final snapshot is authoritative. Citation buffering belongs to the Messages adapter, exposed by the `Molto.messages()` argument `citation_policy`, which accepts `"buffer"`, `"auto"`, or `"drop"`. With Responses upstream, the default `"drop"` streams text and discards attribution; `"buffer"` waits for every text part's completed snapshot, including uncited text, before splitting it at citation boundaries. `"auto"` buffers when the normalized request declares hosted search or carries search calls, search results, or text with citations in its history; otherwise text streams and unexpected citations are dropped. The coordinator selects `"drop"` for Chat upstream regardless of the requested policy, preserving immediate text streaming and chunk boundaries because Chat normalization discards annotations. The policy never changes request construction or native passthrough. The Responses normalizer and Messages projector buffer unresolved search parts in start order, allowing ordinary parts to stream when they reach the front. No upstream client predicts citation buffering, and opaque extensions cannot influence it. The `"auto"` policy inspects only the normalized request; callers supplying search context only through `extra_body` must explicitly select `"buffer"` for attribution. Projection assigns output indices because parts can expand or disappear. The [hosted-search contract](../docs/web-search.md) defines supported routes, reductions, failures, usage, and continuation. diff --git a/python/molto/adapters/anthropic.py b/python/molto/adapters/anthropic.py index 82bad3b..2da49b5 100644 --- a/python/molto/adapters/anthropic.py +++ b/python/molto/adapters/anthropic.py @@ -9,7 +9,7 @@ from collections import deque from collections.abc import AsyncIterator from contextlib import aclosing -from typing import Any, Literal, NoReturn +from typing import Any, Literal, NoReturn, TypeAlias from anthropic import APIError as AnthropicAPIError from anthropic import APIStatusError as AnthropicAPIStatusError @@ -90,6 +90,8 @@ "unknown": "end_turn", } +CitationPolicy: TypeAlias = Literal["auto", "buffer", "drop"] + _EXTENDED_THINKING_EFFORTS: tuple[tuple[int, ReasoningEffort], ...] = ( (1024, "minimal"), (2048, "low"), @@ -157,9 +159,24 @@ def _citations(citations: list[dict[str, Any]] | None, text: str) -> list[WebCit return result +def _citations_are_possible(request: NormalizedRequest) -> bool: + """Report whether normalized tools or history signal possible URL citations.""" + if any(isinstance(tool, WebSearchTool) for tool in request.tools or []): + return True + for item in request.input or []: + parts = item.content if isinstance(item, AssistantInput) else [item] + for part in parts: + if isinstance(part, (SearchCall, SearchResult)): + return True + if isinstance(part, TextOutput) and part.citations: + return True + return False + + async def _ordered_parts( events: AsyncIterator[ModelResponseStreamEvent], - citation_policy: Literal["buffer", "drop"], + *, + buffer_text: bool, ) -> AsyncIterator[ModelResponseStreamEvent]: """Stream parts in start order, yielding only the end for snapshot-based parts.""" # Responses may stream "Alpha. Beta." before citing only "Beta.". Messages @@ -168,8 +185,8 @@ async def _ordered_parts( # us to expand a query batch into searches or omit browsing. # If part A expands into two blocks, part B must follow both, so later parts # wait to preserve order and receive the correct consecutive block indices. - # The explicit citation policy determines whether text waits for its final - # snapshot; upstream request extensions never select that behavior. + # The adapter resolves text buffering from the policy and normalized request; + # upstream transport extensions never select that behavior. pending: dict[int, deque[ModelResponseStreamEvent]] = {} snapshots: set[int] = set() async for event in events: @@ -182,7 +199,7 @@ async def _ordered_parts( pending[event.index] = deque() part = event.part if isinstance(part, (SearchCall, SearchResult)) or ( - isinstance(part, TextOutput) and citation_policy == "buffer" + isinstance(part, TextOutput) and buffer_text ): snapshots.add(event.index) if isinstance(event, PartEndEvent): @@ -219,10 +236,10 @@ def __init__( self, capabilities: ProviderCapabilities, *, - citation_policy: Literal["buffer", "drop"] = "drop", + citation_policy: CitationPolicy = "drop", ) -> None: super().__init__(capabilities) - if citation_policy not in {"buffer", "drop"}: + if citation_policy not in {"auto", "buffer", "drop"}: raise ValueError(f"Unsupported citation policy: {citation_policy!r}.") self._citation_policy = citation_policy @@ -641,7 +658,10 @@ def event(data: dict[str, Any]) -> AnthropicMessagesStreamEvent: thinking_parts: set[int] = set() thinking_signatures: dict[int, str] = {} - async with aclosing(_ordered_parts(events, self._citation_policy)) as ordered_events: + buffer_text = self._citation_policy == "buffer" or ( + self._citation_policy == "auto" and _citations_are_possible(request) + ) + async with aclosing(_ordered_parts(events, buffer_text=buffer_text)) as ordered_events: async for normalized_event in ordered_events: if isinstance(normalized_event, ResponseStartEvent): yield event( diff --git a/python/molto/core.py b/python/molto/core.py index 076b6fd..ad10e9c 100644 --- a/python/molto/core.py +++ b/python/molto/core.py @@ -1,10 +1,10 @@ from collections.abc import AsyncIterator, Mapping -from typing import Any, Literal, Self +from typing import Any, Self from httpx import AsyncClient, Headers from pydantic import BaseModel -from molto.adapters.anthropic import AnthropicMessagesAdapter +from molto.adapters.anthropic import AnthropicMessagesAdapter, CitationPolicy from molto.adapters.openai import OpenAIChatCompletionsAdapter, OpenAIResponsesAdapter from molto.configs import ENV_CONFIG from molto.providers import ( @@ -346,7 +346,7 @@ async def messages( base_url: str | None = None, extra_body: Mapping[str, Any] | None = None, extra_headers: Mapping[str, str] | None = None, - citation_policy: Literal["buffer", "drop"] = "drop", + citation_policy: CitationPolicy = "drop", ) -> MoltoStream[AnthropicMessagesStreamEvent] | AnthropicMessagesResponse: """Consume an Anthropic Messages request using any supported upstream protocol. @@ -354,8 +354,10 @@ async def messages( implementation and its capabilities, and `base_url` selects only the HTTP destination. With Responses upstream, `citation_policy="buffer"` withholds text until each part completes to preserve citation boundaries; - the default `"drop"` streams text without citations. The policy has no - effect with Chat upstream or native Messages. + `"auto"` buffers only when normalized tools or history signal possible + citations, otherwise streaming text without citations. The default `"drop"` + always streams text without citations. Transport extensions do not inform + `"auto"`. The policy has no effect with Chat upstream or native Messages. """ request = AnthropicMessagesCreateRequest.model_validate(body) resolved_provider = self._resolve_provider(endpoint, provider) diff --git a/python/molto/utils/web_search.py b/python/molto/utils/web_search.py index 9a636c6..2ebab0c 100644 --- a/python/molto/utils/web_search.py +++ b/python/molto/utils/web_search.py @@ -20,13 +20,14 @@ def messages_text_blocks( text = part.content boundaries = {0, len(text)} for citation in part.citations: - boundaries.add(citation.start_index) - boundaries.add(citation.end_index) - - if any(offset < 0 or offset > len(text) for offset in boundaries): - raise ValueError("Citation offsets fall outside the associated answer text.") - if any(citation.end_index < citation.start_index for citation in part.citations): - raise ValueError("Citation end precedes its start.") + if not (0 <= citation.start_index <= len(text) and 0 <= citation.end_index <= len(text)): + raise ValueError("Citation offsets fall outside the associated answer text.") + if citation.end_index < citation.start_index: + raise ValueError("Citation end precedes its start.") + # A point names a position, not a range, so it must not split the text. + if citation.start_index != citation.end_index: + boundaries.add(citation.start_index) + boundaries.add(citation.end_index) if not text.strip(): raise ValueError("Messages text blocks require non-whitespace text.") @@ -54,7 +55,9 @@ def messages_text_blocks( else: matches = citation.start_index < end and start < citation.end_index if matches: - block_citations.append(citation_to_messages(citation, text=text)) + block_citations.append( + citation_to_messages(citation, text=text, block_start=start, block_end=end) + ) if block_citations: block["citations"] = block_citations @@ -113,15 +116,19 @@ def citation_to_responses( return annotation -def citation_to_messages(citation: WebCitation, *, text: str) -> dict[str, Any]: +def citation_to_messages( + citation: WebCitation, *, text: str, block_start: int, block_end: int +) -> dict[str, Any]: """Build a Messages citation with the documented answer-range/state fallbacks.""" + cited_text = citation.cited_text + if cited_text is None: + # A point has no excerpt of its own; use the block carrying its attribution. + cited_text = text[citation.start_index : citation.end_index] or text[block_start:block_end] return { "type": "web_search_result_location", "url": citation.url, "title": citation.title if citation.title is not None else "", - "cited_text": citation.cited_text - if citation.cited_text is not None - else text[citation.start_index : citation.end_index], + "cited_text": cited_text, "encrypted_index": citation.encrypted_index if citation.encrypted_index is not None else "N/A", diff --git a/tests/adapters/test_anthropic_search.py b/tests/adapters/test_anthropic_search.py index 29a36d9..9b4adc4 100644 --- a/tests/adapters/test_anthropic_search.py +++ b/tests/adapters/test_anthropic_search.py @@ -7,6 +7,7 @@ from molto.types.anthropic_messages import AnthropicMessagesCreateRequest from molto.types.exceptions import UnsupportedInput from molto.types.normalized import ( + AssistantInput, CitationDelta, FunctionToolChoice, InputTokenDetails, @@ -53,9 +54,9 @@ async def iterate(events): yield event -@pytest.mark.parametrize("citation_policy", ["buffer", "drop"]) +@pytest.mark.parametrize("citation_policy", ["buffer", "drop", "auto"]) async def test_citation_policy_controls_text_latency_without_search(citation_policy): - """Ensures only the explicit policy determines whether ordinary text waits for its end.""" + """Ensures buffer waits for ordinary text while auto and drop preserve immediate deltas.""" adapter, request = setup_adapter(citation_policy) completed = False @@ -83,12 +84,12 @@ async def source(): assert deltas == ["Alpha. ", "Beta."] -@pytest.mark.parametrize("citation_policy", ["buffer", "drop"]) +@pytest.mark.parametrize("citation_policy", ["buffer", "drop", "auto"]) @pytest.mark.parametrize("citation_delivery", ["start", "delta", "end"]) async def test_explicit_citation_policy_handles_all_citation_delivery( citation_policy, citation_delivery ): - """Ensures buffer preserves final attribution and drop omits even late or initial citations.""" + """Ensures buffer preserves attribution while drop and unsignaled auto omit unexpected citations.""" adapter, request = setup_adapter(citation_policy) citation = WebCitation(url="https://a", start_index=7, end_index=12) events = [ @@ -122,7 +123,50 @@ async def test_explicit_citation_policy_handles_all_citation_delivery( def test_citation_policy_rejects_unknown_mode(): """Ensures an unsupported policy cannot silently discard requested attribution.""" with pytest.raises(ValueError, match="Unsupported citation policy"): - setup_adapter(citation_policy="auto") + setup_adapter(citation_policy="invalid") + + +@pytest.mark.parametrize("signal", ["tool", "search_call", "search_result", "citation"]) +async def test_auto_buffers_text_when_request_announces_citations(signal): + """Ensures tools or history select auto buffering before text and late citations arrive.""" + adapter, request = setup_adapter("auto") + citation = WebCitation(url="https://a", start_index=7, end_index=12) + if signal == "tool": + request.tools = [WebSearchTool()] + else: + part = { + "search_call": SearchCall(id="s", query="q"), + "search_result": SearchResult(tool_call_id="s", sources=[]), + "citation": TextOutput(content="Alpha. Beta.", citations=[citation]), + }[signal] + request.input = [AssistantInput(content=[part])] + completed = False + + async def source(): + nonlocal completed + yield ResponseStartEvent(id="r", model="m") + yield PartStartEvent(index=0, part=TextOutput(content="")) + yield PartDeltaEvent(index=0, delta=TextDelta(content_delta="Alpha. ")) + yield PartDeltaEvent(index=0, delta=TextDelta(content_delta="Beta.")) + yield PartDeltaEvent(index=0, delta=CitationDelta(citation=citation)) + completed = True + yield PartEndEvent(index=0, part=TextOutput(content="Alpha. Beta.", citations=[citation])) + yield ResponseEndEvent(id="r", model="m", created_at=0, termination_reason="stop") + + stream = adapter.project_events(source(), request) + assert (await anext(stream)).type == "message_start" + start = await anext(stream) + assert start.type == "content_block_start" + assert completed + events = [start, *[event async for event in stream]] + blocks = [event.content_block for event in events if event.type == "content_block_start"] + assert len(blocks) == 2 + assert not blocks[0].citations + assert blocks[1].citations[0].cited_text == "Beta." + assert [event.delta.text for event in events if event.type == "content_block_delta"] == [ + "Alpha. ", + "Beta.", + ] @pytest.mark.parametrize("hosted_first", [True, False]) @@ -439,7 +483,7 @@ def test_citation_boundaries_preserve_nonblank_blocks( ] for block, sources in zip(blocks, expected_sources, strict=True): assert [citation["cited_text"] for citation in block.get("citations", [])] == [ - text[ranges[index][0] : ranges[index][1]] for index in sources + text[ranges[index][0] : ranges[index][1]] or block["text"] for index in sources ] AnthropicMessagesCreateRequest.model_validate( {"model": "m", "max_tokens": 100, "messages": [{"role": "assistant", "content": blocks}]} @@ -450,7 +494,7 @@ def test_citation_boundaries_preserve_nonblank_blocks( "text, offset, expected_text, citation_index", [ ("abc", 0, ["abc"], 0), - ("abc", 1, ["a", "bc"], 1), + ("abc", 1, ["abc"], 0), ("abc", 3, ["abc"], 0), ], ) @@ -466,7 +510,7 @@ def test_point_citation_preserves_attribution(text, offset, expected_text, citat assert "".join(block["text"] for block in blocks) == text assert all(block["text"].strip() for block in blocks) assert blocks[citation_index]["citations"][0]["url"] == "https://x" - assert blocks[citation_index]["citations"][0]["cited_text"] == "" + assert blocks[citation_index]["citations"][0]["cited_text"] == blocks[citation_index]["text"] assert all( "citations" not in block for index, block in enumerate(blocks) if index != citation_index ) @@ -475,6 +519,39 @@ def test_point_citation_preserves_attribution(text, offset, expected_text, citat ) +@pytest.mark.parametrize("offset", [-1, 4]) +def test_point_citation_rejects_offsets_outside_text(offset): + """Ensures excluding point citations from splitting does not bypass offset validation.""" + part = TextOutput( + content="abc", + citations=[WebCitation(url="https://x", start_index=offset, end_index=offset)], + ) + with pytest.raises(ValueError, match="Citation offsets fall outside"): + messages_text_blocks(part) + + +def test_point_citation_preserves_observed_source_state(): + """Ensures a point's observed source excerpt and encrypted reference beat answer fallbacks.""" + blocks = messages_text_blocks( + TextOutput( + content="abc", + citations=[ + WebCitation( + url="https://x", + start_index=1, + end_index=1, + cited_text="source excerpt", + encrypted_index="original-reference", + ) + ], + ) + ) + assert len(blocks) == 1 + assert blocks[0]["text"] == "abc" + assert blocks[0]["citations"][0]["cited_text"] == "source excerpt" + assert blocks[0]["citations"][0]["encrypted_index"] == "original-reference" + + @pytest.mark.parametrize("text", ["", " ", "\n\t"]) @pytest.mark.parametrize("cited", [False, True]) def test_blank_text_cannot_be_projected_losslessly(text, cited): diff --git a/tests/adapters/test_common.py b/tests/adapters/test_common.py index 595280e..f3def4c 100644 --- a/tests/adapters/test_common.py +++ b/tests/adapters/test_common.py @@ -12,18 +12,27 @@ @pytest.mark.parametrize( - ("termination_reason", "chat_reason", "messages_reason", "responses_reason"), + ( + "termination_reason", + "chat_reason", + "messages_reason", + "responses_status", + "responses_reason", + ), [ - ("max_output_tokens", "length", "max_tokens", "max_output_tokens"), - ("content_filter", "content_filter", "end_turn", "content_filter"), - ("unknown", "stop", "end_turn", None), - ("pause", "length", "pause_turn", None), + ("max_output_tokens", "length", "max_tokens", "incomplete", "max_output_tokens"), + ("content_filter", "content_filter", "end_turn", "incomplete", "content_filter"), + ("unknown", "stop", "end_turn", "incomplete", None), + ("pause", "length", "pause_turn", "incomplete", None), + ("stop", "stop", "end_turn", "completed", None), + ("refusal", "stop", "refusal", "completed", None), ], ) -async def test_incomplete_termination_projections( +async def test_termination_projections( termination_reason: TerminationReason, chat_reason: str, messages_reason: str, + responses_status: str, responses_reason: str | None, ) -> None: """Ensures all adapters preserve explicit reasons and distinguish unknown from pause.""" @@ -66,8 +75,8 @@ async def normalized_events(): event async for event in responses.project_events(normalized_events(), request) ] terminal = response_events[-1] - assert terminal.type == "response.incomplete" - assert terminal.response.status == "incomplete" + assert terminal.type == f"response.{responses_status}" + assert terminal.response.status == responses_status assert terminal.response.model_dump()["incomplete_details"] == ( {"reason": responses_reason} if responses_reason is not None else None ) diff --git a/tests/core/test_citation_policy.py b/tests/core/test_citation_policy.py index 5413334..3c76003 100644 --- a/tests/core/test_citation_policy.py +++ b/tests/core/test_citation_policy.py @@ -17,11 +17,11 @@ ) -@pytest.mark.parametrize("citation_policy", [None, "drop", "buffer"]) +@pytest.mark.parametrize("citation_policy", [None, "drop", "buffer", "auto"]) @pytest.mark.parametrize("stream", [True, False]) @pytest.mark.parametrize("tools_in_extra_body", [False, True]) async def test_messages_citation_policy_matches_sdk(citation_policy, stream, tools_in_extra_body): - """Ensures only citation policy selects buffering, including with opaque search extensions.""" + """Ensures auto uses declared search while buffer also preserves opaque search citations.""" body, expected_request = _request_pair("messages", "responses", stream) extra_body = None if tools_in_extra_body: @@ -32,7 +32,10 @@ async def test_messages_citation_policy_matches_sdk(citation_policy, stream, too expected_request.update(extra_body) expected = _messages_semantics(_collect("messages", await _sdk_result("messages", True))) sdk_events = await _sdk_result("responses", True) - if citation_policy != "buffer": + buffer_text = citation_policy == "buffer" or ( + citation_policy == "auto" and not tools_in_extra_body + ) + if not buffer_text: content = [] for block in expected["content"]: if block["type"] != "text": @@ -57,7 +60,7 @@ async def test_messages_citation_policy_matches_sdk(citation_policy, stream, too events = [event async for event in result] _assert_lifecycle("messages", events) actual = _collect("messages", events) - if citation_policy != "buffer": + if not buffer_text: assert [ event.delta.text for event in events @@ -72,7 +75,8 @@ async def test_messages_citation_policy_matches_sdk(citation_policy, stream, too assert _messages_semantics(actual) == expected -async def test_buffered_citations_preserve_sdk_text_stream(): +@pytest.mark.parametrize("citation_policy", ["buffer", "auto"]) +async def test_buffered_citations_preserve_sdk_text_stream(citation_policy): """Ensures SDK text iteration exposes the recorded answer without losing citation blocks.""" body, expected_request = _request_pair("messages", "responses", True) reference = _collect("responses", await _sdk_result("responses", True)) @@ -83,7 +87,7 @@ async def test_buffered_citations_preserve_sdk_text_stream(): body=body, endpoint="responses", api_key="test-api-key", - citation_policy="buffer", + citation_policy=citation_policy, ) events = [event async for event in stream] @@ -108,7 +112,7 @@ async def test_buffered_citations_preserve_sdk_text_stream(): ] -@pytest.mark.parametrize("citation_policy", ["drop", "buffer"]) +@pytest.mark.parametrize("citation_policy", ["drop", "buffer", "auto"]) @pytest.mark.parametrize("stream", [True, False]) async def test_native_messages_ignore_citation_policy(citation_policy, stream): """Ensures projection policy never changes native Messages requests or SDK responses.""" diff --git a/tests/core/test_web_search.py b/tests/core/test_web_search.py index 0568267..27ad6d3 100644 --- a/tests/core/test_web_search.py +++ b/tests/core/test_web_search.py @@ -650,7 +650,7 @@ def handler(request: Request) -> Response: @pytest.mark.parametrize( ("downstream", "citation_policy"), - [("responses", None), ("messages", "drop"), ("messages", "buffer")], + [("responses", None), ("messages", "drop"), ("messages", "buffer"), ("messages", "auto")], ) @pytest.mark.parametrize("stream", [True, False]) @pytest.mark.parametrize("synthetic_annotations", [False, True]) @@ -662,7 +662,7 @@ async def test_chat_upstream_extra_search_reaches_transport( synthetic_annotations: bool, extra_search: bool, ): - """Ensures Chat preserves SDK text chunks under either policy, including opaque search.""" + """Ensures Chat preserves SDK text chunks under every policy, including opaque search.""" body = {"model": "test-model", "stream": stream} if downstream == "responses": body["input"] = "Search" @@ -1551,7 +1551,7 @@ async def test_synthetic_pause_has_no_invented_result(stream: bool, downstream: ("A😀βé Z", [(0, 4), (2, 6)], ["A😀", "βé", " Z"], [[0], [0, 1], [1]]), ("abc de f", [(0, 6), (7, 8)], ["abc de ", "f"], [[0], [1]]), ("abc", [(0, 0)], ["abc"], [[0]]), - ("abc", [(1, 1)], ["a", "bc"], [[], [0]]), + ("abc", [(1, 1)], ["abc"], [[0]]), ("abc", [(3, 3)], ["abc"], [[0]]), ], ids=["unicode-overlap", "whitespace-gap", "point-start", "point-middle", "point-end"], @@ -1617,7 +1617,9 @@ async def test_synthetic_citation_boundaries_match_sdk_and_history( for block in blocks: for citation in block.citations or []: source = next(source for source in sources if source.url == citation.url) - assert citation.cited_text == text[source.start_index : source.end_index] + assert citation.cited_text == ( + text[source.start_index : source.end_index] or block.text + ) assert citation.encrypted_index == "N/A" assert citation.title == (source.title or "") @@ -1917,11 +1919,11 @@ async def test_synthetic_repeated_searches_preserve_identity(stream: bool): @pytest.mark.parametrize("stream", [True, False]) @pytest.mark.parametrize("override", [False, True]) @pytest.mark.parametrize("new_search", [False, True]) -@pytest.mark.parametrize("citation_policy", ["drop", "buffer"]) -async def test_search_history_uses_explicit_citation_policy( +@pytest.mark.parametrize("citation_policy", ["drop", "buffer", "auto"]) +async def test_search_history_uses_citation_policy( stream: bool, override: bool, new_search: bool, citation_policy: str ): - """Ensures typed or opaque history preserves input attribution without selecting output buffering.""" + """Ensures auto buffers typed history only while buffer also preserves opaque-history citations.""" body, expected = _request_pair("messages", "responses", stream) for request in (body, expected): request.pop("tools") @@ -1959,7 +1961,7 @@ async def test_search_history_uses_explicit_citation_policy( text_blocks = [block for block in result.content if block.type == "text"] assert "".join(block.text for block in text_blocks) == reference.output_text citations = [citation for block in text_blocks for citation in block.citations or []] - if citation_policy == "drop": + if citation_policy == "drop" or (citation_policy == "auto" and override): assert citations == [] return annotation = ( diff --git a/tests/test_match_anthropic_cassette_responses.py b/tests/test_match_anthropic_cassette_responses.py index 215fdf6..527c980 100644 --- a/tests/test_match_anthropic_cassette_responses.py +++ b/tests/test_match_anthropic_cassette_responses.py @@ -364,7 +364,7 @@ def test_search_response_maps_sources_citations_and_usage(sources: list) -> None @pytest.mark.parametrize("offset", [0, 3, 6]) def test_search_response_preserves_point_citations(offset: int) -> None: - """Ensures point and overlapping range attribution survive in nonblank replayable blocks.""" + """Ensures point citations keep one block and use its text as their fallback excerpt.""" text = "answer" messages, responses = _search_cassettes() annotations = [ @@ -391,12 +391,10 @@ def test_search_response_preserves_point_citations(offset: int) -> None: body = messages["response"]["body"] AnthropicMessagesResponse.model_validate(body) blocks = [block for block in body["content"] if block["type"] == "text"] - assert "".join(block["text"] for block in blocks) == text - assert all(block["text"].strip() for block in blocks) - point_index = 1 if offset == 3 else 0 + assert [block["text"] for block in blocks] == [text] assert [ citation - for citation in blocks[point_index]["citations"] + for citation in blocks[0]["citations"] if citation["url"] == "https://example.org/point" ] == [ { @@ -404,13 +402,11 @@ def test_search_response_preserves_point_citations(offset: int) -> None: "url": "https://example.org/point", "title": "", "encrypted_index": "N/A", - "cited_text": "", + "cited_text": text, } ] assert [[citation["url"] for citation in block["citations"]] for block in blocks] == [ - (["https://example.org/point"] if index == point_index else []) - + ["https://example.org/range"] - for index in range(len(blocks)) + ["https://example.org/point", "https://example.org/range"] ] AnthropicMessagesCreateRequest.model_validate( {