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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Bug fixes

* `ChatBedrock()` (with the default `api="converse"`) no longer sends assistant turns with an empty `content` array, which Converse rejects. This happens when a response carries no content blocks, for example when a guardrail intervenes before the model produces any. A `"[empty string]"` placeholder is sent instead, matching how empty text content is already normalized. (#426)
* `ChatPosit()` now switches API flavors when `chat.model` is set to a model from the other family: setting a non-Claude model on a Claude-backed chat (or vice versa) swaps the underlying provider so requests use the correct wire format and gateway endpoint, rather than failing with a mismatched request. The `cache` setting is preserved across flavor switches. (Mirrors tidyverse/ellmer#1139.)
* Anthropic-backed providers (`ChatAnthropic()`, `ChatPosit()`, etc.) now drop thinking blocks that lack a signature (e.g., reasoning emitted by a non-Claude model) when replaying conversation history, instead of failing with `Invalid signature in thinking block`.


## [0.23.0] - 2026-09-04
Expand Down
10 changes: 8 additions & 2 deletions chatlas/_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,9 @@ def model(self) -> str:

Setting this updates the model for subsequent requests. The model name
is not validated, so make sure it's a valid model for the chat's
provider.
provider. Note that some providers (e.g., `ChatPosit()`) dispatch to a
different API flavor based on the model name; setting a model from
another flavor swaps out the underlying provider accordingly.

Returns
-------
Expand All @@ -547,7 +549,11 @@ def model(self) -> str:

@model.setter
def model(self, value: str):
self.provider.model = value
old_provider = self.provider
new_provider = old_provider.set_model(value)
if new_provider is not old_provider:
old_provider.close()
self.provider = new_provider

@property
def conversation_id(self) -> str | None:
Expand Down
2 changes: 1 addition & 1 deletion chatlas/_content_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ def content_image_file(
"Install it with `pip install pillow-heif`, or pass "
"`resize='none'` to send the original bytes without resizing."
)
pillow_heif.register_heif_opener()
pillow_heif.register_heif_opener() # pyright: ignore[reportPrivateImportUsage]

if resize == "none":
with open(path, "rb") as image_file:
Expand Down
18 changes: 18 additions & 0 deletions chatlas/_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,24 @@ def model(self):
def model(self, value: str):
self._model = value

def set_model(self, value: str) -> "Provider[Any, Any, Any, Any]":
"""
Set the model used by the provider.

The default implementation just updates the model name and returns
the provider unchanged. Providers that dispatch to a different API
flavor based on the model name (e.g., Posit AI) override this to
return a different provider instance when the new model belongs to
another flavor.

Returns
-------
Provider
The provider to use for subsequent requests (possibly `self`).
"""
self._model = value
return self

def close(self) -> None:
"""
Release resources held by this provider (e.g., HTTP connection pools,
Expand Down
16 changes: 14 additions & 2 deletions chatlas/_provider_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -894,8 +894,11 @@ def _as_message_params(self, turns: Sequence[Turn]) -> list["MessageParam"]:
content = [
self._as_content_block(c)
for c in turn.contents
if not isinstance(c, PROVIDER_ANNOTATION_TYPES)
or anthropic_replayable(c)
if (
not isinstance(c, PROVIDER_ANNOTATION_TYPES)
or anthropic_replayable(c)
)
and self._is_replayable_thinking(c)
]

# Drop empty assistant turns to avoid an API error
Expand All @@ -915,6 +918,15 @@ def _as_message_params(self, turns: Sequence[Turn]) -> list["MessageParam"]:
messages.append({"role": role, "content": content})
return messages

@staticmethod
def _is_replayable_thinking(content: Content) -> bool:
# Thinking blocks without a signature (e.g., reasoning emitted by a
# non-Claude model, replayed after a provider switch) are rejected by
# the API ("Invalid `signature` in `thinking` block"), so drop them.
if not isinstance(content, ContentThinking):
return True
return bool((content.extra or {}).get("signature"))

@staticmethod
def _as_content_block(content: Content) -> "ContentBlockParam":
if isinstance(content, ContentText):
Expand Down
29 changes: 29 additions & 0 deletions chatlas/_provider_posit.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,18 @@ def __init__(
),
)

def set_model(self, value: str) -> "PositAnthropicProvider | PositOpenAIProvider":
if value.startswith("claude"):
self._model = value
return self
return PositOpenAIProvider(
base_url=self._gateway_base_url,
model=value,
credentials=self._credentials,
cache=self._cache,
name=self.name,
)
Comment thread
Copilot marked this conversation as resolved.

def list_models(self) -> list[ModelInfo]:
return list_models_posit(self._gateway_base_url, self._credentials)

Expand All @@ -320,12 +332,16 @@ def __init__(
base_url: str,
model: str,
credentials: Callable[[], str],
cache: Literal["5m", "1h", "none"] = "5m",
name: str = "Posit",
):
super().__init__(model=model, api_key="not-used", name=name)

self._gateway_base_url = base_url.rstrip("/")
self._credentials = credentials
# Inert on this flavor (caching is Claude-only); stored so the
# setting survives a round trip through `set_model()`.
self._cache: Literal["5m", "1h", "none"] = cache

auth = PositHttpx2Auth(credentials)
flavor_base_url = f"{self._gateway_base_url}/openai/v1"
Expand All @@ -349,6 +365,18 @@ def __init__(
),
)

def set_model(self, value: str) -> "PositAnthropicProvider | PositOpenAIProvider":
if value.startswith("claude"):
return PositAnthropicProvider(
base_url=self._gateway_base_url,
model=value,
credentials=self._credentials,
cache=self._cache,
name=self.name,
)
Comment thread
Copilot marked this conversation as resolved.
self._model = value
return self

def list_models(self) -> list[ModelInfo]:
return list_models_posit(self._gateway_base_url, self._credentials)

Expand Down Expand Up @@ -445,6 +473,7 @@ def ChatPosit(
base_url=base_url,
model=model,
credentials=token_provider,
cache=cache,
)

return Chat(provider=provider, system_prompt=system_prompt)
Loading