Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [UNRELEASED]

### Bug fixes

* `ChatAnthropic()` citations backed by `document_index` (from `tool_web_fetch()` results and document/PDF attachments) now resolve to a source URL, instead of always coming back without one. Anthropic counts that index across every document-shaped block in the whole request, including ones from prior turns, which chatlas wasn't accounting for. (#382)
* Token cost lookups (`.get_cost()`, `token_usage()`) no longer crash on models with no input price (e.g. output-only video generation models on Bedrock), and read ellmer's current pricing data format, which now wraps the price list in a versioned envelope rather than a bare array. (#382)

Comment on lines +13 to +17
## [0.21.0] - 2026-08-04

### New features
Expand Down
36 changes: 22 additions & 14 deletions chatlas/_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2868,9 +2868,8 @@ 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")

chat_span = start_chat_span(
self.provider, [*self._turns, user_turn], _otel_parent
)
request_turns = [*self._turns, user_turn]
chat_span = start_chat_span(self.provider, request_turns, _otel_parent)
try:

def emit(x: str | Content):
Expand Down Expand Up @@ -2905,7 +2904,7 @@ def emit(x: str | Content):
with activate_span(chat_span):
response = self.provider.chat_perform(
stream=True,
turns=[*self._turns, user_turn],
turns=request_turns,
tools=self._tools,
data_model=data_model,
kwargs=all_kwargs,
Expand All @@ -2926,7 +2925,9 @@ def emit(x: str | Content):
if controller.cancelled:
break
result = self.provider.stream_merge_chunks(result, chunk)
for content in self.provider.stream_content(chunk, result):
for content in self.provider.stream_content(
chunk, result, turns=request_turns
):
yield from acc.process_content(
content, display_text(content), content_mode, emit
)
Expand All @@ -2937,6 +2938,7 @@ def emit(x: str | Content):
turn = self.provider.stream_turn(
result,
has_data_model=data_model is not None,
turns=request_turns,
)
emit_image_contents(turn, emit)
if echo == "all":
Expand All @@ -2952,14 +2954,16 @@ def emit(x: str | Content):
with activate_span(chat_span):
response = self.provider.chat_perform(
stream=False,
turns=[*self._turns, user_turn],
turns=request_turns,
tools=self._tools,
data_model=data_model,
kwargs=all_kwargs,
)

turn = self.provider.value_turn(
response, has_data_model=data_model is not None
response,
has_data_model=data_model is not None,
turns=request_turns,
)
emit_thinking_contents(turn, emit)
emit_web_contents(turn, emit)
Expand Down Expand Up @@ -3021,9 +3025,8 @@ async def _submit_turns_async(
*,
controller: StreamController,
) -> AsyncGenerator[str | Content, None]:
chat_span = start_chat_span(
self.provider, [*self._turns, user_turn], _otel_parent
)
request_turns = [*self._turns, user_turn]
chat_span = start_chat_span(self.provider, request_turns, _otel_parent)
try:

def emit(x: str | Content):
Expand Down Expand Up @@ -3058,7 +3061,7 @@ def emit(x: str | Content):
with activate_span(chat_span):
response = await self.provider.chat_perform_async(
stream=True,
turns=[*self._turns, user_turn],
turns=request_turns,
tools=self._tools,
data_model=data_model,
kwargs=all_kwargs,
Expand All @@ -3079,7 +3082,9 @@ def emit(x: str | Content):
if controller.cancelled:
break
result = self.provider.stream_merge_chunks(result, chunk)
for content in self.provider.stream_content(chunk, result):
for content in self.provider.stream_content(
chunk, result, turns=request_turns
):
for item in acc.process_content(
content, display_text(content), content_mode, emit
):
Expand All @@ -3092,6 +3097,7 @@ def emit(x: str | Content):
turn = self.provider.stream_turn(
result,
has_data_model=data_model is not None,
turns=request_turns,
)
emit_image_contents(turn, emit)
if echo == "all":
Expand All @@ -3107,14 +3113,16 @@ def emit(x: str | Content):
with activate_span(chat_span):
response = await self.provider.chat_perform_async(
stream=False,
turns=[*self._turns, user_turn],
turns=request_turns,
tools=self._tools,
data_model=data_model,
kwargs=all_kwargs,
)

turn = self.provider.value_turn(
response, has_data_model=data_model is not None
response,
has_data_model=data_model is not None,
turns=request_turns,
)
emit_thinking_contents(turn, emit)
emit_web_contents(turn, emit)
Expand Down
9 changes: 9 additions & 0 deletions chatlas/_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ def stream_content(
self,
chunk: ChatCompletionChunkT,
completion: Optional[ChatCompletionDictT],
turns: Sequence[Turn] = (),
) -> "Sequence[Content]":
"""
Content to yield for `chunk`.
Expand All @@ -289,6 +290,12 @@ def stream_content(
single provider instance is shared across forked chats
(`Chat.__deepcopy__` keeps `provider` by reference), so several streams
can be in flight at once.

`turns` is the full request history (prior turns plus the newest user
turn) that produced this stream, for providers that need to resolve
content referencing something outside `completion` itself (e.g.
Anthropic's `document_index` citations, which can reference a document
from an earlier turn). Defaults to `()` for callers that don't have it.
"""
...

Expand All @@ -304,13 +311,15 @@ def stream_turn(
self,
completion: ChatCompletionDictT,
has_data_model: bool,
turns: Sequence[Turn] = (),
) -> AssistantTurn[ChatCompletionT]: ...

@abstractmethod
def value_turn(
self,
completion: ChatCompletionT,
has_data_model: bool,
turns: Sequence[Turn] = (),
) -> AssistantTurn[ChatCompletionT]: ...

@abstractmethod
Expand Down
96 changes: 84 additions & 12 deletions chatlas/_provider_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
from typing import IO

from anthropic.types import (
ContentBlock,
Message,
MessageParam,
RawMessageStreamEvent,
Expand Down Expand Up @@ -592,7 +593,7 @@ def _structured_tool_call(**kwargs: Any):

return data_model_tool

def stream_content(self, chunk, completion) -> list[Content]:
def stream_content(self, chunk, completion, turns=()) -> list[Content]:
if chunk.type == "content_block_delta":
if chunk.delta.type == "text_delta":
return [ContentText.model_construct(text=chunk.delta.text)]
Expand All @@ -615,7 +616,14 @@ def stream_content(self, chunk, completion) -> list[Content]:
block = completion.content[chunk.index]
if block.type == "text":
# list() widens list[ContentCitation] to the list[Content] return
return list(anthropic_citations(block))
return list(
anthropic_citations(
block,
fetch_sources=anthropic_document_sources(
turns, completion.content
),
)
)
if block.type == "server_tool_use":
request = anthropic_server_tool_request(block)
return [request] if request is not None else []
Expand Down Expand Up @@ -673,11 +681,11 @@ def stream_merge_chunks(self, completion, chunk):

return completion

def stream_turn(self, completion, has_data_model):
return self._as_turn(completion, has_data_model)
def stream_turn(self, completion, has_data_model, turns=()):
return self._as_turn(completion, has_data_model, turns)

def value_turn(self, completion, has_data_model):
return self._as_turn(completion, has_data_model)
def value_turn(self, completion, has_data_model, turns=()):
return self._as_turn(completion, has_data_model, turns)

def value_tokens(self, completion):
usage = completion.usage
Expand Down Expand Up @@ -1035,13 +1043,19 @@ def _anthropic_tool_schema(self, tool: "Tool | ToolBuiltIn") -> "ToolUnionParam"

return res

def _as_turn(self, completion: Message, has_data_model=False) -> AssistantTurn:
def _as_turn(
self,
completion: Message,
has_data_model=False,
turns: Sequence[Turn] = (),
) -> AssistantTurn:
finish_reason = normalize_finish_reason(completion.stop_reason)
if has_data_model:
# Must precede the JSON parse below; see check_finish_reason().
check_finish_reason(finish_reason, "error")

contents = []
fetch_sources = anthropic_document_sources(turns, completion.content)

# Detect which structured output approach was used:
# - Old approach: has a _structured_tool_call tool_use block
Expand All @@ -1058,7 +1072,7 @@ def _as_turn(self, completion: Message, has_data_model=False) -> AssistantTurn:
contents.append(ContentJson(value=orjson.loads(content.text)))
else:
contents.append(ContentText(text=content.text))
contents.extend(anthropic_citations(content))
contents.extend(anthropic_citations(content, fetch_sources))
elif content.type == "tool_use":
if uses_old_tool_approach and content.name == "_structured_tool_call":
if not isinstance(content.input, dict):
Expand Down Expand Up @@ -1544,18 +1558,35 @@ def list_models(self):
return res


def anthropic_citations(block: "TextBlock") -> list[ContentCitation]:
def anthropic_citations(
block: "TextBlock",
fetch_sources: Sequence[Optional[WebSource]] = (),
) -> 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).
url = getattr(c, "url", None)
source = None
if url:
source = WebSource(url=url, title=getattr(c, "title", None))
else:
document_index = getattr(c, "document_index", None)
if (
isinstance(document_index, int)
and not isinstance(document_index, bool)
and 0 <= document_index < len(fetch_sources)
):
resolved = fetch_sources[document_index]
if resolved is not None:
source = WebSource(
url=resolved.url,
title=getattr(c, "document_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,
Expand All @@ -1565,6 +1596,47 @@ def anthropic_citations(block: "TextBlock") -> list[ContentCitation]:
return out


def anthropic_document_sources(
turns: Sequence[Turn], contents: Sequence["ContentBlock"]
) -> list[Optional[WebSource]]:
"""Ordered document slots for `document_index` citations in `contents`.

Anthropic's `document_index` counts every document-shaped content block
across the *whole* request (spanning prior turns), not just this
completion's own fetches -- a `web_fetch_tool_result`'s embedded document
and a user-attached PDF/text document share one index space. `turns` (the
prior history plus the newest user turn) supplies everything that
precedes this completion in that space; `contents` supplies this
completion's own new fetches, appended last. A slot with no representable
URL (e.g. an uploaded PDF) stays `None` so later slots still land on the
right index -- it never fabricates a source.
"""
from anthropic.types import WebFetchBlock

sources: list[Optional[WebSource]] = []
for turn in turns:
if isinstance(turn, SystemTurn):
continue
for content in turn.contents:
if isinstance(content, ContentToolResponseFetch):
if content.status == "success" and anthropic_replayable(content):
sources.append(WebSource(url=content.url))
elif isinstance(content, (ContentPDF, ContentDocument)):
sources.append(WebSource(url=content.url) if content.url else None)
elif isinstance(content, ContentUploaded):
if not content.mime_type.startswith("image/"):
sources.append(None)

for block in contents:
if block.type != "web_fetch_tool_result":
continue
result = block.content
if isinstance(result, WebFetchBlock):
sources.append(WebSource(url=result.url))

return sources


def anthropic_server_tool_request(
block: "ServerToolUseBlock",
) -> Optional[ContentToolRequestSearch | ContentToolRequestFetch]:
Expand Down
3 changes: 3 additions & 0 deletions chatlas/_provider_bedrock_converse.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,7 @@ def stream_content(
self,
chunk: dict,
completion: Optional[ConverseAccumulator],
turns: Sequence[Turn] = (),
) -> Sequence[Content]:
delta_event = chunk.get("contentBlockDelta")
if delta_event is None:
Expand Down Expand Up @@ -621,6 +622,7 @@ def stream_turn(
self,
completion: ConverseAccumulator,
has_data_model: bool,
turns: Sequence[Turn] = (),
) -> AssistantTurn[ConverseResponseTypeDef]:
# Reassemble into a ConverseResponseTypeDef shape so this can share
# `_as_turn` with `value_turn` -- matching the pattern
Expand Down Expand Up @@ -683,6 +685,7 @@ def value_turn(
self,
completion: ConverseResponseTypeDef,
has_data_model: bool,
turns: Sequence[Turn] = (),
) -> AssistantTurn[ConverseResponseTypeDef]:
return self._as_turn(completion, has_data_model)

Expand Down
6 changes: 3 additions & 3 deletions chatlas/_provider_google.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ def _chat_perform_args(

return kwargs_full

def stream_content(self, chunk, completion) -> list[Content]:
def stream_content(self, chunk, completion, turns=()) -> list[Content]:
candidates = getattr(chunk, "candidates", None)
if not candidates:
return []
Expand Down Expand Up @@ -516,13 +516,13 @@ def stream_merge_chunks(self, completion, chunk):
merge_dicts(completion, chunkd), # type: ignore
)

def stream_turn(self, completion, has_data_model):
def stream_turn(self, completion, has_data_model, turns=()):
return self._as_turn(
completion,
has_data_model,
)

def value_turn(self, completion, has_data_model):
def value_turn(self, completion, has_data_model, turns=()):
completion = cast("GenerateContentResponseDict", completion.model_dump())
return self._as_turn(completion, has_data_model)

Expand Down
Loading