Skip to content
Draft
3 changes: 2 additions & 1 deletion chatlas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from ._stream_controller import StreamController
from ._tokens import token_usage
from ._tools import Tool, ToolBuiltIn, ToolRejectError
from ._tools_builtin import tool_web_fetch, tool_web_search
from ._tools_builtin import tool_code_execution, tool_web_fetch, tool_web_search
from ._turn import AssistantTurn, SystemTurn, Turn, UserTurn

try:
Expand Down Expand Up @@ -93,6 +93,7 @@
"Tool",
"ToolBuiltIn",
"ToolRejectError",
"tool_code_execution",
"tool_web_fetch",
"tool_web_search",
"Turn",
Expand Down
76 changes: 76 additions & 0 deletions chatlas/_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ def from_tool(cls, tool: "Tool | ToolBuiltIn") -> "ToolInfo":
"web_search_results",
"web_fetch_request",
"web_fetch_results",
"code_execution_request",
"code_execution_result",
]
"""
A discriminated union of all content types.
Expand Down Expand Up @@ -839,6 +841,74 @@ def __str__(self):
return f"[web fetch result]: {self.url}"


class ContentToolRequestCodeExecution(Content):
"""
A code execution request from the model.

This content type represents the model's request to run code in a
sandboxed environment. It's automatically generated when a built-in code
execution tool is used.

Parameters
----------
code
The code the model wants to run.
language
The programming language of `code`, if the provider reports it.
extra
The raw provider-specific response data.
"""

code: str
language: Optional[str] = None
extra: Optional[dict[str, Any]] = None

content_type: ContentTypeEnum = "code_execution_request"

def __str__(self):
return f"[code execution request]:\n```\n{self.code}\n```"


class ContentToolResponseCodeExecution(Content):
"""
Code execution results from the model.

This content type represents the result of running code in a sandboxed
environment. It's automatically generated when a built-in code execution
tool returns results.

