From a30a7224d49b88b4d3955d6e55f5997a3dba307e Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 Aug 2025 11:23:46 -0500 Subject: [PATCH 01/15] feat(contents_shinychat): Add new contents_shinychat() and contents_shinychat_chunk() These new functions are the singledispatch equivalent of the previous normalize_message() and normalize_message_chunk(). Although not quite as flexible as the previous strategies pattern, it's much simpler (implementation wise and also for those registering new methods) and aligns much better with the vision for the R package --- pkg-py/src/shinychat/__init__.py | 10 +- pkg-py/src/shinychat/_chat.py | 6 +- pkg-py/src/shinychat/_chat_normalize.py | 472 ++++++++++-------------- pkg-py/tests/pytest/test_chat.py | 40 +- 4 files changed, 217 insertions(+), 311 deletions(-) diff --git a/pkg-py/src/shinychat/__init__.py b/pkg-py/src/shinychat/__init__.py index 137685e7..02506a24 100644 --- a/pkg-py/src/shinychat/__init__.py +++ b/pkg-py/src/shinychat/__init__.py @@ -1,4 +1,12 @@ from ._chat import Chat, chat_ui +from ._chat_normalize import contents_shinychat, contents_shinychat_chunk from ._markdown_stream import MarkdownStream, output_markdown_stream -__all__ = ["Chat", "chat_ui", "MarkdownStream", "output_markdown_stream"] +__all__ = [ + "Chat", + "chat_ui", + "MarkdownStream", + "output_markdown_stream", + "contents_shinychat", + "contents_shinychat_chunk", +] diff --git a/pkg-py/src/shinychat/_chat.py b/pkg-py/src/shinychat/_chat.py index 30f7e31b..0d306d5e 100644 --- a/pkg-py/src/shinychat/_chat.py +++ b/pkg-py/src/shinychat/_chat.py @@ -52,7 +52,7 @@ is_chatlas_chat_client, set_chatlas_state, ) -from ._chat_normalize import normalize_message, normalize_message_chunk +from ._chat_normalize import contents_shinychat, contents_shinychat_chunk from ._chat_provider_types import ( AnthropicMessage, GoogleMessage, @@ -636,7 +636,7 @@ async def append_message( self._pending_messages.append((message, False, "append", None)) return - msg = normalize_message(message) + msg = contents_shinychat(message) msg = await self._transform_message(msg) if msg is None: return @@ -753,7 +753,7 @@ async def _append_message_chunk( self._current_stream_id = stream_id # Normalize various message types into a ChatMessage() - msg = normalize_message_chunk(message) + msg = contents_shinychat_chunk(message) if operation == "replace": self._current_stream_message = ( diff --git a/pkg-py/src/shinychat/_chat_normalize.py b/pkg-py/src/shinychat/_chat_normalize.py index c22a4549..8b64528b 100644 --- a/pkg-py/src/shinychat/_chat_normalize.py +++ b/pkg-py/src/shinychat/_chat_normalize.py @@ -1,164 +1,190 @@ -import sys -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Optional, cast +from __future__ import annotations + +from functools import singledispatch from htmltools import HTML, Tagifiable from ._chat_types import ChatMessage -if TYPE_CHECKING: - from anthropic.types import Message as AnthropicMessage - from anthropic.types import MessageStreamEvent - - if sys.version_info >= (3, 9): - from google.generativeai.types.generation_types import ( # pyright: ignore[reportMissingTypeStubs] - GenerateContentResponse, - ) - else: - - class GenerateContentResponse: - text: str +__all__ = ["contents_shinychat", "contents_shinychat_chunk"] - from langchain_core.messages import BaseMessage, BaseMessageChunk - from openai.types.chat import ChatCompletion, ChatCompletionChunk - - -class BaseMessageNormalizer(ABC): - @abstractmethod - def normalize(self, message: Any) -> ChatMessage: - pass - - @abstractmethod - def normalize_chunk(self, chunk: Any) -> ChatMessage: - pass - - @abstractmethod - def can_normalize(self, message: Any) -> bool: - pass - - @abstractmethod - def can_normalize_chunk(self, chunk: Any) -> bool: - pass +@singledispatch +def contents_shinychat(message) -> ChatMessage: + """ + Extract content from various message types into a ChatMessage. -class StringNormalizer(BaseMessageNormalizer): - def normalize(self, message: Any) -> ChatMessage: - x = cast(Optional[str], message) - return ChatMessage(content=x or "", role="assistant") + This function uses `singledispatch` to allow for easy extension to support + new message types. To add support for a new type, register a new function + using the `@contents_shinychat.register` decorator. - def normalize_chunk(self, chunk: Any) -> ChatMessage: - x = cast(Optional[str], chunk) - return ChatMessage(content=x or "", role="assistant") + Parameters + ---------- + message + The message object to extract content from (e.g., ChatCompletion, + BaseMessage, etc.). + + Note + ---- + This function is implicitly called `Chat.append_message()` to support + handling of various message types. It is not intended to be called directly + by users, but may be useful for debugging or advanced use cases. + + Returns + ------- + ChatMessage + A ChatMessage object containing the extracted content and role. + + Raises + ------ + ValueError + If the message type is unsupported. + """ + if isinstance(message, (str, HTML)) or message is None: + return ChatMessage(content=message, role="assistant") + if isinstance(message, dict): + if "content" not in message: + raise ValueError("Message dictionary must have a 'content' key") + return ChatMessage( + content=message["content"], + role=message.get("role", "assistant"), + ) + raise ValueError( + f"Don't know how to extract content for message type {type(message)}: {message}. " + "Consider registering a function to handle this type via `@contents_shinychat.register`" + ) - def can_normalize(self, message: Any) -> bool: - return isinstance(message, (str, HTML)) or message is None - def can_normalize_chunk(self, chunk: Any) -> bool: - return isinstance(chunk, (str, HTML)) or chunk is None +@singledispatch +def contents_shinychat_chunk(chunk) -> ChatMessage: + """ + Extract content from various message chunk types into a ChatMessage. + This function uses `singledispatch` to allow for easy extension to support + new chunk types. To add support for a new type, register a new function + using the `@contents_shinychat_chunk.register` decorator. -class DictNormalizer(BaseMessageNormalizer): - def normalize(self, message: Any) -> ChatMessage: - x = cast("dict[str, Any]", message) - if "content" not in x: - raise ValueError("Message must have 'content' key") - return ChatMessage(content=x["content"], role=x.get("role", "assistant")) + Parameters + ---------- + chunk + The message chunk object to extract content from (e.g., ChatCompletionChunk, + BaseMessageChunk, etc.). + + Note + ---- + This function is implicitly called `Chat.append_message_stream()` (on every + chunk of a message stream). It is not intended to be called directly by + users, but may be useful for debugging or advanced use cases. + + Returns + ------- + ChatMessage + A ChatMessage object containing the extracted content and role. + + Raises + ------ + ValueError + If the chunk type is unsupported. + """ + if isinstance(chunk, (str, HTML)) or chunk is None: + return ChatMessage(content=chunk, role="assistant") + if isinstance(chunk, dict): + if "content" not in chunk: + raise ValueError("Chunk dictionary must have a 'content' key") + return ChatMessage( + content=chunk["content"], + role=chunk.get("role", "assistant"), + ) + raise ValueError( + f"Don't know how to extract content for message chunk type {type(chunk)}: {chunk}. " + "Consider registering a function to handle this type via `@contents_shinychat_chunk.register`" + ) - def normalize_chunk(self, chunk: Any) -> ChatMessage: - x = cast("dict[str, Any]", chunk) - if "content" not in x: - raise ValueError("Message must have 'content' key") - return ChatMessage(content=x["content"], role=x.get("role", "assistant")) - def can_normalize(self, message: Any) -> bool: - return isinstance(message, dict) +# ------------------------------------------------------------------ +# Shiny tagifiable content extractor +# ------------------------------------------------------------------ - def can_normalize_chunk(self, chunk: Any) -> bool: - return isinstance(chunk, dict) +@contents_shinychat.register +def _(message: Tagifiable) -> ChatMessage: + return ChatMessage(content=message, role="assistant") -class TagifiableNormalizer(DictNormalizer): - def normalize(self, message: Any) -> ChatMessage: - x = cast("Tagifiable", message) - return super().normalize({"content": x}) - def normalize_chunk(self, chunk: Any) -> ChatMessage: - x = cast("Tagifiable", chunk) - return super().normalize_chunk({"content": x}) +@contents_shinychat_chunk.register +def _(chunk: Tagifiable) -> ChatMessage: + return ChatMessage(content=chunk, role="assistant") - def can_normalize(self, message: Any) -> bool: - return isinstance(message, Tagifiable) - def can_normalize_chunk(self, chunk: Any) -> bool: - return isinstance(chunk, Tagifiable) +# ------------------------------------------------------------------ +# LangChain content extractor +# ------------------------------------------------------------------ +try: + from langchain_core.messages import BaseMessage, BaseMessageChunk -class LangChainNormalizer(BaseMessageNormalizer): - def normalize(self, message: Any) -> ChatMessage: - x = cast("BaseMessage", message) - if isinstance(x.content, list): # type: ignore + @contents_shinychat.register + def _(message: BaseMessage) -> ChatMessage: + if isinstance(message.content, list): raise ValueError( "The `message.content` provided seems to represent numerous messages. " "Consider iterating over `message.content` and calling .append_message() on each iteration." ) - return ChatMessage(content=x.content, role="assistant") + return ChatMessage( + content=message.content, + role="assistant", + ) - def normalize_chunk(self, chunk: Any) -> ChatMessage: - x = cast("BaseMessageChunk", chunk) - if isinstance(x.content, list): # type: ignore + @contents_shinychat_chunk.register + def _(chunk: BaseMessageChunk) -> ChatMessage: + if isinstance(chunk.content, list): raise ValueError( - "The `message.content` provided seems to represent numerous messages. " - "Consider iterating over `message.content` and calling .append_message() on each iteration." + "The `chunk.content` provided seems to represent numerous messages. " + "Consider iterating over `chunk.content` and calling .append_message() on each iteration." ) - return ChatMessage(content=x.content, role="assistant") - - def can_normalize(self, message: Any) -> bool: - try: - from langchain_core.messages import BaseMessage - - return isinstance(message, BaseMessage) - except Exception: - return False - - def can_normalize_chunk(self, chunk: Any) -> bool: - try: - from langchain_core.messages import BaseMessageChunk - - return isinstance(chunk, BaseMessageChunk) - except Exception: - return False + return ChatMessage( + content=chunk.content, + role="assistant", + ) +except ImportError: + pass -class OpenAINormalizer(StringNormalizer): - def normalize(self, message: Any) -> ChatMessage: - x = cast("ChatCompletion", message) - return super().normalize(x.choices[0].message.content) +# ------------------------------------------------------------------ +# OpenAI content extractor +# ------------------------------------------------------------------ - def normalize_chunk(self, chunk: Any) -> ChatMessage: - x = cast("ChatCompletionChunk", chunk) - return super().normalize_chunk(x.choices[0].delta.content) +try: + from openai.types.chat import ChatCompletion, ChatCompletionChunk - def can_normalize(self, message: Any) -> bool: - try: - from openai.types.chat import ChatCompletion + @contents_shinychat.register + def _(message: ChatCompletion) -> ChatMessage: + return ChatMessage( + content=message.choices[0].message.content, + role="assistant", + ) - return isinstance(message, ChatCompletion) - except Exception: - return False + @contents_shinychat_chunk.register + def _(chunk: ChatCompletionChunk) -> ChatMessage: + return ChatMessage( + content=chunk.choices[0].delta.content, + role="assistant", + ) +except ImportError: + pass - def can_normalize_chunk(self, chunk: Any) -> bool: - try: - from openai.types.chat import ChatCompletionChunk - return isinstance(chunk, ChatCompletionChunk) - except Exception: - return False +# ------------------------------------------------------------------ +# Anthropic content extractor +# ------------------------------------------------------------------ +try: + from anthropic.types import Message as AnthropicMessage + from anthropic.types import MessageStreamEvent -class AnthropicNormalizer(BaseMessageNormalizer): - def normalize(self, message: Any) -> ChatMessage: - x = cast("AnthropicMessage", message) - content = x.content[0] + @contents_shinychat.register + def _(message: AnthropicMessage) -> ChatMessage: + content = message.content[0] if content.type != "text": raise ValueError( f"Anthropic message type {content.type} not supported. " @@ -166,185 +192,59 @@ def normalize(self, message: Any) -> ChatMessage: ) return ChatMessage(content=content.text, role="assistant") - def normalize_chunk(self, chunk: Any) -> ChatMessage: - x = cast("MessageStreamEvent", chunk) + @contents_shinychat_chunk.register + def _(chunk: MessageStreamEvent) -> ChatMessage: content = "" - if x.type == "content_block_delta": - if x.delta.type != "text_delta": + if chunk.type == "content_block_delta": + if chunk.delta.type != "text_delta": raise ValueError( - f"Anthropic message delta type {x.delta.type} not supported. " + f"Anthropic message delta type {chunk.delta.type} not supported. " "Only 'text_delta' type is supported" ) - content = x.delta.text + content = chunk.delta.text return ChatMessage(content=content, role="assistant") - - def can_normalize(self, message: Any) -> bool: - try: - from anthropic.types import Message as AnthropicMessage - - return isinstance(message, AnthropicMessage) - except Exception: - return False - - def can_normalize_chunk(self, chunk: Any) -> bool: - try: - from anthropic.types import ( - RawContentBlockDeltaEvent, - RawContentBlockStartEvent, - RawContentBlockStopEvent, - RawMessageDeltaEvent, - RawMessageStartEvent, - RawMessageStopEvent, - ) - - # The actual MessageStreamEvent is a generic, so isinstance() can't - # be used to check the type. Instead, we manually construct the relevant - # union of relevant classes... - return ( - isinstance(chunk, RawContentBlockDeltaEvent) - or isinstance(chunk, RawContentBlockStartEvent) - or isinstance(chunk, RawContentBlockStopEvent) - or isinstance(chunk, RawMessageDeltaEvent) - or isinstance(chunk, RawMessageStartEvent) - or isinstance(chunk, RawMessageStopEvent) - ) - except Exception: - return False +except ImportError: + pass -class GoogleNormalizer(BaseMessageNormalizer): - def normalize(self, message: Any) -> ChatMessage: - x = cast("GenerateContentResponse", message) - return ChatMessage(content=x.text, role="assistant") +# ------------------------------------------------------------------ +# Google content extractor +# ------------------------------------------------------------------ - def normalize_chunk(self, chunk: Any) -> ChatMessage: - x = cast("GenerateContentResponse", chunk) - return ChatMessage(content=x.text, role="assistant") - - def can_normalize(self, message: Any) -> bool: - try: - import google.generativeai.types.generation_types as gtypes # pyright: ignore[reportMissingTypeStubs, reportMissingImports] +try: + from google.generativeai.types.generation_types import ( + GenerateContentResponse, + ) - return isinstance( - message, - gtypes.GenerateContentResponse, # pyright: ignore[reportUnknownMemberType] - ) - except Exception: - return False + @contents_shinychat.register + def _(message: GenerateContentResponse) -> ChatMessage: + return ChatMessage(content=message.text, role="assistant") - def can_normalize_chunk(self, chunk: Any) -> bool: - return self.can_normalize(chunk) + @contents_shinychat_chunk.register + def _(chunk: GenerateContentResponse) -> ChatMessage: + return ChatMessage(content=chunk.text, role="assistant") +except ImportError: + pass -class OllamaNormalizer(DictNormalizer): - def normalize(self, message: Any) -> ChatMessage: - x = cast("dict[str, Any]", message["message"]) - return super().normalize(x) - def normalize_chunk(self, chunk: "dict[str, Any]") -> ChatMessage: - msg = cast("dict[str, Any]", chunk["message"]) - return super().normalize_chunk(msg) +# ------------------------------------------------------------------ +# Ollama content extractor +# ------------------------------------------------------------------ - def can_normalize(self, message: Any) -> bool: - try: - from ollama import ChatResponse +try: + from ollama import ChatResponse - # Ollama<0.4 used TypedDict (now it uses pydantic) - # https://github.com/ollama/ollama-python/pull/276 - if isinstance(ChatResponse, dict): - return "message" in message and super().can_normalize( - message["message"] - ) - else: - return isinstance(message, ChatResponse) - except Exception: - return False - - def can_normalize_chunk(self, chunk: Any) -> bool: - return self.can_normalize(chunk) - - -class NormalizerRegistry: - def __init__(self) -> None: - # Order of strategies matters (the 1st one that can normalize the message is used) - # So make sure to put the most specific strategies first - self._strategies: dict[str, BaseMessageNormalizer] = { - "openai": OpenAINormalizer(), - "anthropic": AnthropicNormalizer(), - "google": GoogleNormalizer(), - "langchain": LangChainNormalizer(), - "ollama": OllamaNormalizer(), - "tagify": TagifiableNormalizer(), - "dict": DictNormalizer(), - "string": StringNormalizer(), - } - - def register( - self, provider: str, strategy: BaseMessageNormalizer, force: bool = False - ) -> None: - if provider in self._strategies: - if force: - del self._strategies[provider] - else: - raise ValueError(f"Provider {provider} already exists in registry") - # Update the strategies dict such that the new strategy is the first to be considered - self._strategies = {provider: strategy, **self._strategies} - - -message_normalizer_registry = NormalizerRegistry() - - -def register_custom_normalizer( - provider: str, normalizer: BaseMessageNormalizer, force: bool = False -) -> None: - """ - Register a custom normalizer for handling specific message types. + @contents_shinychat.register + def _(message: ChatResponse) -> ChatMessage: + msg = message.message + return ChatMessage(msg.content, role="assistant") - Parameters - ---------- - provider : str - A unique identifier for this normalizer in the registry - normalizer : BaseMessageNormalizer - A normalizer instance that can handle your specific message type - force : bool, optional - Whether to override an existing normalizer with the same provider name, - by default False - - Examples - -------- - >>> class MyCustomMessage: - ... def __init__(self, content): - ... self.content = content - ... - >>> class MyCustomNormalizer(StringNormalizer): - ... def normalize(self, message): - ... return ChatMessage(content=message.content, role="assistant") - ... def can_normalize(self, message): - ... return isinstance(message, MyCustomMessage) - ... - >>> register_custom_normalizer("my_provider", MyCustomNormalizer()) - """ - message_normalizer_registry.register(provider, normalizer, force) + @contents_shinychat_chunk.register + def _(chunk: ChatResponse) -> ChatMessage: + msg = chunk.message + return ChatMessage(msg.content, role="assistant") - -def normalize_message(message: Any) -> ChatMessage: - strategies = message_normalizer_registry._strategies - for strategy in strategies.values(): - if strategy.can_normalize(message): - return strategy.normalize(message) - raise ValueError( - f"Could not find a normalizer for message of type {type(message)}: {message}. " - "Consider registering a custom normalizer via shiny.ui._chat_types.registry.register()" - ) - - -def normalize_message_chunk(chunk: Any) -> ChatMessage: - strategies = message_normalizer_registry._strategies - for strategy in strategies.values(): - if strategy.can_normalize_chunk(chunk): - return strategy.normalize_chunk(chunk) - raise ValueError( - f"Could not find a normalizer for message chunk of type {type(chunk)}: {chunk}. " - "Consider registering a custom normalizer via shiny.ui._chat_normalize.register_custom_normalizer()" - ) +except ImportError: + pass diff --git a/pkg-py/tests/pytest/test_chat.py b/pkg-py/tests/pytest/test_chat.py index 32e7cbe9..1e56ae02 100644 --- a/pkg-py/tests/pytest/test_chat.py +++ b/pkg-py/tests/pytest/test_chat.py @@ -9,8 +9,12 @@ from shiny.module import ResolvedId from shiny.session import session_context from shiny.types import MISSING + from shinychat import Chat -from shinychat._chat_normalize import normalize_message, normalize_message_chunk +from shinychat._chat_normalize import ( + contents_shinychat, + contents_shinychat_chunk, +) from shinychat._chat_types import ( ChatMessage, ChatMessageDict, @@ -169,7 +173,7 @@ def generate_content(token_count: int) -> str: # ------------------------------------------------------------------------------------ -# Unit tests for normalize_message() and normalize_message_chunk(). +# Unit tests for contents_shinychat() and contents_shinychat_chunk(). # # This is where we go from provider's response object to ChatMessage. # @@ -181,13 +185,13 @@ def generate_content(token_count: int) -> str: def test_string_normalization(): - m = normalize_message_chunk("Hello world!") + m = contents_shinychat_chunk("Hello world!") assert m.content == "Hello world!" assert m.role == "assistant" def test_dict_normalization(): - m = normalize_message_chunk( + m = contents_shinychat_chunk( {"content": "Hello world!", "role": "assistant"} ) assert m.content == "Hello world!" @@ -208,13 +212,13 @@ def test_langchain_normalization(): # Mock & normalize return value of BaseChatModel.invoke() msg = BaseMessage(content="Hello world!", role="assistant", type="foo") - m = normalize_message(msg) + m = contents_shinychat(msg) assert m.content == "Hello world!" assert m.role == "assistant" # Mock & normalize return value of BaseChatModel.stream() chunk = BaseMessageChunk(content="Hello ", type="foo") - m = normalize_message_chunk(chunk) + m = contents_shinychat_chunk(chunk) assert m.content == "Hello " assert m.role == "assistant" @@ -224,9 +228,7 @@ def test_google_normalization(): if sys.version_info < (3, 9): return - from google.generativeai.generative_models import ( - GenerativeModel, # pyright: ignore[reportMissingTypeStubs] - ) + from google.generativeai.generative_models import GenerativeModel # pyright: ignore[reportMissingTypeStubs] generate_content = GenerativeModel.generate_content # type: ignore @@ -275,7 +277,7 @@ def test_anthropic_normalization(): usage=Usage(input_tokens=0, output_tokens=0), ) - m = normalize_message(msg) + m = contents_shinychat(msg) assert m.content == "Hello world!" assert m.role == "assistant" @@ -286,7 +288,7 @@ def test_anthropic_normalization(): index=0, ) - m = normalize_message_chunk(chunk) + m = contents_shinychat_chunk(chunk) assert m.content == "Hello " assert m.role == "assistant" @@ -335,7 +337,7 @@ def test_openai_normalization(): created=int(datetime.now().timestamp()), ) - m = normalize_message(completion) + m = contents_shinychat(completion) assert m.content == "Hello world!" assert m.role == "assistant" @@ -356,7 +358,7 @@ def test_openai_normalization(): ], ) - m = normalize_message_chunk(chunk) + m = contents_shinychat_chunk(chunk) assert m.content == "Hello " assert m.role == "assistant" @@ -371,11 +373,11 @@ def test_ollama_normalization(): ) msg_dict = {"content": "Hello world!", "role": "assistant"} - m = normalize_message(msg) + m = contents_shinychat(msg) assert m.content == msg_dict["content"] assert m.role == msg_dict["role"] - m = normalize_message_chunk(msg) + m = contents_shinychat_chunk(msg) assert m.content == msg_dict["content"] assert m.role == msg_dict["role"] @@ -419,9 +421,7 @@ def test_as_google_message(): if sys.version_info < (3, 9): return - from google.generativeai.generative_models import ( - GenerativeModel, # pyright: ignore[reportMissingTypeStubs] - ) + from google.generativeai.generative_models import GenerativeModel # pyright: ignore[reportMissingTypeStubs] generate_content = GenerativeModel.generate_content # type: ignore @@ -430,9 +430,7 @@ def test_as_google_message(): == "content_types.ContentsType" ) - from google.generativeai.types import ( - content_types, # pyright: ignore[reportMissingTypeStubs] - ) + from google.generativeai.types import content_types # pyright: ignore[reportMissingTypeStubs] assert is_type_in_union( content_types.ContentDict, content_types.ContentsType From e7f70acf71297ea56fc0caf8dd2764f186d789ae Mon Sep 17 00:00:00 2001 From: Carson Sievert Date: Thu, 7 Aug 2025 13:21:31 -0500 Subject: [PATCH 02/15] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg-py/src/shinychat/_chat_normalize.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg-py/src/shinychat/_chat_normalize.py b/pkg-py/src/shinychat/_chat_normalize.py index 8b64528b..3b0cd7bf 100644 --- a/pkg-py/src/shinychat/_chat_normalize.py +++ b/pkg-py/src/shinychat/_chat_normalize.py @@ -26,7 +26,7 @@ def contents_shinychat(message) -> ChatMessage: Note ---- - This function is implicitly called `Chat.append_message()` to support + This function is implicitly called by `Chat.append_message()` to support handling of various message types. It is not intended to be called directly by users, but may be useful for debugging or advanced use cases. @@ -72,7 +72,7 @@ def contents_shinychat_chunk(chunk) -> ChatMessage: Note ---- - This function is implicitly called `Chat.append_message_stream()` (on every + This function is implicitly called by `Chat.append_message_stream()` (on every chunk of a message stream). It is not intended to be called directly by users, but may be useful for debugging or advanced use cases. From 3c0a46e0c1d91fc8887f9c84cddd8c97a3eff396 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 Aug 2025 13:30:40 -0500 Subject: [PATCH 03/15] Fix format check --- pkg-py/tests/pytest/test_chat.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/pkg-py/tests/pytest/test_chat.py b/pkg-py/tests/pytest/test_chat.py index 1e56ae02..f3022bfd 100644 --- a/pkg-py/tests/pytest/test_chat.py +++ b/pkg-py/tests/pytest/test_chat.py @@ -9,7 +9,6 @@ from shiny.module import ResolvedId from shiny.session import session_context from shiny.types import MISSING - from shinychat import Chat from shinychat._chat_normalize import ( contents_shinychat, @@ -228,7 +227,9 @@ def test_google_normalization(): if sys.version_info < (3, 9): return - from google.generativeai.generative_models import GenerativeModel # pyright: ignore[reportMissingTypeStubs] + from google.generativeai.generative_models import ( + GenerativeModel, # pyright: ignore[reportMissingTypeStubs] + ) generate_content = GenerativeModel.generate_content # type: ignore @@ -421,7 +422,9 @@ def test_as_google_message(): if sys.version_info < (3, 9): return - from google.generativeai.generative_models import GenerativeModel # pyright: ignore[reportMissingTypeStubs] + from google.generativeai.generative_models import ( + GenerativeModel, # pyright: ignore[reportMissingTypeStubs] + ) generate_content = GenerativeModel.generate_content # type: ignore @@ -430,7 +433,9 @@ def test_as_google_message(): == "content_types.ContentsType" ) - from google.generativeai.types import content_types # pyright: ignore[reportMissingTypeStubs] + from google.generativeai.types import ( + content_types, # pyright: ignore[reportMissingTypeStubs] + ) assert is_type_in_union( content_types.ContentDict, content_types.ContentsType From 66276dda861bd601ce566609e7cc8bcf93513ae4 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 Aug 2025 13:42:23 -0500 Subject: [PATCH 04/15] Rename --- pkg-py/CHANGELOG.md | 2 ++ pkg-py/src/shinychat/__init__.py | 6 ++-- pkg-py/src/shinychat/_chat.py | 6 ++-- pkg-py/src/shinychat/_chat_normalize.py | 38 +++++++++++----------- pkg-py/tests/pytest/test_chat.py | 43 +++++++++++-------------- 5 files changed, 46 insertions(+), 49 deletions(-) diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 35d646f9..bb2e4bc0 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +## New features +* Added new `get_message_content()` and `get_message_chunk_content()` generic (`singledispatch`) functions. These functions aren't intended to be called directly by users, but instead, provide an opportunity to teach `Chat.append_message()`/`Chat.append_message_stream()` to extract message contents from different types of objects. (#96) ## [0.1.0] - 2025-08-07 diff --git a/pkg-py/src/shinychat/__init__.py b/pkg-py/src/shinychat/__init__.py index 02506a24..1fb2e5f5 100644 --- a/pkg-py/src/shinychat/__init__.py +++ b/pkg-py/src/shinychat/__init__.py @@ -1,5 +1,5 @@ from ._chat import Chat, chat_ui -from ._chat_normalize import contents_shinychat, contents_shinychat_chunk +from ._chat_normalize import get_message_chunk_content, get_message_content from ._markdown_stream import MarkdownStream, output_markdown_stream __all__ = [ @@ -7,6 +7,6 @@ "chat_ui", "MarkdownStream", "output_markdown_stream", - "contents_shinychat", - "contents_shinychat_chunk", + "get_message_content", + "get_message_chunk_content", ] diff --git a/pkg-py/src/shinychat/_chat.py b/pkg-py/src/shinychat/_chat.py index 0d306d5e..c700c1a4 100644 --- a/pkg-py/src/shinychat/_chat.py +++ b/pkg-py/src/shinychat/_chat.py @@ -52,7 +52,7 @@ is_chatlas_chat_client, set_chatlas_state, ) -from ._chat_normalize import contents_shinychat, contents_shinychat_chunk +from ._chat_normalize import get_message_chunk_content, get_message_content from ._chat_provider_types import ( AnthropicMessage, GoogleMessage, @@ -636,7 +636,7 @@ async def append_message( self._pending_messages.append((message, False, "append", None)) return - msg = contents_shinychat(message) + msg = get_message_content(message) msg = await self._transform_message(msg) if msg is None: return @@ -753,7 +753,7 @@ async def _append_message_chunk( self._current_stream_id = stream_id # Normalize various message types into a ChatMessage() - msg = contents_shinychat_chunk(message) + msg = get_message_chunk_content(message) if operation == "replace": self._current_stream_message = ( diff --git a/pkg-py/src/shinychat/_chat_normalize.py b/pkg-py/src/shinychat/_chat_normalize.py index 3b0cd7bf..8d46ad26 100644 --- a/pkg-py/src/shinychat/_chat_normalize.py +++ b/pkg-py/src/shinychat/_chat_normalize.py @@ -6,17 +6,17 @@ from ._chat_types import ChatMessage -__all__ = ["contents_shinychat", "contents_shinychat_chunk"] +__all__ = ["get_message_content", "get_message_chunk_content"] @singledispatch -def contents_shinychat(message) -> ChatMessage: +def get_message_content(message) -> ChatMessage: """ Extract content from various message types into a ChatMessage. This function uses `singledispatch` to allow for easy extension to support new message types. To add support for a new type, register a new function - using the `@contents_shinychat.register` decorator. + using the `@get_message_content.register` decorator. Parameters ---------- @@ -51,18 +51,18 @@ def contents_shinychat(message) -> ChatMessage: ) raise ValueError( f"Don't know how to extract content for message type {type(message)}: {message}. " - "Consider registering a function to handle this type via `@contents_shinychat.register`" + "Consider registering a function to handle this type via `@get_message_content.register`" ) @singledispatch -def contents_shinychat_chunk(chunk) -> ChatMessage: +def get_message_chunk_content(chunk) -> ChatMessage: """ Extract content from various message chunk types into a ChatMessage. This function uses `singledispatch` to allow for easy extension to support new chunk types. To add support for a new type, register a new function - using the `@contents_shinychat_chunk.register` decorator. + using the `@get_message_chunk_content.register` decorator. Parameters ---------- @@ -97,7 +97,7 @@ def contents_shinychat_chunk(chunk) -> ChatMessage: ) raise ValueError( f"Don't know how to extract content for message chunk type {type(chunk)}: {chunk}. " - "Consider registering a function to handle this type via `@contents_shinychat_chunk.register`" + "Consider registering a function to handle this type via `@get_message_chunk_content.register`" ) @@ -106,12 +106,12 @@ def contents_shinychat_chunk(chunk) -> ChatMessage: # ------------------------------------------------------------------ -@contents_shinychat.register +@get_message_content.register def _(message: Tagifiable) -> ChatMessage: return ChatMessage(content=message, role="assistant") -@contents_shinychat_chunk.register +@get_message_chunk_content.register def _(chunk: Tagifiable) -> ChatMessage: return ChatMessage(content=chunk, role="assistant") @@ -123,7 +123,7 @@ def _(chunk: Tagifiable) -> ChatMessage: try: from langchain_core.messages import BaseMessage, BaseMessageChunk - @contents_shinychat.register + @get_message_content.register def _(message: BaseMessage) -> ChatMessage: if isinstance(message.content, list): raise ValueError( @@ -135,7 +135,7 @@ def _(message: BaseMessage) -> ChatMessage: role="assistant", ) - @contents_shinychat_chunk.register + @get_message_chunk_content.register def _(chunk: BaseMessageChunk) -> ChatMessage: if isinstance(chunk.content, list): raise ValueError( @@ -157,14 +157,14 @@ def _(chunk: BaseMessageChunk) -> ChatMessage: try: from openai.types.chat import ChatCompletion, ChatCompletionChunk - @contents_shinychat.register + @get_message_content.register def _(message: ChatCompletion) -> ChatMessage: return ChatMessage( content=message.choices[0].message.content, role="assistant", ) - @contents_shinychat_chunk.register + @get_message_chunk_content.register def _(chunk: ChatCompletionChunk) -> ChatMessage: return ChatMessage( content=chunk.choices[0].delta.content, @@ -182,7 +182,7 @@ def _(chunk: ChatCompletionChunk) -> ChatMessage: from anthropic.types import Message as AnthropicMessage from anthropic.types import MessageStreamEvent - @contents_shinychat.register + @get_message_content.register def _(message: AnthropicMessage) -> ChatMessage: content = message.content[0] if content.type != "text": @@ -192,7 +192,7 @@ def _(message: AnthropicMessage) -> ChatMessage: ) return ChatMessage(content=content.text, role="assistant") - @contents_shinychat_chunk.register + @get_message_chunk_content.register def _(chunk: MessageStreamEvent) -> ChatMessage: content = "" if chunk.type == "content_block_delta": @@ -217,11 +217,11 @@ def _(chunk: MessageStreamEvent) -> ChatMessage: GenerateContentResponse, ) - @contents_shinychat.register + @get_message_content.register def _(message: GenerateContentResponse) -> ChatMessage: return ChatMessage(content=message.text, role="assistant") - @contents_shinychat_chunk.register + @get_message_chunk_content.register def _(chunk: GenerateContentResponse) -> ChatMessage: return ChatMessage(content=chunk.text, role="assistant") @@ -236,12 +236,12 @@ def _(chunk: GenerateContentResponse) -> ChatMessage: try: from ollama import ChatResponse - @contents_shinychat.register + @get_message_content.register def _(message: ChatResponse) -> ChatMessage: msg = message.message return ChatMessage(msg.content, role="assistant") - @contents_shinychat_chunk.register + @get_message_chunk_content.register def _(chunk: ChatResponse) -> ChatMessage: msg = chunk.message return ChatMessage(msg.content, role="assistant") diff --git a/pkg-py/tests/pytest/test_chat.py b/pkg-py/tests/pytest/test_chat.py index f3022bfd..9c20327b 100644 --- a/pkg-py/tests/pytest/test_chat.py +++ b/pkg-py/tests/pytest/test_chat.py @@ -9,10 +9,11 @@ from shiny.module import ResolvedId from shiny.session import session_context from shiny.types import MISSING + from shinychat import Chat from shinychat._chat_normalize import ( - contents_shinychat, - contents_shinychat_chunk, + get_message_chunk_content, + get_message_content, ) from shinychat._chat_types import ( ChatMessage, @@ -172,7 +173,7 @@ def generate_content(token_count: int) -> str: # ------------------------------------------------------------------------------------ -# Unit tests for contents_shinychat() and contents_shinychat_chunk(). +# Unit tests for get_message_content() and get_message_chunk_content(). # # This is where we go from provider's response object to ChatMessage. # @@ -184,13 +185,13 @@ def generate_content(token_count: int) -> str: def test_string_normalization(): - m = contents_shinychat_chunk("Hello world!") + m = get_message_chunk_content("Hello world!") assert m.content == "Hello world!" assert m.role == "assistant" def test_dict_normalization(): - m = contents_shinychat_chunk( + m = get_message_chunk_content( {"content": "Hello world!", "role": "assistant"} ) assert m.content == "Hello world!" @@ -211,13 +212,13 @@ def test_langchain_normalization(): # Mock & normalize return value of BaseChatModel.invoke() msg = BaseMessage(content="Hello world!", role="assistant", type="foo") - m = contents_shinychat(msg) + m = get_message_content(msg) assert m.content == "Hello world!" assert m.role == "assistant" # Mock & normalize return value of BaseChatModel.stream() chunk = BaseMessageChunk(content="Hello ", type="foo") - m = contents_shinychat_chunk(chunk) + m = get_message_chunk_content(chunk) assert m.content == "Hello " assert m.role == "assistant" @@ -227,9 +228,7 @@ def test_google_normalization(): if sys.version_info < (3, 9): return - from google.generativeai.generative_models import ( - GenerativeModel, # pyright: ignore[reportMissingTypeStubs] - ) + from google.generativeai.generative_models import GenerativeModel # pyright: ignore[reportMissingTypeStubs] generate_content = GenerativeModel.generate_content # type: ignore @@ -278,7 +277,7 @@ def test_anthropic_normalization(): usage=Usage(input_tokens=0, output_tokens=0), ) - m = contents_shinychat(msg) + m = get_message_content(msg) assert m.content == "Hello world!" assert m.role == "assistant" @@ -289,7 +288,7 @@ def test_anthropic_normalization(): index=0, ) - m = contents_shinychat_chunk(chunk) + m = get_message_chunk_content(chunk) assert m.content == "Hello " assert m.role == "assistant" @@ -338,7 +337,7 @@ def test_openai_normalization(): created=int(datetime.now().timestamp()), ) - m = contents_shinychat(completion) + m = get_message_content(completion) assert m.content == "Hello world!" assert m.role == "assistant" @@ -359,7 +358,7 @@ def test_openai_normalization(): ], ) - m = contents_shinychat_chunk(chunk) + m = get_message_chunk_content(chunk) assert m.content == "Hello " assert m.role == "assistant" @@ -374,11 +373,11 @@ def test_ollama_normalization(): ) msg_dict = {"content": "Hello world!", "role": "assistant"} - m = contents_shinychat(msg) + m = get_message_content(msg) assert m.content == msg_dict["content"] assert m.role == msg_dict["role"] - m = contents_shinychat_chunk(msg) + m = get_message_chunk_content(msg) assert m.content == msg_dict["content"] assert m.role == msg_dict["role"] @@ -422,9 +421,7 @@ def test_as_google_message(): if sys.version_info < (3, 9): return - from google.generativeai.generative_models import ( - GenerativeModel, # pyright: ignore[reportMissingTypeStubs] - ) + from google.generativeai.generative_models import GenerativeModel # pyright: ignore[reportMissingTypeStubs] generate_content = GenerativeModel.generate_content # type: ignore @@ -433,9 +430,7 @@ def test_as_google_message(): == "content_types.ContentsType" ) - from google.generativeai.types import ( - content_types, # pyright: ignore[reportMissingTypeStubs] - ) + from google.generativeai.types import content_types # pyright: ignore[reportMissingTypeStubs] assert is_type_in_union( content_types.ContentDict, content_types.ContentsType @@ -450,8 +445,8 @@ def test_as_google_message(): def test_as_langchain_message(): from langchain_core.language_models.base import LanguageModelInput from langchain_core.language_models.base import ( - Sequence as LangchainSequence, # pyright: ignore[reportPrivateImportUsage] - ) + Sequence as LangchainSequence, + ) # pyright: ignore[reportPrivateImportUsage] from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import ( AIMessage, From 3bae3d3da728ba718aa71c8b13735a0fc6100b3f Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 Aug 2025 13:55:15 -0500 Subject: [PATCH 05/15] Add ChatMessage to types module. Add types and playwright module to top-level namespace --- pkg-py/src/shinychat/__init__.py | 3 +++ pkg-py/src/shinychat/types/__init__.py | 3 ++- pkg-py/tests/pytest/test_chat.py | 17 +++++++++++------ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/pkg-py/src/shinychat/__init__.py b/pkg-py/src/shinychat/__init__.py index 1fb2e5f5..3f0eb654 100644 --- a/pkg-py/src/shinychat/__init__.py +++ b/pkg-py/src/shinychat/__init__.py @@ -1,3 +1,4 @@ +from . import playwright, types from ._chat import Chat, chat_ui from ._chat_normalize import get_message_chunk_content, get_message_content from ._markdown_stream import MarkdownStream, output_markdown_stream @@ -9,4 +10,6 @@ "output_markdown_stream", "get_message_content", "get_message_chunk_content", + "types", + "playwright", ] diff --git a/pkg-py/src/shinychat/types/__init__.py b/pkg-py/src/shinychat/types/__init__.py index 1b9c019a..36060196 100644 --- a/pkg-py/src/shinychat/types/__init__.py +++ b/pkg-py/src/shinychat/types/__init__.py @@ -1,5 +1,6 @@ -from .._chat import ChatMessageDict +from .._chat import ChatMessage, ChatMessageDict __all__ = [ + "ChatMessage", "ChatMessageDict", ] diff --git a/pkg-py/tests/pytest/test_chat.py b/pkg-py/tests/pytest/test_chat.py index 9c20327b..20e63cbe 100644 --- a/pkg-py/tests/pytest/test_chat.py +++ b/pkg-py/tests/pytest/test_chat.py @@ -9,7 +9,6 @@ from shiny.module import ResolvedId from shiny.session import session_context from shiny.types import MISSING - from shinychat import Chat from shinychat._chat_normalize import ( get_message_chunk_content, @@ -228,7 +227,9 @@ def test_google_normalization(): if sys.version_info < (3, 9): return - from google.generativeai.generative_models import GenerativeModel # pyright: ignore[reportMissingTypeStubs] + from google.generativeai.generative_models import ( + GenerativeModel, # pyright: ignore[reportMissingTypeStubs] + ) generate_content = GenerativeModel.generate_content # type: ignore @@ -421,7 +422,9 @@ def test_as_google_message(): if sys.version_info < (3, 9): return - from google.generativeai.generative_models import GenerativeModel # pyright: ignore[reportMissingTypeStubs] + from google.generativeai.generative_models import ( + GenerativeModel, # pyright: ignore[reportMissingTypeStubs] + ) generate_content = GenerativeModel.generate_content # type: ignore @@ -430,7 +433,9 @@ def test_as_google_message(): == "content_types.ContentsType" ) - from google.generativeai.types import content_types # pyright: ignore[reportMissingTypeStubs] + from google.generativeai.types import ( + content_types, # pyright: ignore[reportMissingTypeStubs] + ) assert is_type_in_union( content_types.ContentDict, content_types.ContentsType @@ -445,8 +450,8 @@ def test_as_google_message(): def test_as_langchain_message(): from langchain_core.language_models.base import LanguageModelInput from langchain_core.language_models.base import ( - Sequence as LangchainSequence, - ) # pyright: ignore[reportPrivateImportUsage] + Sequence as LangchainSequence, # pyright: ignore[reportPrivateImportUsage] + ) from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import ( AIMessage, From c84ff9473fdc173d1918c58919df993083a8aa85 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 Aug 2025 16:28:37 -0500 Subject: [PATCH 06/15] Fix for Anthropic; cleanup --- pkg-py/src/shinychat/_chat_normalize.py | 22 ++++++++++++++++++++-- pkg-py/tests/pytest/test_chat.py | 19 ++++++++----------- pyproject.toml | 2 +- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/pkg-py/src/shinychat/_chat_normalize.py b/pkg-py/src/shinychat/_chat_normalize.py index 8d46ad26..bdffe69b 100644 --- a/pkg-py/src/shinychat/_chat_normalize.py +++ b/pkg-py/src/shinychat/_chat_normalize.py @@ -180,7 +180,25 @@ def _(chunk: ChatCompletionChunk) -> ChatMessage: try: from anthropic.types import Message as AnthropicMessage - from anthropic.types import MessageStreamEvent + from anthropic.types import ( + RawContentBlockDeltaEvent, + RawContentBlockStartEvent, + RawContentBlockStopEvent, + RawMessageDeltaEvent, + RawMessageStartEvent, + RawMessageStopEvent, + ) + + # Create a non-annotated type alias for RawMessageStreamEvent + # (so it works with singledispatch) + RawMessageStreamEvent = ( + RawMessageStartEvent + | RawMessageDeltaEvent + | RawMessageStopEvent + | RawContentBlockStartEvent + | RawContentBlockDeltaEvent + | RawContentBlockStopEvent + ) @get_message_content.register def _(message: AnthropicMessage) -> ChatMessage: @@ -193,7 +211,7 @@ def _(message: AnthropicMessage) -> ChatMessage: return ChatMessage(content=content.text, role="assistant") @get_message_chunk_content.register - def _(chunk: MessageStreamEvent) -> ChatMessage: + def _(chunk: RawMessageStreamEvent) -> ChatMessage: content = "" if chunk.type == "content_block_delta": if chunk.delta.type != "text_delta": diff --git a/pkg-py/tests/pytest/test_chat.py b/pkg-py/tests/pytest/test_chat.py index 20e63cbe..adfd72c2 100644 --- a/pkg-py/tests/pytest/test_chat.py +++ b/pkg-py/tests/pytest/test_chat.py @@ -398,7 +398,7 @@ def test_ollama_normalization(): def test_as_anthropic_message(): from anthropic.resources.messages import AsyncMessages, Messages from anthropic.types import MessageParam - from shiny.ui._chat_provider_types import as_anthropic_message + from shinychat._chat_provider_types import as_anthropic_message # Make sure return type of llm.messages.create() hasn't changed assert ( @@ -416,7 +416,7 @@ def test_as_anthropic_message(): def test_as_google_message(): - from shiny.ui._chat_provider_types import as_google_message + from shinychat._chat_provider_types import as_google_message # Not available for Python 3.8 if sys.version_info < (3, 9): @@ -460,7 +460,7 @@ def test_as_langchain_message(): MessageLikeRepresentation, SystemMessage, ) - from shiny.ui._chat_provider_types import as_langchain_message + from shinychat._chat_provider_types import as_langchain_message assert BaseChatModel.invoke.__annotations__["input"] == "LanguageModelInput" assert BaseChatModel.stream.__annotations__["input"] == "LanguageModelInput" @@ -491,7 +491,7 @@ def test_as_openai_message(): ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam, ) - from shiny.ui._chat_provider_types import as_openai_message + from shinychat._chat_provider_types import as_openai_message assert ( Completions.create.__annotations__["messages"] @@ -523,14 +523,11 @@ def test_as_ollama_message(): import ollama from ollama import Message as OllamaMessage - # ollama 0.4.2 added Callable to the type hints, but pyright complains about - # missing arguments to the Callable type. We'll ignore this for now. - # https://github.com/ollama/ollama-python/commit/b50a65b - chat = ollama.chat # type: ignore - - assert "ollama._types.Message" in str(chat.__annotations__["messages"]) + assert "ollama._types.Message" in str( + ollama.chat.__annotations__["messages"] + ) - from shiny.ui._chat_provider_types import as_ollama_message + from shinychat._chat_provider_types import as_ollama_message msg = ChatMessageDict(content="I have a question", role="user") assert as_ollama_message(msg) == OllamaMessage( diff --git a/pyproject.toml b/pyproject.toml index 3c8a9aae..02f28e24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ providers = [ "chatlas>=0.6.1", "google-generativeai;python_version>='3.9'", "langchain-core", - "ollama", + "ollama>=0.4.0", "openai", "tokenizers", ] From ba7e2010496f2d5116abe7942588306b5fce3e27 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 Aug 2025 16:36:11 -0500 Subject: [PATCH 07/15] Just require python 3.11 with Anthropic --- pkg-py/src/shinychat/_chat_normalize.py | 48 +++++++++---------------- pyproject.toml | 4 +-- 2 files changed, 19 insertions(+), 33 deletions(-) diff --git a/pkg-py/src/shinychat/_chat_normalize.py b/pkg-py/src/shinychat/_chat_normalize.py index bdffe69b..7f76310d 100644 --- a/pkg-py/src/shinychat/_chat_normalize.py +++ b/pkg-py/src/shinychat/_chat_normalize.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from functools import singledispatch from htmltools import HTML, Tagifiable @@ -180,25 +181,6 @@ def _(chunk: ChatCompletionChunk) -> ChatMessage: try: from anthropic.types import Message as AnthropicMessage - from anthropic.types import ( - RawContentBlockDeltaEvent, - RawContentBlockStartEvent, - RawContentBlockStopEvent, - RawMessageDeltaEvent, - RawMessageStartEvent, - RawMessageStopEvent, - ) - - # Create a non-annotated type alias for RawMessageStreamEvent - # (so it works with singledispatch) - RawMessageStreamEvent = ( - RawMessageStartEvent - | RawMessageDeltaEvent - | RawMessageStopEvent - | RawContentBlockStartEvent - | RawContentBlockDeltaEvent - | RawContentBlockStopEvent - ) @get_message_content.register def _(message: AnthropicMessage) -> ChatMessage: @@ -210,18 +192,22 @@ def _(message: AnthropicMessage) -> ChatMessage: ) return ChatMessage(content=content.text, role="assistant") - @get_message_chunk_content.register - def _(chunk: RawMessageStreamEvent) -> ChatMessage: - content = "" - if chunk.type == "content_block_delta": - if chunk.delta.type != "text_delta": - raise ValueError( - f"Anthropic message delta type {chunk.delta.type} not supported. " - "Only 'text_delta' type is supported" - ) - content = chunk.delta.text - - return ChatMessage(content=content, role="assistant") + # Old versions of singledispatch doesn't seem to support union types + if sys.version_info >= (3, 11): + from anthropic.types import RawMessageStreamEvent + + @get_message_chunk_content.register + def _(chunk: RawMessageStreamEvent) -> ChatMessage: + content = "" + if chunk.type == "content_block_delta": + if chunk.delta.type != "text_delta": + raise ValueError( + f"Anthropic message delta type {chunk.delta.type} not supported. " + "Only 'text_delta' type is supported" + ) + content = chunk.delta.text + + return ChatMessage(content=content, role="assistant") except ImportError: pass diff --git a/pyproject.toml b/pyproject.toml index 02f28e24..131bd921 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,9 +24,9 @@ Changelog = "https://github.com/posit-dev/shinychat/blob/main/pkg-py/CHANGELOG.m [project.optional-dependencies] providers = [ - "anthropic", + "anthropic;python_version>='3.11'", "chatlas>=0.6.1", - "google-generativeai;python_version>='3.9'", + "google-generativeai", "langchain-core", "ollama>=0.4.0", "openai", From 7f61233f251632ba5d5d7e4165e734a64221e5af Mon Sep 17 00:00:00 2001 From: Carson Sievert Date: Thu, 7 Aug 2025 16:40:43 -0500 Subject: [PATCH 08/15] Update pkg-py/src/shinychat/_chat_normalize.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg-py/src/shinychat/_chat_normalize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg-py/src/shinychat/_chat_normalize.py b/pkg-py/src/shinychat/_chat_normalize.py index 7f76310d..76f205b2 100644 --- a/pkg-py/src/shinychat/_chat_normalize.py +++ b/pkg-py/src/shinychat/_chat_normalize.py @@ -140,7 +140,7 @@ def _(message: BaseMessage) -> ChatMessage: def _(chunk: BaseMessageChunk) -> ChatMessage: if isinstance(chunk.content, list): raise ValueError( - "The `chunk.content` provided seems to represent numerous messages. " + "The `chunk.content` provided seems to represent numerous message chunks. " "Consider iterating over `chunk.content` and calling .append_message() on each iteration." ) return ChatMessage( From b00aa7dcaa0e4002233d15c004f7d200a5d46203 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 Aug 2025 16:41:29 -0500 Subject: [PATCH 09/15] Just require python 3.11 with Anthropic --- pkg-py/tests/pytest/test_chat.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg-py/tests/pytest/test_chat.py b/pkg-py/tests/pytest/test_chat.py index adfd72c2..175b2ed8 100644 --- a/pkg-py/tests/pytest/test_chat.py +++ b/pkg-py/tests/pytest/test_chat.py @@ -243,6 +243,9 @@ def test_google_normalization(): def test_anthropic_normalization(): + if sys.version_info < (3, 11): + pytest.skip("Anthropic is only available for Python 3.11+") + from anthropic import Anthropic, AsyncAnthropic from anthropic.resources.messages import AsyncMessages, Messages from anthropic.types import TextBlock, Usage From 33fb55a29cf8fbd7f23aca24ebce9e8bccd9d736 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 Aug 2025 16:48:34 -0500 Subject: [PATCH 10/15] Just require python 3.11 with Anthropic --- pkg-py/tests/pytest/test_chat.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg-py/tests/pytest/test_chat.py b/pkg-py/tests/pytest/test_chat.py index 175b2ed8..62c99ece 100644 --- a/pkg-py/tests/pytest/test_chat.py +++ b/pkg-py/tests/pytest/test_chat.py @@ -399,6 +399,9 @@ def test_ollama_normalization(): def test_as_anthropic_message(): + if sys.version_info < (3, 11): + pytest.skip("Anthropic is only available for Python 3.11+") + from anthropic.resources.messages import AsyncMessages, Messages from anthropic.types import MessageParam from shinychat._chat_provider_types import as_anthropic_message From fda779045bee65257164b3b94e7bca9c4bab67cf Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 Aug 2025 16:57:36 -0500 Subject: [PATCH 11/15] Add a test for registering custom objects --- pkg-py/tests/pytest/test_chat.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pkg-py/tests/pytest/test_chat.py b/pkg-py/tests/pytest/test_chat.py index 62c99ece..109e8487 100644 --- a/pkg-py/tests/pytest/test_chat.py +++ b/pkg-py/tests/pytest/test_chat.py @@ -539,3 +539,33 @@ def test_as_ollama_message(): assert as_ollama_message(msg) == OllamaMessage( content="I have a question", role="user" ) + + +class MyObject: + content = "Hello world!" + + +class MyObjectChunk: + content = "Hello world!" + + +@get_message_content.register +def _(message: MyObject) -> ChatMessage: + return ChatMessage(content=message.content, role="assistant") + + +@get_message_chunk_content.register +def _(chunk: MyObjectChunk) -> ChatMessage: + return ChatMessage(content=chunk.content, role="assistant") + + +def test_custom_objects(): + obj = MyObject() + m = get_message_content(obj) + assert m.content == "Hello world!" + assert m.role == "assistant" + + chunk = MyObjectChunk() + m = get_message_chunk_content(chunk) + assert m.content == "Hello world!" + assert m.role == "assistant" From 724906164cbe8c05dbe7be8558156bdf8cced86f Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 Aug 2025 17:05:16 -0500 Subject: [PATCH 12/15] Ignore missing anthropic imports when type checking --- pkg-py/src/shinychat/_chat.py | 2 +- pkg-py/src/shinychat/_chat_normalize.py | 4 ++- pkg-py/src/shinychat/_chat_provider_types.py | 14 ++++++-- pkg-py/tests/pytest/test_chat.py | 34 +++++++++++++++----- 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/pkg-py/src/shinychat/_chat.py b/pkg-py/src/shinychat/_chat.py index c700c1a4..51b0b81e 100644 --- a/pkg-py/src/shinychat/_chat.py +++ b/pkg-py/src/shinychat/_chat.py @@ -54,7 +54,7 @@ ) from ._chat_normalize import get_message_chunk_content, get_message_content from ._chat_provider_types import ( - AnthropicMessage, + AnthropicMessage, # pyright: ignore[reportAttributeAccessIssue] GoogleMessage, LangChainMessage, OllamaMessage, diff --git a/pkg-py/src/shinychat/_chat_normalize.py b/pkg-py/src/shinychat/_chat_normalize.py index 76f205b2..8a093e97 100644 --- a/pkg-py/src/shinychat/_chat_normalize.py +++ b/pkg-py/src/shinychat/_chat_normalize.py @@ -180,7 +180,9 @@ def _(chunk: ChatCompletionChunk) -> ChatMessage: # ------------------------------------------------------------------ try: - from anthropic.types import Message as AnthropicMessage + from anthropic.types import ( + Message as AnthropicMessage, # pyright: ignore[reportMissingImports] + ) @get_message_content.register def _(message: AnthropicMessage) -> ChatMessage: diff --git a/pkg-py/src/shinychat/_chat_provider_types.py b/pkg-py/src/shinychat/_chat_provider_types.py index cb79ec40..9d118f4f 100644 --- a/pkg-py/src/shinychat/_chat_provider_types.py +++ b/pkg-py/src/shinychat/_chat_provider_types.py @@ -4,7 +4,9 @@ from ._chat_types import ChatMessageDict if TYPE_CHECKING: - from anthropic.types import MessageParam as AnthropicMessage + from anthropic.types import ( + MessageParam as AnthropicMessage, # pyright: ignore[reportMissingImports] + ) from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from ollama import Message as OllamaMessage from openai.types.chat import ( @@ -28,7 +30,11 @@ ] ProviderMessage = Union[ - AnthropicMessage, GoogleMessage, LangChainMessage, OpenAIMessage, OllamaMessage + AnthropicMessage, + GoogleMessage, + LangChainMessage, + OpenAIMessage, + OllamaMessage, ] else: AnthropicMessage = GoogleMessage = LangChainMessage = OpenAIMessage = ( @@ -63,7 +69,9 @@ def as_provider_message( def as_anthropic_message(message: ChatMessageDict) -> "AnthropicMessage": - from anthropic.types import MessageParam as AnthropicMessage + from anthropic.types import ( + MessageParam as AnthropicMessage, # pyright: ignore[reportMissingImports] + ) if message["role"] == "system": raise ValueError( diff --git a/pkg-py/tests/pytest/test_chat.py b/pkg-py/tests/pytest/test_chat.py index 109e8487..fb045c48 100644 --- a/pkg-py/tests/pytest/test_chat.py +++ b/pkg-py/tests/pytest/test_chat.py @@ -246,14 +246,27 @@ def test_anthropic_normalization(): if sys.version_info < (3, 11): pytest.skip("Anthropic is only available for Python 3.11+") - from anthropic import Anthropic, AsyncAnthropic - from anthropic.resources.messages import AsyncMessages, Messages - from anthropic.types import TextBlock, Usage - from anthropic.types.message import Message + from anthropic import ( # pyright: ignore[reportMissingImports] + Anthropic, + AsyncAnthropic, + ) + from anthropic.resources.messages import ( # pyright: ignore[reportMissingImports] + AsyncMessages, + Messages, + ) + from anthropic.types import ( # pyright: ignore[reportMissingImports] + TextBlock, + Usage, + ) + from anthropic.types.message import ( + Message, # pyright: ignore[reportMissingImports] + ) from anthropic.types.raw_content_block_delta_event import ( - RawContentBlockDeltaEvent, + RawContentBlockDeltaEvent, # pyright: ignore[reportMissingImports] + ) + from anthropic.types.text_delta import ( + TextDelta, # pyright: ignore[reportMissingImports] ) - from anthropic.types.text_delta import TextDelta # Make sure return type of Anthropic().messages.create() hasn't changed assert isinstance(Anthropic().messages, Messages) @@ -402,8 +415,13 @@ def test_as_anthropic_message(): if sys.version_info < (3, 11): pytest.skip("Anthropic is only available for Python 3.11+") - from anthropic.resources.messages import AsyncMessages, Messages - from anthropic.types import MessageParam + from anthropic.resources.messages import ( # pyright: ignore[reportMissingImports] + AsyncMessages, + Messages, + ) + from anthropic.types import ( + MessageParam, # pyright: ignore[reportMissingImports] + ) from shinychat._chat_provider_types import as_anthropic_message # Make sure return type of llm.messages.create() hasn't changed From 959fb616f3ae77ad2411734e5283c56ee33e1f60 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 Aug 2025 17:11:01 -0500 Subject: [PATCH 13/15] fml --- pkg-py/src/shinychat/_chat_normalize.py | 4 ++-- pkg-py/src/shinychat/_chat_provider_types.py | 8 ++++---- pkg-py/tests/pytest/test_chat.py | 16 ++++++++-------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pkg-py/src/shinychat/_chat_normalize.py b/pkg-py/src/shinychat/_chat_normalize.py index 8a093e97..01c559c3 100644 --- a/pkg-py/src/shinychat/_chat_normalize.py +++ b/pkg-py/src/shinychat/_chat_normalize.py @@ -180,8 +180,8 @@ def _(chunk: ChatCompletionChunk) -> ChatMessage: # ------------------------------------------------------------------ try: - from anthropic.types import ( - Message as AnthropicMessage, # pyright: ignore[reportMissingImports] + from anthropic.types import ( # pyright: ignore[reportMissingImports] + Message as AnthropicMessage, ) @get_message_content.register diff --git a/pkg-py/src/shinychat/_chat_provider_types.py b/pkg-py/src/shinychat/_chat_provider_types.py index 9d118f4f..cef23afe 100644 --- a/pkg-py/src/shinychat/_chat_provider_types.py +++ b/pkg-py/src/shinychat/_chat_provider_types.py @@ -4,8 +4,8 @@ from ._chat_types import ChatMessageDict if TYPE_CHECKING: - from anthropic.types import ( - MessageParam as AnthropicMessage, # pyright: ignore[reportMissingImports] + from anthropic.types import ( # pyright: ignore[reportMissingImports] + MessageParam as AnthropicMessage, ) from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from ollama import Message as OllamaMessage @@ -69,8 +69,8 @@ def as_provider_message( def as_anthropic_message(message: ChatMessageDict) -> "AnthropicMessage": - from anthropic.types import ( - MessageParam as AnthropicMessage, # pyright: ignore[reportMissingImports] + from anthropic.types import ( # pyright: ignore[reportMissingImports] + MessageParam as AnthropicMessage, ) if message["role"] == "system": diff --git a/pkg-py/tests/pytest/test_chat.py b/pkg-py/tests/pytest/test_chat.py index fb045c48..6ac55bf3 100644 --- a/pkg-py/tests/pytest/test_chat.py +++ b/pkg-py/tests/pytest/test_chat.py @@ -258,14 +258,14 @@ def test_anthropic_normalization(): TextBlock, Usage, ) - from anthropic.types.message import ( - Message, # pyright: ignore[reportMissingImports] + from anthropic.types.message import ( # pyright: ignore[reportMissingImports] + Message, ) - from anthropic.types.raw_content_block_delta_event import ( - RawContentBlockDeltaEvent, # pyright: ignore[reportMissingImports] + from anthropic.types.raw_content_block_delta_event import ( # pyright: ignore[reportMissingImports] + RawContentBlockDeltaEvent, ) - from anthropic.types.text_delta import ( - TextDelta, # pyright: ignore[reportMissingImports] + from anthropic.types.text_delta import ( # pyright: ignore[reportMissingImports] + TextDelta, ) # Make sure return type of Anthropic().messages.create() hasn't changed @@ -419,8 +419,8 @@ def test_as_anthropic_message(): AsyncMessages, Messages, ) - from anthropic.types import ( - MessageParam, # pyright: ignore[reportMissingImports] + from anthropic.types import ( # pyright: ignore[reportMissingImports] + MessageParam, ) from shinychat._chat_provider_types import as_anthropic_message From 80a10e50e8541787a8a39ace5a236e3c305e5da1 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 7 Aug 2025 17:22:05 -0500 Subject: [PATCH 14/15] rename (again) --- pkg-py/CHANGELOG.md | 2 +- pkg-py/src/shinychat/__init__.py | 6 ++-- pkg-py/src/shinychat/_chat.py | 6 ++-- pkg-py/src/shinychat/_chat_normalize.py | 38 ++++++++++++------------- pkg-py/tests/pytest/test_chat.py | 37 +++++++++++------------- 5 files changed, 42 insertions(+), 47 deletions(-) diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 0f5f1136..3d85741f 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features -* Added new `get_message_content()` and `get_message_chunk_content()` generic (`singledispatch`) functions. These functions aren't intended to be called directly by users, but instead, provide an opportunity to teach `Chat.append_message()`/`Chat.append_message_stream()` to extract message contents from different types of objects. (#96) +* Added new `message_content()` and `message_chunk_content()` generic (`singledispatch`) functions. These functions aren't intended to be called directly by users, but instead, provide an opportunity to teach `Chat.append_message()`/`Chat.append_message_stream()` to extract message contents from different types of objects. (#96) ### Bug fixes diff --git a/pkg-py/src/shinychat/__init__.py b/pkg-py/src/shinychat/__init__.py index 3f0eb654..6cd9a4d5 100644 --- a/pkg-py/src/shinychat/__init__.py +++ b/pkg-py/src/shinychat/__init__.py @@ -1,6 +1,6 @@ from . import playwright, types from ._chat import Chat, chat_ui -from ._chat_normalize import get_message_chunk_content, get_message_content +from ._chat_normalize import message_chunk_content, message_content from ._markdown_stream import MarkdownStream, output_markdown_stream __all__ = [ @@ -8,8 +8,8 @@ "chat_ui", "MarkdownStream", "output_markdown_stream", - "get_message_content", - "get_message_chunk_content", + "message_content", + "message_chunk_content", "types", "playwright", ] diff --git a/pkg-py/src/shinychat/_chat.py b/pkg-py/src/shinychat/_chat.py index 51b0b81e..6e812c36 100644 --- a/pkg-py/src/shinychat/_chat.py +++ b/pkg-py/src/shinychat/_chat.py @@ -52,7 +52,7 @@ is_chatlas_chat_client, set_chatlas_state, ) -from ._chat_normalize import get_message_chunk_content, get_message_content +from ._chat_normalize import message_chunk_content, message_content from ._chat_provider_types import ( AnthropicMessage, # pyright: ignore[reportAttributeAccessIssue] GoogleMessage, @@ -636,7 +636,7 @@ async def append_message( self._pending_messages.append((message, False, "append", None)) return - msg = get_message_content(message) + msg = message_content(message) msg = await self._transform_message(msg) if msg is None: return @@ -753,7 +753,7 @@ async def _append_message_chunk( self._current_stream_id = stream_id # Normalize various message types into a ChatMessage() - msg = get_message_chunk_content(message) + msg = message_chunk_content(message) if operation == "replace": self._current_stream_message = ( diff --git a/pkg-py/src/shinychat/_chat_normalize.py b/pkg-py/src/shinychat/_chat_normalize.py index 01c559c3..d1ff2d26 100644 --- a/pkg-py/src/shinychat/_chat_normalize.py +++ b/pkg-py/src/shinychat/_chat_normalize.py @@ -7,17 +7,17 @@ from ._chat_types import ChatMessage -__all__ = ["get_message_content", "get_message_chunk_content"] +__all__ = ["message_content", "message_chunk_content"] @singledispatch -def get_message_content(message) -> ChatMessage: +def message_content(message) -> ChatMessage: """ Extract content from various message types into a ChatMessage. This function uses `singledispatch` to allow for easy extension to support new message types. To add support for a new type, register a new function - using the `@get_message_content.register` decorator. + using the `@message_content.register` decorator. Parameters ---------- @@ -52,18 +52,18 @@ def get_message_content(message) -> ChatMessage: ) raise ValueError( f"Don't know how to extract content for message type {type(message)}: {message}. " - "Consider registering a function to handle this type via `@get_message_content.register`" + "Consider registering a function to handle this type via `@message_content.register`" ) @singledispatch -def get_message_chunk_content(chunk) -> ChatMessage: +def message_chunk_content(chunk) -> ChatMessage: """ Extract content from various message chunk types into a ChatMessage. This function uses `singledispatch` to allow for easy extension to support new chunk types. To add support for a new type, register a new function - using the `@get_message_chunk_content.register` decorator. + using the `@message_chunk_content.register` decorator. Parameters ---------- @@ -98,7 +98,7 @@ def get_message_chunk_content(chunk) -> ChatMessage: ) raise ValueError( f"Don't know how to extract content for message chunk type {type(chunk)}: {chunk}. " - "Consider registering a function to handle this type via `@get_message_chunk_content.register`" + "Consider registering a function to handle this type via `@message_chunk_content.register`" ) @@ -107,12 +107,12 @@ def get_message_chunk_content(chunk) -> ChatMessage: # ------------------------------------------------------------------ -@get_message_content.register +@message_content.register def _(message: Tagifiable) -> ChatMessage: return ChatMessage(content=message, role="assistant") -@get_message_chunk_content.register +@message_chunk_content.register def _(chunk: Tagifiable) -> ChatMessage: return ChatMessage(content=chunk, role="assistant") @@ -124,7 +124,7 @@ def _(chunk: Tagifiable) -> ChatMessage: try: from langchain_core.messages import BaseMessage, BaseMessageChunk - @get_message_content.register + @message_content.register def _(message: BaseMessage) -> ChatMessage: if isinstance(message.content, list): raise ValueError( @@ -136,7 +136,7 @@ def _(message: BaseMessage) -> ChatMessage: role="assistant", ) - @get_message_chunk_content.register + @message_chunk_content.register def _(chunk: BaseMessageChunk) -> ChatMessage: if isinstance(chunk.content, list): raise ValueError( @@ -158,14 +158,14 @@ def _(chunk: BaseMessageChunk) -> ChatMessage: try: from openai.types.chat import ChatCompletion, ChatCompletionChunk - @get_message_content.register + @message_content.register def _(message: ChatCompletion) -> ChatMessage: return ChatMessage( content=message.choices[0].message.content, role="assistant", ) - @get_message_chunk_content.register + @message_chunk_content.register def _(chunk: ChatCompletionChunk) -> ChatMessage: return ChatMessage( content=chunk.choices[0].delta.content, @@ -184,7 +184,7 @@ def _(chunk: ChatCompletionChunk) -> ChatMessage: Message as AnthropicMessage, ) - @get_message_content.register + @message_content.register def _(message: AnthropicMessage) -> ChatMessage: content = message.content[0] if content.type != "text": @@ -198,7 +198,7 @@ def _(message: AnthropicMessage) -> ChatMessage: if sys.version_info >= (3, 11): from anthropic.types import RawMessageStreamEvent - @get_message_chunk_content.register + @message_chunk_content.register def _(chunk: RawMessageStreamEvent) -> ChatMessage: content = "" if chunk.type == "content_block_delta": @@ -223,11 +223,11 @@ def _(chunk: RawMessageStreamEvent) -> ChatMessage: GenerateContentResponse, ) - @get_message_content.register + @message_content.register def _(message: GenerateContentResponse) -> ChatMessage: return ChatMessage(content=message.text, role="assistant") - @get_message_chunk_content.register + @message_chunk_content.register def _(chunk: GenerateContentResponse) -> ChatMessage: return ChatMessage(content=chunk.text, role="assistant") @@ -242,12 +242,12 @@ def _(chunk: GenerateContentResponse) -> ChatMessage: try: from ollama import ChatResponse - @get_message_content.register + @message_content.register def _(message: ChatResponse) -> ChatMessage: msg = message.message return ChatMessage(msg.content, role="assistant") - @get_message_chunk_content.register + @message_chunk_content.register def _(chunk: ChatResponse) -> ChatMessage: msg = chunk.message return ChatMessage(msg.content, role="assistant") diff --git a/pkg-py/tests/pytest/test_chat.py b/pkg-py/tests/pytest/test_chat.py index 6ac55bf3..fd0d1825 100644 --- a/pkg-py/tests/pytest/test_chat.py +++ b/pkg-py/tests/pytest/test_chat.py @@ -10,10 +10,7 @@ from shiny.session import session_context from shiny.types import MISSING from shinychat import Chat -from shinychat._chat_normalize import ( - get_message_chunk_content, - get_message_content, -) +from shinychat._chat_normalize import message_chunk_content, message_content from shinychat._chat_types import ( ChatMessage, ChatMessageDict, @@ -172,7 +169,7 @@ def generate_content(token_count: int) -> str: # ------------------------------------------------------------------------------------ -# Unit tests for get_message_content() and get_message_chunk_content(). +# Unit tests for message_content() and message_chunk_content(). # # This is where we go from provider's response object to ChatMessage. # @@ -184,15 +181,13 @@ def generate_content(token_count: int) -> str: def test_string_normalization(): - m = get_message_chunk_content("Hello world!") + m = message_chunk_content("Hello world!") assert m.content == "Hello world!" assert m.role == "assistant" def test_dict_normalization(): - m = get_message_chunk_content( - {"content": "Hello world!", "role": "assistant"} - ) + m = message_chunk_content({"content": "Hello world!", "role": "assistant"}) assert m.content == "Hello world!" assert m.role == "assistant" @@ -211,13 +206,13 @@ def test_langchain_normalization(): # Mock & normalize return value of BaseChatModel.invoke() msg = BaseMessage(content="Hello world!", role="assistant", type="foo") - m = get_message_content(msg) + m = message_content(msg) assert m.content == "Hello world!" assert m.role == "assistant" # Mock & normalize return value of BaseChatModel.stream() chunk = BaseMessageChunk(content="Hello ", type="foo") - m = get_message_chunk_content(chunk) + m = message_chunk_content(chunk) assert m.content == "Hello " assert m.role == "assistant" @@ -294,7 +289,7 @@ def test_anthropic_normalization(): usage=Usage(input_tokens=0, output_tokens=0), ) - m = get_message_content(msg) + m = message_content(msg) assert m.content == "Hello world!" assert m.role == "assistant" @@ -305,7 +300,7 @@ def test_anthropic_normalization(): index=0, ) - m = get_message_chunk_content(chunk) + m = message_chunk_content(chunk) assert m.content == "Hello " assert m.role == "assistant" @@ -354,7 +349,7 @@ def test_openai_normalization(): created=int(datetime.now().timestamp()), ) - m = get_message_content(completion) + m = message_content(completion) assert m.content == "Hello world!" assert m.role == "assistant" @@ -375,7 +370,7 @@ def test_openai_normalization(): ], ) - m = get_message_chunk_content(chunk) + m = message_chunk_content(chunk) assert m.content == "Hello " assert m.role == "assistant" @@ -390,11 +385,11 @@ def test_ollama_normalization(): ) msg_dict = {"content": "Hello world!", "role": "assistant"} - m = get_message_content(msg) + m = message_content(msg) assert m.content == msg_dict["content"] assert m.role == msg_dict["role"] - m = get_message_chunk_content(msg) + m = message_chunk_content(msg) assert m.content == msg_dict["content"] assert m.role == msg_dict["role"] @@ -567,23 +562,23 @@ class MyObjectChunk: content = "Hello world!" -@get_message_content.register +@message_content.register def _(message: MyObject) -> ChatMessage: return ChatMessage(content=message.content, role="assistant") -@get_message_chunk_content.register +@message_chunk_content.register def _(chunk: MyObjectChunk) -> ChatMessage: return ChatMessage(content=chunk.content, role="assistant") def test_custom_objects(): obj = MyObject() - m = get_message_content(obj) + m = message_content(obj) assert m.content == "Hello world!" assert m.role == "assistant" chunk = MyObjectChunk() - m = get_message_chunk_content(chunk) + m = message_chunk_content(chunk) assert m.content == "Hello world!" assert m.role == "assistant" From 57bead140f1216a6d3aa03183fa1e4d95f7944f8 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 8 Aug 2025 10:55:38 -0500 Subject: [PATCH 15/15] rename (again) --- pkg-py/CHANGELOG.md | 2 +- pkg-py/src/shinychat/__init__.py | 4 ++-- pkg-py/src/shinychat/_chat.py | 4 ++-- pkg-py/src/shinychat/_chat_normalize.py | 20 ++++++++++---------- pkg-py/tests/pytest/test_chat.py | 20 ++++++++++---------- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 3d85741f..4c4e9074 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features -* Added new `message_content()` and `message_chunk_content()` generic (`singledispatch`) functions. These functions aren't intended to be called directly by users, but instead, provide an opportunity to teach `Chat.append_message()`/`Chat.append_message_stream()` to extract message contents from different types of objects. (#96) +* Added new `message_content()` and `message_content_chunk()` generic (`singledispatch`) functions. These functions aren't intended to be called directly by users, but instead, provide an opportunity to teach `Chat.append_message()`/`Chat.append_message_stream()` to extract message contents from different types of objects. (#96) ### Bug fixes diff --git a/pkg-py/src/shinychat/__init__.py b/pkg-py/src/shinychat/__init__.py index 6cd9a4d5..d76f0f19 100644 --- a/pkg-py/src/shinychat/__init__.py +++ b/pkg-py/src/shinychat/__init__.py @@ -1,6 +1,6 @@ from . import playwright, types from ._chat import Chat, chat_ui -from ._chat_normalize import message_chunk_content, message_content +from ._chat_normalize import message_content, message_content_chunk from ._markdown_stream import MarkdownStream, output_markdown_stream __all__ = [ @@ -9,7 +9,7 @@ "MarkdownStream", "output_markdown_stream", "message_content", - "message_chunk_content", + "message_content_chunk", "types", "playwright", ] diff --git a/pkg-py/src/shinychat/_chat.py b/pkg-py/src/shinychat/_chat.py index 6e812c36..3016e38a 100644 --- a/pkg-py/src/shinychat/_chat.py +++ b/pkg-py/src/shinychat/_chat.py @@ -52,7 +52,7 @@ is_chatlas_chat_client, set_chatlas_state, ) -from ._chat_normalize import message_chunk_content, message_content +from ._chat_normalize import message_content, message_content_chunk from ._chat_provider_types import ( AnthropicMessage, # pyright: ignore[reportAttributeAccessIssue] GoogleMessage, @@ -753,7 +753,7 @@ async def _append_message_chunk( self._current_stream_id = stream_id # Normalize various message types into a ChatMessage() - msg = message_chunk_content(message) + msg = message_content_chunk(message) if operation == "replace": self._current_stream_message = ( diff --git a/pkg-py/src/shinychat/_chat_normalize.py b/pkg-py/src/shinychat/_chat_normalize.py index d1ff2d26..892c4dde 100644 --- a/pkg-py/src/shinychat/_chat_normalize.py +++ b/pkg-py/src/shinychat/_chat_normalize.py @@ -7,7 +7,7 @@ from ._chat_types import ChatMessage -__all__ = ["message_content", "message_chunk_content"] +__all__ = ["message_content", "message_content_chunk"] @singledispatch @@ -57,13 +57,13 @@ def message_content(message) -> ChatMessage: @singledispatch -def message_chunk_content(chunk) -> ChatMessage: +def message_content_chunk(chunk) -> ChatMessage: """ Extract content from various message chunk types into a ChatMessage. This function uses `singledispatch` to allow for easy extension to support new chunk types. To add support for a new type, register a new function - using the `@message_chunk_content.register` decorator. + using the `@message_content_chunk.register` decorator. Parameters ---------- @@ -98,7 +98,7 @@ def message_chunk_content(chunk) -> ChatMessage: ) raise ValueError( f"Don't know how to extract content for message chunk type {type(chunk)}: {chunk}. " - "Consider registering a function to handle this type via `@message_chunk_content.register`" + "Consider registering a function to handle this type via `@message_content_chunk.register`" ) @@ -112,7 +112,7 @@ def _(message: Tagifiable) -> ChatMessage: return ChatMessage(content=message, role="assistant") -@message_chunk_content.register +@message_content_chunk.register def _(chunk: Tagifiable) -> ChatMessage: return ChatMessage(content=chunk, role="assistant") @@ -136,7 +136,7 @@ def _(message: BaseMessage) -> ChatMessage: role="assistant", ) - @message_chunk_content.register + @message_content_chunk.register def _(chunk: BaseMessageChunk) -> ChatMessage: if isinstance(chunk.content, list): raise ValueError( @@ -165,7 +165,7 @@ def _(message: ChatCompletion) -> ChatMessage: role="assistant", ) - @message_chunk_content.register + @message_content_chunk.register def _(chunk: ChatCompletionChunk) -> ChatMessage: return ChatMessage( content=chunk.choices[0].delta.content, @@ -198,7 +198,7 @@ def _(message: AnthropicMessage) -> ChatMessage: if sys.version_info >= (3, 11): from anthropic.types import RawMessageStreamEvent - @message_chunk_content.register + @message_content_chunk.register def _(chunk: RawMessageStreamEvent) -> ChatMessage: content = "" if chunk.type == "content_block_delta": @@ -227,7 +227,7 @@ def _(chunk: RawMessageStreamEvent) -> ChatMessage: def _(message: GenerateContentResponse) -> ChatMessage: return ChatMessage(content=message.text, role="assistant") - @message_chunk_content.register + @message_content_chunk.register def _(chunk: GenerateContentResponse) -> ChatMessage: return ChatMessage(content=chunk.text, role="assistant") @@ -247,7 +247,7 @@ def _(message: ChatResponse) -> ChatMessage: msg = message.message return ChatMessage(msg.content, role="assistant") - @message_chunk_content.register + @message_content_chunk.register def _(chunk: ChatResponse) -> ChatMessage: msg = chunk.message return ChatMessage(msg.content, role="assistant") diff --git a/pkg-py/tests/pytest/test_chat.py b/pkg-py/tests/pytest/test_chat.py index fd0d1825..243130f1 100644 --- a/pkg-py/tests/pytest/test_chat.py +++ b/pkg-py/tests/pytest/test_chat.py @@ -10,7 +10,7 @@ from shiny.session import session_context from shiny.types import MISSING from shinychat import Chat -from shinychat._chat_normalize import message_chunk_content, message_content +from shinychat._chat_normalize import message_content, message_content_chunk from shinychat._chat_types import ( ChatMessage, ChatMessageDict, @@ -169,7 +169,7 @@ def generate_content(token_count: int) -> str: # ------------------------------------------------------------------------------------ -# Unit tests for message_content() and message_chunk_content(). +# Unit tests for message_content() and message_content_chunk(). # # This is where we go from provider's response object to ChatMessage. # @@ -181,13 +181,13 @@ def generate_content(token_count: int) -> str: def test_string_normalization(): - m = message_chunk_content("Hello world!") + m = message_content_chunk("Hello world!") assert m.content == "Hello world!" assert m.role == "assistant" def test_dict_normalization(): - m = message_chunk_content({"content": "Hello world!", "role": "assistant"}) + m = message_content_chunk({"content": "Hello world!", "role": "assistant"}) assert m.content == "Hello world!" assert m.role == "assistant" @@ -212,7 +212,7 @@ def test_langchain_normalization(): # Mock & normalize return value of BaseChatModel.stream() chunk = BaseMessageChunk(content="Hello ", type="foo") - m = message_chunk_content(chunk) + m = message_content_chunk(chunk) assert m.content == "Hello " assert m.role == "assistant" @@ -300,7 +300,7 @@ def test_anthropic_normalization(): index=0, ) - m = message_chunk_content(chunk) + m = message_content_chunk(chunk) assert m.content == "Hello " assert m.role == "assistant" @@ -370,7 +370,7 @@ def test_openai_normalization(): ], ) - m = message_chunk_content(chunk) + m = message_content_chunk(chunk) assert m.content == "Hello " assert m.role == "assistant" @@ -389,7 +389,7 @@ def test_ollama_normalization(): assert m.content == msg_dict["content"] assert m.role == msg_dict["role"] - m = message_chunk_content(msg) + m = message_content_chunk(msg) assert m.content == msg_dict["content"] assert m.role == msg_dict["role"] @@ -567,7 +567,7 @@ def _(message: MyObject) -> ChatMessage: return ChatMessage(content=message.content, role="assistant") -@message_chunk_content.register +@message_content_chunk.register def _(chunk: MyObjectChunk) -> ChatMessage: return ChatMessage(content=chunk.content, role="assistant") @@ -579,6 +579,6 @@ def test_custom_objects(): assert m.role == "assistant" chunk = MyObjectChunk() - m = message_chunk_content(chunk) + m = message_content_chunk(chunk) assert m.content == "Hello world!" assert m.role == "assistant"