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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,13 @@ bandit-report.json

scripts/
assets/

# openspec — local-only authoring scratchpad
openspec/

# Agent / IDE tooling
.agents/
.claude/
.codex/
.cursor/
.worktrees/
4 changes: 4 additions & 0 deletions src/celeste/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
Tool,
ToolCall,
ToolChoice,
ToolError,
ToolOutput,
ToolResult,
WebSearch,
XSearch,
Expand Down Expand Up @@ -292,6 +294,8 @@ def create_client(
"Tool",
"ToolCall",
"ToolChoice",
"ToolError",
"ToolOutput",
"ToolResult",
"Usage",
"WebSearch",
Expand Down
65 changes: 58 additions & 7 deletions src/celeste/modalities/text/protocols/chatcompletions/client.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
"""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,
)
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
Expand All @@ -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."""

Expand All @@ -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 ""
Expand Down
55 changes: 51 additions & 4 deletions src/celeste/modalities/text/protocols/openresponses/client.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion src/celeste/modalities/text/providers/anthropic/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/celeste/modalities/text/providers/cohere/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
}

Expand Down
7 changes: 6 additions & 1 deletion src/celeste/modalities/text/providers/google/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -91,14 +93,17 @@ 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",
"parts": [
{
"functionResponse": {
"name": msg.name,
"response": {"result": msg.content},
"response": {"result": content},
}
}
],
Expand Down
43 changes: 1 addition & 42 deletions src/celeste/protocols/chatcompletions/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []

Expand Down Expand Up @@ -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",
]
38 changes: 0 additions & 38 deletions src/celeste/protocols/openresponses/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,9 @@
Tool,
ToolCall,
ToolMapper,
ToolResult,
WebSearch,
XSearch,
)
from celeste.types import Message


class WebSearchMapper(ToolMapper):
Expand Down Expand Up @@ -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",
Expand All @@ -164,5 +127,4 @@ def serialize_messages(messages: list[Message]) -> list[dict[str, Any]]:
"parse_content",
"parse_reasoning",
"parse_tool_calls",
"serialize_messages",
]
Loading
Loading