Only text output (stdout/stderr or each provider's equivalent) is
surfaced here. Files the code produced (e.g. plots, CSVs) aren't
downloaded or decoded -- their raw provider references are still
available via `extra`.

Parameters
----------
output
The text output of the code (e.g. stdout).
error
The error output of the code (e.g. stderr), if any.
container_id
The id of the sandbox/container the code ran in, if the provider
exposes one. Used by `chatlas.Chat` to reuse the same sandbox across
turns for providers that support it (OpenAI, Anthropic).
extra
The raw provider-specific response data.
"""

output: Optional[str] = None
error: Optional[str] = None
container_id: Optional[str] = None
extra: Optional[dict[str, Any]] = None

content_type: ContentTypeEnum = "code_execution_result"

def __str__(self):
if self.error:
return f"[code execution error]:\n```\n{self.error}\n```"
return f"[code execution result]:\n```\n{self.output or ''}\n```"


ContentUnion = Union[
ContentText,
ContentImageRemote,
Expand All @@ -852,6 +922,8 @@ def __str__(self):
ContentToolResponseSearch,
ContentToolRequestFetch,
ContentToolResponseFetch,
ContentToolRequestCodeExecution,
ContentToolResponseCodeExecution,
]


Expand Down Expand Up @@ -917,6 +989,10 @@ def create_content(data: dict[str, Any]) -> ContentUnion:
return ContentToolRequestFetch.model_validate(data)
elif ct == "web_fetch_results":
return ContentToolResponseFetch.model_validate(data)
elif ct == "code_execution_request":
return ContentToolRequestCodeExecution.model_validate(data)
elif ct == "code_execution_result":
return ContentToolResponseCodeExecution.model_validate(data)
else:
raise ValueError(f"Unknown content type: {ct}")

Expand Down
70 changes: 66 additions & 4 deletions chatlas/_provider_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@
ContentThinking,
ContentThinkingDelta,
ContentToolRequest,
ContentToolRequestCodeExecution,
ContentToolRequestFetch,
ContentToolRequestSearch,
ContentToolResponseCodeExecution,
ContentToolResponseFetch,
ContentToolResponseSearch,
ContentToolResult,
Expand All @@ -44,7 +46,12 @@
)
from ._tokens import get_price_info
from ._tools import Tool, ToolBuiltIn, basemodel_to_param_schema
from ._tools_builtin import ToolWebFetch, ToolWebSearch
from ._tools_builtin import (
ToolCodeExecution,
ToolWebFetch,
ToolWebSearch,
last_code_execution_container_id,
)
from ._turn import AssistantTurn, FinishReason, SystemTurn, Turn, UserTurn, user_turn
from ._utils import split_http_client_kwargs

Expand Down Expand Up @@ -492,6 +499,13 @@ def _chat_perform_args(
**(kwargs or {}),
}

if "container" not in kwargs_full and any(
isinstance(tool, ToolCodeExecution) for tool in tools.values()
):
container_id = last_code_execution_container_id(turns)
if container_id is not None:
kwargs_full["container"] = container_id

if data_model is not None and use_native:
kwargs_full["output_config"] = output_config # type: ignore[reportPossiblyUnbound]
elif data_model is not None:
Expand Down Expand Up @@ -591,6 +605,10 @@ def stream_merge_chunks(self, completion, chunk):
completion.stop_reason = chunk.delta.stop_reason
completion.stop_sequence = chunk.delta.stop_sequence
completion.usage.output_tokens = chunk.usage.output_tokens
# Anthropic only sends `container` (e.g. from the code execution
# tool) on the message_delta event, not message_start.
if chunk.delta.container is not None:
completion.container = chunk.delta.container

return completion

Expand Down Expand Up @@ -716,9 +734,13 @@ def _as_message_params(self, turns: Sequence[Turn]) -> list["MessageParam"]:
# https://docs.claude.com/en/docs/build-with-claude/prompt-caching#how-automatic-prefix-checking-works
is_last_turn = i == len(turns) - 1
if self._cache_control() and is_last_turn and len(content) > 0:
# Note: ThinkingBlockParam (i.e., type: "thinking") doesn't support cache_control
if content[-1].get("type") != "thinking":
content[-1]["cache_control"] = self._cache_control() # type: ignore
last_block = content[-1]
# Note: ThinkingBlockParam (i.e., type: "thinking") doesn't support
# cache_control. `last_block` can also be None for round-tripped
# server tool content missing `extra` (e.g. manually constructed
# in tests), in which case there's nothing to attach cache_control to.
if last_block is not None and last_block.get("type") != "thinking":
last_block["cache_control"] = self._cache_control() # type: ignore

role = "user" if isinstance(turn, UserTurn) else "assistant"
messages.append({"role": role, "content": content})
Expand Down Expand Up @@ -788,6 +810,8 @@ def _as_content_block(content: Content) -> "ContentBlockParam":
ContentToolResponseSearch,
ContentToolRequestFetch,
ContentToolResponseFetch,
ContentToolRequestCodeExecution,
ContentToolResponseCodeExecution,
),
):
# extra contains the full original content block param
Expand All @@ -803,6 +827,9 @@ def _anthropic_tool_schema(tool: "Tool | ToolBuiltIn") -> "ToolUnionParam":
# N.B. seems the return type here (BetaWebFetchTool20250910Param) is
# not a member of ToolUnionParam since it's still in beta?
return tool.get_definition("anthropic") # type: ignore
if isinstance(tool, ToolCodeExecution):
# N.B. same beta-type situation as ToolWebFetch above.
return tool.get_definition("anthropic") # type: ignore
if isinstance(tool, ToolBuiltIn):
return tool.definition # type: ignore

Expand Down Expand Up @@ -907,6 +934,13 @@ def _as_turn(self, completion: Message, has_data_model=False) -> AssistantTurn:
extra=extra,
)
)
elif content.name == "code_execution":
contents.append(
ContentToolRequestCodeExecution(
code=str(input_data.get("code", "")),
extra=extra,
)
)
else:
raise ValueError(f"Unknown server tool: {content.name}")
elif content.type == "web_search_tool_result":
Expand Down Expand Up @@ -954,6 +988,34 @@ def _as_turn(self, completion: Message, has_data_model=False) -> AssistantTurn:
extra=extra,
)
)
elif content.type == "code_execution_tool_result":
# https://docs.claude.com/en/docs/agents-and-tools/tool-use/code-execution-tool#result
result = content.content
if result.type == "code_execution_tool_result_error":
output = None
error = result.error_code
elif result.type == "code_execution_result":
output = result.stdout or None
error = result.stderr or None
else:
# encrypted_code_execution_result: stdout isn't available in plaintext
output = None
error = result.stderr or None
extra = {
"type": content.type,
"tool_use_id": content.tool_use_id,
"content": result.model_dump(exclude_none=True),
}
contents.append(
ContentToolResponseCodeExecution(
output=output,
error=error,
container_id=completion.container.id
if completion.container
else None,
extra=extra,
)
)

return AssistantTurn(
contents,
Expand Down
49 changes: 48 additions & 1 deletion chatlas/_provider_google.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
ContentThinking,
ContentThinkingDelta,
ContentToolRequest,
ContentToolRequestCodeExecution,
ContentToolResponseCodeExecution,
ContentToolResult,
)
from ._logging import log_model_default
Expand All @@ -32,7 +34,7 @@
)
from ._tokens import get_price_info
from ._tools import Tool, ToolBuiltIn
from ._tools_builtin import ToolWebFetch, ToolWebSearch
from ._tools_builtin import ToolCodeExecution, ToolWebFetch, ToolWebSearch
from ._turn import AssistantTurn, FinishReason, SystemTurn, Turn, UserTurn, user_turn

if TYPE_CHECKING:
Expand Down Expand Up @@ -398,6 +400,9 @@ def _chat_perform_args(
elif isinstance(tool, ToolWebFetch):
gtool = GoogleTool(url_context=tool.get_definition("google"))
google_tools.append(gtool)
elif isinstance(tool, ToolCodeExecution):
gtool = GoogleTool(code_execution=tool.get_definition("google"))
google_tools.append(gtool)
elif isinstance(tool, ToolBuiltIn):
gtool = GoogleTool.model_validate(tool.definition)
google_tools.append(gtool)
Expand Down Expand Up @@ -618,6 +623,26 @@ def _as_part_type(self, content: Content) -> "Part":
response=resp,
)
)
elif isinstance(content, ContentToolRequestCodeExecution):
from google.genai.types import ExecutableCode, Language

return Part(
executable_code=ExecutableCode(
code=content.code,
language=Language.PYTHON,
)
)
elif isinstance(content, ContentToolResponseCodeExecution):
from google.genai.types import CodeExecutionResult, Outcome

return Part(
code_execution_result=CodeExecutionResult(
outcome=Outcome.OUTCOME_FAILED
if content.error
else Outcome.OUTCOME_OK,
output=content.error or content.output,
)
)
raise ValueError(f"Unknown content type: {type(content)}")

def _as_turn(
Expand Down Expand Up @@ -695,6 +720,28 @@ def _as_turn(
image_content_type=mime_type, # type: ignore
)
)
executable_code = part.get("executable_code")
if executable_code:
code = executable_code.get("code")
if code:
contents.append(
ContentToolRequestCodeExecution(
code=code,
language=executable_code.get("language"),
extra=dict(executable_code),
)
)
code_execution_result = part.get("code_execution_result")
if code_execution_result:
outcome = code_execution_result.get("outcome")
output = code_execution_result.get("output")
contents.append(
ContentToolResponseCodeExecution(
output=output if outcome == "OUTCOME_OK" else None,
error=output if outcome != "OUTCOME_OK" else None,
extra=dict(code_execution_result),
)
)

if isinstance(finish_reason, FinishReason):
finish_reason = finish_reason.name
Expand Down
Loading