diff --git a/.gitignore b/.gitignore index bd252c2..0a13616 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,13 @@ bandit-report.json scripts/ assets/ + +# openspec — local-only authoring scratchpad +openspec/ + +# Agent / IDE tooling +.agents/ +.claude/ +.codex/ +.cursor/ +.worktrees/ diff --git a/src/celeste/__init__.py b/src/celeste/__init__.py index d2e78ef..df37b92 100644 --- a/src/celeste/__init__.py +++ b/src/celeste/__init__.py @@ -40,6 +40,8 @@ Tool, ToolCall, ToolChoice, + ToolError, + ToolOutput, ToolResult, WebSearch, XSearch, @@ -292,6 +294,8 @@ def create_client( "Tool", "ToolCall", "ToolChoice", + "ToolError", + "ToolOutput", "ToolResult", "Usage", "WebSearch", diff --git a/src/celeste/modalities/text/protocols/chatcompletions/client.py b/src/celeste/modalities/text/protocols/chatcompletions/client.py index 298c8b0..a71b1ac 100644 --- a/src/celeste/modalities/text/protocols/chatcompletions/client.py +++ b/src/celeste/modalities/text/protocols/chatcompletions/client.py @@ -1,7 +1,10 @@ """Chat Completions text client.""" +import json from typing import Any +from pydantic import BaseModel + from celeste.parameters import ParameterMapper from celeste.protocols.chatcompletions.client import ( ChatCompletionsClient as ChatCompletionsMixin, @@ -9,12 +12,9 @@ from celeste.protocols.chatcompletions.streaming import ( ChatCompletionsStream as _ChatCompletionsStream, ) -from celeste.protocols.chatcompletions.tools import ( - parse_tool_calls, - serialize_messages, -) -from celeste.tools import ToolCall -from celeste.types import TextContent +from celeste.protocols.chatcompletions.tools import parse_tool_calls +from celeste.tools import ToolCall, ToolResult +from celeste.types import Message, TextContent from celeste.utils import build_document_data_url, build_image_data_url from ...client import TextClient @@ -25,6 +25,57 @@ from .parameters import CHATCOMPLETIONS_PARAMETER_MAPPERS +def _chat_completions_text_messages( + messages: list[Message], +) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for msg in messages: + if isinstance(msg, ToolResult): + content = msg.content + if isinstance(content, BaseModel): + content = content.model_dump_json() + elif not isinstance(content, str): + content = json.dumps(content, default=str) + items.append( + { + "role": "tool", + "tool_call_id": msg.tool_call_id, + "content": content, + } + ) + elif msg.role == "assistant" and msg.tool_calls: + msg_dict = msg.model_dump( + exclude_none=True, + mode="json", + serialize_as_any=True, + ) + msg_dict.pop("reasoning", None) + msg_dict.pop("signature", None) + msg_dict["tool_calls"] = [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.name, + "arguments": json.dumps(tc.arguments), + }, + } + for tc in msg.tool_calls + ] + items.append(msg_dict) + else: + msg_dict = msg.model_dump( + exclude_none=True, + mode="json", + serialize_as_any=True, + ) + msg_dict.pop("tool_calls", None) + msg_dict.pop("reasoning", None) + msg_dict.pop("signature", None) + items.append(msg_dict) + return items + + class ChatCompletionsTextStream(_ChatCompletionsStream, TextStream): """Chat Completions streaming for text modality.""" @@ -39,7 +90,7 @@ def parameter_mappers(cls) -> list[ParameterMapper[TextContent]]: def _init_request(self, inputs: TextInput) -> dict[str, Any]: """Initialize request with Chat Completions message format.""" if inputs.messages is not None: - return {"messages": serialize_messages(inputs.messages)} + return {"messages": _chat_completions_text_messages(inputs.messages)} if inputs.image is None and inputs.document is None: content: str | list[dict[str, Any]] = inputs.prompt or "" diff --git a/src/celeste/modalities/text/protocols/openresponses/client.py b/src/celeste/modalities/text/protocols/openresponses/client.py index c70f091..fd46f6e 100644 --- a/src/celeste/modalities/text/protocols/openresponses/client.py +++ b/src/celeste/modalities/text/protocols/openresponses/client.py @@ -1,7 +1,10 @@ """OpenResponses text client.""" +import json from typing import Any +from pydantic import BaseModel + from celeste.parameters import ParameterMapper from celeste.protocols.openresponses.client import ( OpenResponsesClient as OpenResponsesMixin, @@ -13,10 +16,9 @@ parse_content, parse_reasoning, parse_tool_calls, - serialize_messages, ) -from celeste.tools import ToolCall -from celeste.types import TextContent +from celeste.tools import ToolCall, ToolResult +from celeste.types import Message, TextContent from celeste.utils import build_document_data_url, build_image_data_url from ...client import TextClient @@ -28,6 +30,51 @@ from .parameters import OPENRESPONSES_PARAMETER_MAPPERS +def _openresponses_text_input_messages( + messages: list[Message], +) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for msg in messages: + if isinstance(msg, ToolResult): + content = msg.content + if isinstance(content, BaseModel): + content = content.model_dump_json() + elif not isinstance(content, str): + content = json.dumps(content, default=str) + items.append( + { + "type": "function_call_output", + "call_id": msg.tool_call_id, + "output": content, + } + ) + elif msg.role == "assistant" and (msg.tool_calls or msg.signature): + sig_blocks = msg.signature + if sig_blocks: + items.extend(sig_blocks) + if msg.tool_calls: + for tc in msg.tool_calls: + items.append( + { + "type": "function_call", + "name": tc.name, + "arguments": json.dumps(tc.arguments), + "call_id": tc.id, + } + ) + else: + msg_dict = msg.model_dump( + exclude_none=True, + mode="json", + serialize_as_any=True, + ) + msg_dict.pop("tool_calls", None) + msg_dict.pop("reasoning", None) + msg_dict.pop("signature", None) + items.append(msg_dict) + return items + + class OpenResponsesTextStream(_OpenResponsesStream, TextStream): """OpenResponses streaming for text modality.""" @@ -80,7 +127,7 @@ def parameter_mappers(cls) -> list[ParameterMapper[TextContent]]: def _init_request(self, inputs: TextInput) -> dict[str, Any]: """Initialize request with input content.""" if inputs.messages is not None: - return {"input": serialize_messages(inputs.messages)} + return {"input": _openresponses_text_input_messages(inputs.messages)} content: list[dict[str, Any]] = [] if inputs.image is not None: diff --git a/src/celeste/modalities/text/providers/anthropic/client.py b/src/celeste/modalities/text/providers/anthropic/client.py index 101cb5c..d72d7a0 100644 --- a/src/celeste/modalities/text/providers/anthropic/client.py +++ b/src/celeste/modalities/text/providers/anthropic/client.py @@ -2,8 +2,11 @@ import base64 import contextlib +import json from typing import Any +from pydantic import BaseModel + from celeste.artifacts import DocumentArtifact, ImageArtifact from celeste.mime_types import ImageMimeType from celeste.parameters import ParameterMapper @@ -141,11 +144,15 @@ def _init_request(self, inputs: TextInput) -> dict[str, Any]: continue if isinstance(message, ToolResult): + if isinstance(content, BaseModel): + content = content.model_dump_json() + elif not isinstance(content, str): + content = json.dumps(content, default=str) pending_tool_results.append( { "type": "tool_result", "tool_use_id": message.tool_call_id, - "content": str(content), + "content": content, } ) continue diff --git a/src/celeste/modalities/text/providers/cohere/client.py b/src/celeste/modalities/text/providers/cohere/client.py index 90de13d..4bf64f6 100644 --- a/src/celeste/modalities/text/providers/cohere/client.py +++ b/src/celeste/modalities/text/providers/cohere/client.py @@ -35,7 +35,12 @@ def _init_request(self, inputs: TextInput) -> dict[str, Any]: if inputs.messages is not None: return { "messages": [ - message.model_dump(exclude_none=True) for message in inputs.messages + message.model_dump( + exclude_none=True, + mode="json", + serialize_as_any=True, + ) + for message in inputs.messages ] } diff --git a/src/celeste/modalities/text/providers/google/client.py b/src/celeste/modalities/text/providers/google/client.py index de81993..34926de 100644 --- a/src/celeste/modalities/text/providers/google/client.py +++ b/src/celeste/modalities/text/providers/google/client.py @@ -3,6 +3,8 @@ from typing import Any from uuid import uuid4 +from pydantic import BaseModel + from celeste.parameters import ParameterMapper from celeste.providers.google.generate_content.client import GoogleGenerateContentClient from celeste.providers.google.generate_content.streaming import ( @@ -91,6 +93,9 @@ def content_to_parts(content: Any) -> list[dict[str, Any]]: if msg.role in ("system", "developer"): system_parts.extend(content_to_parts(msg.content)) elif isinstance(msg, ToolResult): + content = msg.content + if isinstance(content, BaseModel): + content = content.model_dump(mode="json") contents.append( { "role": "user", @@ -98,7 +103,7 @@ def content_to_parts(content: Any) -> list[dict[str, Any]]: { "functionResponse": { "name": msg.name, - "response": {"result": msg.content}, + "response": {"result": content}, } } ], diff --git a/src/celeste/protocols/chatcompletions/tools.py b/src/celeste/protocols/chatcompletions/tools.py index 509f190..0878ea7 100644 --- a/src/celeste/protocols/chatcompletions/tools.py +++ b/src/celeste/protocols/chatcompletions/tools.py @@ -3,8 +3,7 @@ import json from typing import Any -from celeste.tools import ToolCall, ToolMapper, ToolResult -from celeste.types import Message +from celeste.tools import ToolCall, ToolMapper TOOL_MAPPERS: list[ToolMapper] = [] @@ -42,47 +41,7 @@ def parse_tool_calls( return tool_calls -def serialize_messages( - messages: list[Message], -) -> list[dict[str, Any]]: - """Serialize messages to Chat Completions API format.""" - items: list[dict[str, Any]] = [] - for msg in messages: - if isinstance(msg, ToolResult): - items.append( - { - "role": "tool", - "tool_call_id": msg.tool_call_id, - "content": str(msg.content), - } - ) - elif msg.role == "assistant" and msg.tool_calls: - msg_dict = msg.model_dump(exclude_none=True) - msg_dict.pop("reasoning", None) - msg_dict.pop("signature", None) - msg_dict["tool_calls"] = [ - { - "id": tc.id, - "type": "function", - "function": { - "name": tc.name, - "arguments": json.dumps(tc.arguments), - }, - } - for tc in msg.tool_calls - ] - items.append(msg_dict) - else: - msg_dict = msg.model_dump(exclude_none=True) - msg_dict.pop("tool_calls", None) - msg_dict.pop("reasoning", None) - msg_dict.pop("signature", None) - items.append(msg_dict) - return items - - __all__ = [ "TOOL_MAPPERS", "parse_tool_calls", - "serialize_messages", ] diff --git a/src/celeste/protocols/openresponses/tools.py b/src/celeste/protocols/openresponses/tools.py index 0b35896..93b15d3 100644 --- a/src/celeste/protocols/openresponses/tools.py +++ b/src/celeste/protocols/openresponses/tools.py @@ -10,11 +10,9 @@ Tool, ToolCall, ToolMapper, - ToolResult, WebSearch, XSearch, ) -from celeste.types import Message class WebSearchMapper(ToolMapper): @@ -121,41 +119,6 @@ def parse_reasoning( return text, signature_blocks -def serialize_messages(messages: list[Message]) -> list[dict[str, Any]]: - """Serialize messages to Responses API input format.""" - items: list[dict[str, Any]] = [] - for msg in messages: - if isinstance(msg, ToolResult): - items.append( - { - "type": "function_call_output", - "call_id": msg.tool_call_id, - "output": str(msg.content), - } - ) - elif msg.role == "assistant" and (msg.tool_calls or msg.signature): - sig_blocks = msg.signature - if sig_blocks: - items.extend(sig_blocks) - if msg.tool_calls: - for tc in msg.tool_calls: - items.append( - { - "type": "function_call", - "name": tc.name, - "arguments": json.dumps(tc.arguments), - "call_id": tc.id, - } - ) - else: - msg_dict = msg.model_dump(exclude_none=True) - msg_dict.pop("tool_calls", None) - msg_dict.pop("reasoning", None) - msg_dict.pop("signature", None) - items.append(msg_dict) - return items - - __all__ = [ "TOOL_MAPPERS", "CodeExecutionMapper", @@ -164,5 +127,4 @@ def serialize_messages(messages: list[Message]) -> list[dict[str, Any]]: "parse_content", "parse_reasoning", "parse_tool_calls", - "serialize_messages", ] diff --git a/src/celeste/tools.py b/src/celeste/tools.py index 33dcea1..bcd0c75 100644 --- a/src/celeste/tools.py +++ b/src/celeste/tools.py @@ -4,7 +4,7 @@ from enum import StrEnum from typing import Any, ClassVar -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from celeste.types import Message, Role, ToolCall @@ -74,6 +74,21 @@ class ToolResult(Message): name: str | None = None +class ToolOutput[Content](BaseModel): + """Structured output returned by executable tools.""" + + content: Content + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ToolError[Content](BaseModel): + """Structured error returned by executable tools.""" + + content: Content + code: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + __all__ = [ "CodeExecution", "Tool", @@ -81,7 +96,9 @@ class ToolResult(Message): "ToolChoice", "ToolChoiceOption", "ToolDefinition", + "ToolError", "ToolMapper", + "ToolOutput", "ToolResult", "WebSearch", "XSearch", diff --git a/tests/unit_tests/test_text_tool_results.py b/tests/unit_tests/test_text_tool_results.py new file mode 100644 index 0000000..538344f --- /dev/null +++ b/tests/unit_tests/test_text_tool_results.py @@ -0,0 +1,137 @@ +"""Unit tests for text tool-result request payloads.""" + +import json + +from pydantic import BaseModel, SecretStr + +from celeste import Message, Model, Role +from celeste.auth import AuthHeader +from celeste.core import InputType, Modality, Operation, Provider +from celeste.modalities.text.io import TextInput +from celeste.modalities.text.protocols.chatcompletions.client import ( + ChatCompletionsTextClient, +) +from celeste.modalities.text.protocols.openresponses.client import ( + OpenResponsesTextClient, +) +from celeste.modalities.text.providers.anthropic.client import AnthropicTextClient +from celeste.modalities.text.providers.cohere.client import CohereTextClient +from celeste.modalities.text.providers.google.client import GoogleTextClient +from celeste.tools import ToolOutput, ToolResult + + +class ArtifactRef(BaseModel): + id: str + artifact_type: InputType + + +def _text_model(provider: Provider) -> Model: + return Model( + id="test-model", + provider=provider, + display_name="Test Model", + operations={Modality.TEXT: {Operation.GENERATE}}, + ) + + +def _artifact_output() -> ToolOutput[ArtifactRef]: + return ToolOutput[ArtifactRef]( + content=ArtifactRef(id="image-1", artifact_type=InputType.IMAGE), + metadata={"provider": "test"}, + ) + + +def _tool_result(content: object) -> ToolResult: + return ToolResult( + content=content, + tool_call_id="call_123", + name="generate_image", + ) + + +def test_chat_completions_tool_result_uses_pydantic_json_text() -> None: + output = _artifact_output() + client = ChatCompletionsTextClient( + model=_text_model(Provider.OPENAI), + provider=Provider.OPENAI, + auth=AuthHeader(secret=SecretStr("test")), + ) + + request = client._init_request(TextInput(messages=[_tool_result(output)])) + + content = request["messages"][0]["content"] + assert content == output.model_dump_json() + assert json.loads(content) == output.model_dump(mode="json") + + +def test_chat_completions_tool_result_preserves_string_content() -> None: + client = ChatCompletionsTextClient( + model=_text_model(Provider.OPENAI), + provider=Provider.OPENAI, + auth=AuthHeader(secret=SecretStr("test")), + ) + + request = client._init_request(TextInput(messages=[_tool_result("done")])) + + assert request["messages"][0]["content"] == "done" + + +def test_openresponses_tool_result_uses_pydantic_json_text() -> None: + output = _artifact_output() + client = OpenResponsesTextClient( + model=_text_model(Provider.OPENAI), + provider=Provider.OPENAI, + auth=AuthHeader(secret=SecretStr("test")), + ) + + request = client._init_request(TextInput(messages=[_tool_result(output)])) + + content = request["input"][0]["output"] + assert content == output.model_dump_json() + assert json.loads(content) == output.model_dump(mode="json") + + +def test_anthropic_tool_result_uses_pydantic_json_text() -> None: + output = _artifact_output() + client = AnthropicTextClient( + model=_text_model(Provider.ANTHROPIC), + provider=Provider.ANTHROPIC, + auth=AuthHeader(secret=SecretStr("test"), header="x-api-key", prefix=""), + ) + + request = client._init_request(TextInput(messages=[_tool_result(output)])) + + tool_block = request["messages"][0]["content"][0] + assert tool_block["content"] == output.model_dump_json() + assert json.loads(tool_block["content"]) == output.model_dump(mode="json") + + +def test_google_tool_result_uses_pydantic_json_object() -> None: + output = _artifact_output() + client = GoogleTextClient( + model=_text_model(Provider.GOOGLE), + provider=Provider.GOOGLE, + auth=AuthHeader(secret=SecretStr("test"), header="x-goog-api-key", prefix=""), + ) + + request = client._init_request(TextInput(messages=[_tool_result(output)])) + + result = request["contents"][0]["parts"][0]["functionResponse"]["response"][ + "result" + ] + assert result == output.model_dump(mode="json") + + +def test_cohere_message_content_preserves_pydantic_shape() -> None: + output = _artifact_output() + client = CohereTextClient( + model=_text_model(Provider.COHERE), + provider=Provider.COHERE, + auth=AuthHeader(secret=SecretStr("test")), + ) + + request = client._init_request( + TextInput(messages=[Message(role=Role.USER, content=output)]) + ) + + assert request["messages"][0]["content"] == output.model_dump(mode="json")