diff --git a/.inline-snapshot/external/.gitignore b/.inline-snapshot/external/.gitignore new file mode 100644 index 000000000..92f893631 --- /dev/null +++ b/.inline-snapshot/external/.gitignore @@ -0,0 +1,2 @@ +# ignore all snapshots which are not referred in the source +*-new.* diff --git a/.inline-snapshot/external/cd8d3d185e7a993935ab650e0e5c6a7970758bac7d0487f9b1afaad2cc095f3c.json b/.inline-snapshot/external/cd8d3d185e7a993935ab650e0e5c6a7970758bac7d0487f9b1afaad2cc095f3c.json new file mode 100644 index 000000000..1de1d9f3f --- /dev/null +++ b/.inline-snapshot/external/cd8d3d185e7a993935ab650e0e5c6a7970758bac7d0487f9b1afaad2cc095f3c.json @@ -0,0 +1,4 @@ +[ + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01C63EzoQa1oa1eNn778a6ep\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":656,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":3,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I'll get\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" the weather for San Francisco for you.\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_011V184uLZKFsxYfCJSjiQ6q\",\"name\":\"get_weather\",\"input\":{}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"loc\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ati\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"on\\\": \\\"San Fr\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"anc\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"isc\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"o, CA\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\", \\\"units\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\": \\\"f\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":656,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":85} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01Vm8Ddgc8qm4iuUSKbf6jku\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":781,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":6,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The weather in San Francisco,\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" CA is currently **Sunny** with a temperature of **\"}}\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"68°F**.\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":781,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":25} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" +] \ No newline at end of file diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ae50e2a9e..f910f1a9c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.72.1" + ".": "0.73.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 4a52e8a27..4f7356bb8 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 34 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic%2Fanthropic-5e665f72d2774cd751988ccc94f623f264d9358aa073289779de5815d36e89a3.yml -openapi_spec_hash: c5f969a677c73796d192cf09dbb047f9 -config_hash: fd2165a5f09975707d3c0f6f78fb2be7 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic%2Fanthropic-2f35b2ff9174d526a6d35796d2703490bfa5692312af67cbdfa4500283dabe31.yml +openapi_spec_hash: dc52b25c487e97d355ef645644aa13e7 +config_hash: 239752fc0713a82e121ea45f7e2ebbf6 diff --git a/CHANGELOG.md b/CHANGELOG.md index dbaf3cf10..a31f41659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.73.0 (2025-11-14) + +Full Changelog: [v0.72.1...v0.73.0](https://github.com/anthropics/anthropic-sdk-python/compare/v0.72.1...v0.73.0) + +### Features + +* **api:** add support for structured outputs beta ([688da81](https://github.com/anthropics/anthropic-sdk-python/commit/688da8126df8a304c5f01f0dc63cda437a62c217)) + ## 0.72.1 (2025-11-11) Full Changelog: [v0.72.0...v0.72.1](https://github.com/anthropics/anthropic-sdk-python/compare/v0.72.0...v0.72.1) diff --git a/api.md b/api.md index f10182e47..6248557a3 100644 --- a/api.md +++ b/api.md @@ -263,6 +263,7 @@ from anthropic.types.beta import ( BetaInputJSONDelta, BetaInputTokensClearAtLeast, BetaInputTokensTrigger, + BetaJSONOutputFormat, BetaMCPToolResultBlock, BetaMCPToolUseBlock, BetaMCPToolUseBlockParam, diff --git a/examples/structured_outputs.py b/examples/structured_outputs.py new file mode 100644 index 000000000..1a3831a33 --- /dev/null +++ b/examples/structured_outputs.py @@ -0,0 +1,37 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "anthropic", +# ] +# +# [tool.uv.sources] +# anthropic = { path = "../", editable = true } +# /// + + +import pydantic + +import anthropic + + +class Order(pydantic.BaseModel): + product_name: str + price: float + quantity: int + + +client = anthropic.Anthropic() + +prompt = """ +Extract the product name, price, and quantity from this customer message: +"Hi, I’d like to order 2 packs of Green Tea for 5.50 dollars each." +""" + +parsed_message = client.beta.messages.parse( + model="claude-sonnet-4-5-20250929-structured-outputs", + messages=[{"role": "user", "content": prompt}], + max_tokens=1024, + output_format=Order, +) + +print(parsed_message.parsed_output) # Order(product_name='Green Tea', price=5.5, quantity=2) diff --git a/examples/structured_outputs_streaming.py b/examples/structured_outputs_streaming.py new file mode 100644 index 000000000..1f3c27fc8 --- /dev/null +++ b/examples/structured_outputs_streaming.py @@ -0,0 +1,38 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "anthropic", +# ] +# +# [tool.uv.sources] +# anthropic = { path = "../", editable = true } +# /// + +import pydantic + +import anthropic + + +class Order(pydantic.BaseModel): + product_name: str + price: float + quantity: int + + +client = anthropic.Anthropic() + +prompt = """ +Extract the product name, price, and quantity from this customer message: +"Hi, I’d like to order 2 packs of Green Tea for 5.50 dollars each." +""" + +with client.beta.messages.stream( + model="claude-sonnet-4-5-20250929-structured-outputs", + messages=[{"role": "user", "content": prompt}], + betas=["structured-outputs-2025-09-17"], + max_tokens=1024, + output_format=Order, +) as stream: + for event in stream: + if event.type == "text": + print(event.parsed_snapshot()) diff --git a/pyproject.toml b/pyproject.toml index b15c9bc18..02950c774 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "anthropic" -version = "0.72.1" +version = "0.73.0" description = "The official Python library for the anthropic API" dynamic = ["readme"] license = "MIT" @@ -160,7 +160,7 @@ show_error_codes = true # # We also exclude our `tests` as mypy doesn't always infer # types correctly and Pyright will still catch any type errors. -exclude = ['src/anthropic/_files.py', '_dev/.*.py', 'tests/.*', 'examples/mcp_server_weather.py', 'examples/tools_with_mcp.py', 'examples/memory/basic.py'] +exclude = ['src/anthropic/_files.py', '_dev/.*.py', 'tests/.*', 'examples/mcp_server_weather.py', 'examples/tools_with_mcp.py', 'examples/memory/basic.py', 'src/anthropic/lib/_parse/_transform.py'] strict_equality = true implicit_reexport = true diff --git a/src/anthropic/__init__.py b/src/anthropic/__init__.py index 4090e99b6..b4b49330f 100644 --- a/src/anthropic/__init__.py +++ b/src/anthropic/__init__.py @@ -44,6 +44,7 @@ ) from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient from ._utils._logs import setup_logging as _setup_logging +from .lib._parse._transform import transform_schema __all__ = [ "types", @@ -91,6 +92,7 @@ "AI_PROMPT", "beta_tool", "beta_async_tool", + "transform_schema", ] if not _t.TYPE_CHECKING: diff --git a/src/anthropic/_compat.py b/src/anthropic/_compat.py index bdef67f04..b08033c70 100644 --- a/src/anthropic/_compat.py +++ b/src/anthropic/_compat.py @@ -131,6 +131,12 @@ def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: return model.model_dump_json(indent=indent) +def model_parse_json(model: type[_ModelT], data: str | bytes) -> _ModelT: + if PYDANTIC_V1: + return model.parse_raw(data) # pyright: ignore[reportDeprecated] + return model.model_validate_json(data) + + def model_dump( model: pydantic.BaseModel, *, diff --git a/src/anthropic/_models.py b/src/anthropic/_models.py index 3b8702977..f0a239779 100644 --- a/src/anthropic/_models.py +++ b/src/anthropic/_models.py @@ -774,7 +774,7 @@ class GenericModel(BaseGenericModel, BaseModel): if not PYDANTIC_V1: - from pydantic import TypeAdapter as _TypeAdapter + from pydantic import TypeAdapter as _TypeAdapter, computed_field as computed_field _CachedTypeAdapter = cast("TypeAdapter[object]", lru_cache(maxsize=None)(_TypeAdapter)) @@ -811,6 +811,18 @@ def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]: def TypeAdapter(*_args: Any, **_kwargs: Any) -> Any: raise RuntimeError("attempted to use TypeAdapter in pydantic v1") + def computed_field(func: Any | None = None, /, **__: Any) -> Any: + def _exc_func(*_: Any, **__: Any) -> Any: + raise RuntimeError("attempted to use computed_field in pydantic v1") + + def _dec(*_: Any, **__: Any) -> Any: + return _exc_func + + if func is not None: + return _dec(func) + else: + return _dec + class FinalRequestOptionsInput(TypedDict, total=False): method: Required[str] diff --git a/src/anthropic/_utils/_transform.py b/src/anthropic/_utils/_transform.py index 520754920..414f38c34 100644 --- a/src/anthropic/_utils/_transform.py +++ b/src/anthropic/_utils/_transform.py @@ -218,7 +218,7 @@ def _transform_recursive( return data if isinstance(data, pydantic.BaseModel): - return model_dump(data, exclude_unset=True, mode="json") + return model_dump(data, exclude_unset=True, mode="json", exclude=getattr(data, "__api_exclude__", None)) annotated_type = _get_annotated_type(annotation) if annotated_type is None: diff --git a/src/anthropic/_version.py b/src/anthropic/_version.py index 82fceeb02..bcf7aab54 100644 --- a/src/anthropic/_version.py +++ b/src/anthropic/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "anthropic" -__version__ = "0.72.1" # x-release-please-version +__version__ = "0.73.0" # x-release-please-version diff --git a/src/anthropic/lib/_parse/_response.py b/src/anthropic/lib/_parse/_response.py new file mode 100644 index 000000000..f3d5d7cf1 --- /dev/null +++ b/src/anthropic/lib/_parse/_response.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing_extensions import TypeVar + +from ..._types import NotGiven +from ..._models import TypeAdapter, construct_type_unchecked +from ..._utils._utils import is_given +from ...types.beta.beta_message import BetaMessage +from ...types.beta.parsed_beta_message import ParsedBetaMessage, ParsedBetaTextBlock, ParsedBetaContentBlock + +ResponseFormatT = TypeVar("ResponseFormatT", default=None) + + +def parse_text(text: str, output_format: ResponseFormatT | NotGiven) -> ResponseFormatT | None: + if is_given(output_format): + adapted_type: TypeAdapter[ResponseFormatT] = TypeAdapter(output_format) + return adapted_type.validate_json(text) + return None + + +def parse_response( + *, + output_format: ResponseFormatT | NotGiven, + response: BetaMessage, +) -> ParsedBetaMessage[ResponseFormatT]: + content_list: list[ParsedBetaContentBlock[ResponseFormatT]] = [] + for content in response.content: + if content.type == "text": + content_list.append( + construct_type_unchecked( + type_=ParsedBetaTextBlock[ResponseFormatT], + value={**content.to_dict(), "parsed_output": parse_text(content.text, output_format)}, + ) + ) + else: + content_list.append(content) # type: ignore + + return construct_type_unchecked( + type_=ParsedBetaMessage[ResponseFormatT], + value={ + **response.to_dict(), + "content": content_list, + }, + ) diff --git a/src/anthropic/lib/_parse/_transform.py b/src/anthropic/lib/_parse/_transform.py new file mode 100644 index 000000000..4cc85df35 --- /dev/null +++ b/src/anthropic/lib/_parse/_transform.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import inspect +from typing import Any, Literal, Optional, cast +from typing_extensions import assert_never + +import pydantic + +from ..._utils import is_list + +SupportedTypes = Literal[ + "object", + "array", + "string", + "integer", + "number", + "boolean", + "null", +] + +SupportedStringFormats = { + "date-time", + "time", + "date", + "duration", + "email", + "hostname", + "uri", + "ipv4", + "ipv6", + "uuid", +} + + +def get_transformed_string( + schema: dict[str, Any], +) -> dict[str, Any]: + """Transforms a JSON schema of type string to ensure it conforms to the API's expectations. + + Specifically, it ensures that if the schema is of type "string" and does not already + specify a "format", it sets the format to "text". + + Args: + schema: The original JSON schema. + + Returns: + The transformed JSON schema. + """ + if schema.get("type") == "string" and "format" not in schema: + schema["format"] = "text" + return schema + + +def transform_schema( + json_schema: type[pydantic.BaseModel] | dict[str, Any], +) -> dict[str, Any]: + """ + Transforms a JSON schema to ensure it conforms to the API's expectations. + + Args: + json_schema (Dict[str, Any]): The original JSON schema. + + Returns: + The transformed JSON schema. + + Examples: + >>> transform_schema( + ... { + ... "type": "integer", + ... "minimum": 1, + ... "maximum": 10, + ... "description": "A number", + ... } + ... ) + {'type': 'integer', 'description': 'A number\n\n{minimum: 1, maximum: 10}'} + """ + if inspect.isclass(json_schema) and issubclass(json_schema, pydantic.BaseModel): # pyright: ignore[reportUnnecessaryIsInstance] + json_schema = json_schema.model_json_schema() + + strict_schema: dict[str, Any] = {} + json_schema = {**json_schema} + + ref = json_schema.pop("$ref", None) + if ref is not None: + strict_schema["$ref"] = ref + return strict_schema + + defs = json_schema.pop("$defs", None) + if defs is not None: + strict_defs: dict[str, Any] = {} + strict_schema["$defs"] = strict_defs + + for name, schema in defs.items(): + strict_defs[name] = transform_schema(schema) + + type_: Optional[SupportedTypes] = json_schema.pop("type", None) + any_of = json_schema.pop("anyOf", None) + one_of = json_schema.pop("oneOf", None) + all_of = json_schema.pop("allOf", None) + + if is_list(any_of): + strict_schema["anyOf"] = [transform_schema(cast("dict[str, Any]", variant)) for variant in any_of] + elif is_list(one_of): + strict_schema["anyOf"] = [transform_schema(cast("dict[str, Any]", variant)) for variant in one_of] + elif is_list(all_of): + strict_schema["allOf"] = [transform_schema(cast("dict[str, Any]", variant)) for variant in all_of] + else: + if type_ is None: + raise ValueError("Schema must have a 'type', 'anyOf', 'oneOf', or 'allOf' field.") + + strict_schema["type"] = type_ + + description = json_schema.pop("description", None) + if description is not None: + strict_schema["description"] = description + + title = json_schema.pop("title", None) + if title is not None: + strict_schema["title"] = title + + if type_ == "object": + strict_schema["properties"] = { + key: transform_schema(prop_schema) for key, prop_schema in json_schema.pop("properties", {}).items() + } + json_schema.pop("additionalProperties", None) + strict_schema["additionalProperties"] = False + + required = json_schema.pop("required", None) + if required is not None: + strict_schema["required"] = required + + elif type_ == "string": + format = json_schema.pop("format", None) + if format and format in SupportedStringFormats: + strict_schema["format"] = format + elif format: + # add it back so its treated as an extra property and appended to the description + json_schema["format"] = format + elif type_ == "array": + items = json_schema.pop("items", None) + if items is not None: + strict_schema["items"] = transform_schema(items) + + min_items = json_schema.pop("minItems", None) + if min_items is not None and min_items == 0 or min_items == 1: + strict_schema["minItems"] = min_items + elif min_items is not None: + # add it back so its treated as an extra property and appended to the description + json_schema["minItems"] = min_items + + elif type_ == "boolean" or type_ == "integer" or type_ == "number" or type_ == "null" or type_ is None: + pass + else: + assert_never(type_) + + # if there are any propes leftover then they aren't supported, so we add them to the description + # so that the model *might* follow them. + if json_schema: + description = strict_schema.get("description") + strict_schema["description"] = ( + (description + "\n\n" if description is not None else "") + + "{" + + ", ".join(f"{key}: {value}" for key, value in json_schema.items()) + + "}" + ) + + return strict_schema diff --git a/src/anthropic/lib/streaming/__init__.py b/src/anthropic/lib/streaming/__init__.py index 103fff582..949b06a7f 100644 --- a/src/anthropic/lib/streaming/__init__.py +++ b/src/anthropic/lib/streaming/__init__.py @@ -1,3 +1,5 @@ +from typing_extensions import TypeAlias + from ._types import ( TextEvent as TextEvent, InputJsonEvent as InputJsonEvent, @@ -12,12 +14,20 @@ AsyncMessageStreamManager as AsyncMessageStreamManager, ) from ._beta_types import ( - BetaTextEvent as BetaTextEvent, BetaInputJsonEvent as BetaInputJsonEvent, - BetaMessageStopEvent as BetaMessageStopEvent, - BetaMessageStreamEvent as BetaMessageStreamEvent, - BetaContentBlockStopEvent as BetaContentBlockStopEvent, + ParsedBetaTextEvent as ParsedBetaTextEvent, + ParsedBetaMessageStopEvent as ParsedBetaMessageStopEvent, + ParsedBetaMessageStreamEvent as ParsedBetaMessageStreamEvent, + ParsedBetaContentBlockStopEvent as ParsedBetaContentBlockStopEvent, ) + +# For backwards compatibility +BetaTextEvent: TypeAlias = ParsedBetaTextEvent +BetaMessageStopEvent: TypeAlias = ParsedBetaMessageStopEvent[object] +BetaMessageStreamEvent: TypeAlias = ParsedBetaMessageStreamEvent +BetaContentBlockStopEvent: TypeAlias = ParsedBetaContentBlockStopEvent[object] + + from ._beta_messages import ( BetaMessageStream as BetaMessageStream, BetaAsyncMessageStream as BetaAsyncMessageStream, diff --git a/src/anthropic/lib/streaming/_beta_messages.py b/src/anthropic/lib/streaming/_beta_messages.py index 0c371cce1..6a2e0bb1f 100644 --- a/src/anthropic/lib/streaming/_beta_messages.py +++ b/src/anthropic/lib/streaming/_beta_messages.py @@ -1,7 +1,8 @@ from __future__ import annotations +import builtins from types import TracebackType -from typing import TYPE_CHECKING, Any, Type, Callable, cast +from typing import TYPE_CHECKING, Any, Type, Generic, Callable, cast from typing_extensions import Self, Iterator, Awaitable, AsyncIterator, assert_never import httpx @@ -11,23 +12,27 @@ from anthropic.types.beta.beta_mcp_tool_use_block import BetaMCPToolUseBlock from anthropic.types.beta.beta_server_tool_use_block import BetaServerToolUseBlock +from ..._types import NOT_GIVEN, NotGiven from ..._utils import consume_sync_iterator, consume_async_iterator from ..._models import build, construct_type, construct_type_unchecked from ._beta_types import ( - BetaTextEvent, BetaCitationEvent, BetaThinkingEvent, BetaInputJsonEvent, BetaSignatureEvent, - BetaMessageStopEvent, - BetaMessageStreamEvent, - BetaContentBlockStopEvent, + ParsedBetaTextEvent, + ParsedBetaMessageStopEvent, + ParsedBetaMessageStreamEvent, + ParsedBetaContentBlockStopEvent, ) from ..._streaming import Stream, AsyncStream -from ...types.beta import BetaMessage, BetaContentBlock, BetaRawMessageStreamEvent +from ...types.beta import BetaRawMessageStreamEvent +from ..._utils._utils import is_given +from .._parse._response import ResponseFormatT, parse_text +from ...types.beta.parsed_beta_message import ParsedBetaMessage, ParsedBetaContentBlock -class BetaMessageStream: +class BetaMessageStream(Generic[ResponseFormatT]): text_stream: Iterator[str] """Iterator over just the text deltas in the stream. @@ -38,11 +43,16 @@ class BetaMessageStream: ``` """ - def __init__(self, raw_stream: Stream[BetaRawMessageStreamEvent]) -> None: + def __init__( + self, + raw_stream: Stream[BetaRawMessageStreamEvent], + output_format: ResponseFormatT | NotGiven, + ) -> None: self._raw_stream = raw_stream self.text_stream = self.__stream_text__() self._iterator = self.__stream__() - self.__final_message_snapshot: BetaMessage | None = None + self.__final_message_snapshot: ParsedBetaMessage[ResponseFormatT] | None = None + self.__output_format = output_format @property def response(self) -> httpx.Response: @@ -52,10 +62,10 @@ def response(self) -> httpx.Response: def request_id(self) -> str | None: return self.response.headers.get("request-id") # type: ignore[no-any-return] - def __next__(self) -> BetaMessageStreamEvent: + def __next__(self) -> ParsedBetaMessageStreamEvent[ResponseFormatT]: return self._iterator.__next__() - def __iter__(self) -> Iterator[BetaMessageStreamEvent]: + def __iter__(self) -> Iterator[ParsedBetaMessageStreamEvent[ResponseFormatT]]: for item in self._iterator: yield item @@ -78,7 +88,7 @@ def close(self) -> None: """ self._raw_stream.close() - def get_final_message(self) -> BetaMessage: + def get_final_message(self) -> ParsedBetaMessage[ResponseFormatT]: """Waits until the stream has been read to completion and returns the accumulated `Message` object. """ @@ -113,16 +123,17 @@ def until_done(self) -> None: # properties @property - def current_message_snapshot(self) -> BetaMessage: + def current_message_snapshot(self) -> ParsedBetaMessage[ResponseFormatT]: assert self.__final_message_snapshot is not None return self.__final_message_snapshot - def __stream__(self) -> Iterator[BetaMessageStreamEvent]: + def __stream__(self) -> Iterator[ParsedBetaMessageStreamEvent[ResponseFormatT]]: for sse_event in self._raw_stream: self.__final_message_snapshot = accumulate_event( event=sse_event, current_snapshot=self.__final_message_snapshot, request_headers=self.response.request.headers, + output_format=self.__output_format, ) events_to_fire = build_events(event=sse_event, message_snapshot=self.current_message_snapshot) @@ -135,7 +146,7 @@ def __stream_text__(self) -> Iterator[str]: yield chunk.delta.text -class BetaMessageStreamManager: +class BetaMessageStreamManager(Generic[ResponseFormatT]): """Wrapper over MessageStream that is returned by `.stream()`. ```py @@ -148,13 +159,16 @@ class BetaMessageStreamManager: def __init__( self, api_request: Callable[[], Stream[BetaRawMessageStreamEvent]], + *, + output_format: ResponseFormatT | NotGiven, ) -> None: - self.__stream: BetaMessageStream | None = None + self.__stream: BetaMessageStream[ResponseFormatT] | None = None self.__api_request = api_request + self.__output_format = output_format - def __enter__(self) -> BetaMessageStream: + def __enter__(self) -> BetaMessageStream[ResponseFormatT]: raw_stream = self.__api_request() - self.__stream = BetaMessageStream(raw_stream) + self.__stream = BetaMessageStream(raw_stream, output_format=self.__output_format) return self.__stream def __exit__( @@ -167,7 +181,7 @@ def __exit__( self.__stream.close() -class BetaAsyncMessageStream: +class BetaAsyncMessageStream(Generic[ResponseFormatT]): text_stream: AsyncIterator[str] """Async iterator over just the text deltas in the stream. @@ -178,11 +192,16 @@ class BetaAsyncMessageStream: ``` """ - def __init__(self, raw_stream: AsyncStream[BetaRawMessageStreamEvent]) -> None: + def __init__( + self, + raw_stream: AsyncStream[BetaRawMessageStreamEvent], + output_format: ResponseFormatT | NotGiven, + ) -> None: self._raw_stream = raw_stream self.text_stream = self.__stream_text__() self._iterator = self.__stream__() - self.__final_message_snapshot: BetaMessage | None = None + self.__final_message_snapshot: ParsedBetaMessage[ResponseFormatT] | None = None + self.__output_format = output_format @property def response(self) -> httpx.Response: @@ -192,10 +211,10 @@ def response(self) -> httpx.Response: def request_id(self) -> str | None: return self.response.headers.get("request-id") # type: ignore[no-any-return] - async def __anext__(self) -> BetaMessageStreamEvent: + async def __anext__(self) -> ParsedBetaMessageStreamEvent[ResponseFormatT]: return await self._iterator.__anext__() - async def __aiter__(self) -> AsyncIterator[BetaMessageStreamEvent]: + async def __aiter__(self) -> AsyncIterator[ParsedBetaMessageStreamEvent[ResponseFormatT]]: async for item in self._iterator: yield item @@ -218,7 +237,7 @@ async def close(self) -> None: """ await self._raw_stream.close() - async def get_final_message(self) -> BetaMessage: + async def get_final_message(self) -> ParsedBetaMessage[ResponseFormatT]: """Waits until the stream has been read to completion and returns the accumulated `Message` object. """ @@ -253,16 +272,17 @@ async def until_done(self) -> None: # properties @property - def current_message_snapshot(self) -> BetaMessage: + def current_message_snapshot(self) -> ParsedBetaMessage[ResponseFormatT]: assert self.__final_message_snapshot is not None return self.__final_message_snapshot - async def __stream__(self) -> AsyncIterator[BetaMessageStreamEvent]: + async def __stream__(self) -> AsyncIterator[ParsedBetaMessageStreamEvent[ResponseFormatT]]: async for sse_event in self._raw_stream: self.__final_message_snapshot = accumulate_event( event=sse_event, current_snapshot=self.__final_message_snapshot, request_headers=self.response.request.headers, + output_format=self.__output_format, ) events_to_fire = build_events(event=sse_event, message_snapshot=self.current_message_snapshot) @@ -275,7 +295,7 @@ async def __stream_text__(self) -> AsyncIterator[str]: yield chunk.delta.text -class BetaAsyncMessageStreamManager: +class BetaAsyncMessageStreamManager(Generic[ResponseFormatT]): """Wrapper over BetaAsyncMessageStream that is returned by `.stream()` so that an async context manager can be used without `await`ing the original client call. @@ -290,13 +310,16 @@ class BetaAsyncMessageStreamManager: def __init__( self, api_request: Awaitable[AsyncStream[BetaRawMessageStreamEvent]], + *, + output_format: ResponseFormatT | NotGiven = NOT_GIVEN, ) -> None: - self.__stream: BetaAsyncMessageStream | None = None + self.__stream: BetaAsyncMessageStream[ResponseFormatT] | None = None self.__api_request = api_request + self.__output_format = output_format - async def __aenter__(self) -> BetaAsyncMessageStream: + async def __aenter__(self) -> BetaAsyncMessageStream[ResponseFormatT]: raw_stream = await self.__api_request - self.__stream = BetaAsyncMessageStream(raw_stream) + self.__stream = BetaAsyncMessageStream(raw_stream, output_format=self.__output_format) return self.__stream async def __aexit__( @@ -312,16 +335,18 @@ async def __aexit__( def build_events( *, event: BetaRawMessageStreamEvent, - message_snapshot: BetaMessage, -) -> list[BetaMessageStreamEvent]: - events_to_fire: list[BetaMessageStreamEvent] = [] + message_snapshot: ParsedBetaMessage[ResponseFormatT], +) -> list[ParsedBetaMessageStreamEvent[ResponseFormatT]]: + events_to_fire: list[ParsedBetaMessageStreamEvent[ResponseFormatT]] = [] if event.type == "message_start": events_to_fire.append(event) elif event.type == "message_delta": events_to_fire.append(event) elif event.type == "message_stop": - events_to_fire.append(build(BetaMessageStopEvent, type="message_stop", message=message_snapshot)) + events_to_fire.append( + build(ParsedBetaMessageStopEvent[ResponseFormatT], type="message_stop", message=message_snapshot) + ) elif event.type == "content_block_start": events_to_fire.append(event) elif event.type == "content_block_delta": @@ -332,7 +357,7 @@ def build_events( if content_block.type == "text": events_to_fire.append( build( - BetaTextEvent, + ParsedBetaTextEvent, type="text", text=event.delta.text, snapshot=content_block.text, @@ -385,9 +410,14 @@ def build_events( elif event.type == "content_block_stop": content_block = message_snapshot.content[event.index] - events_to_fire.append( - build(BetaContentBlockStopEvent, type="content_block_stop", index=event.index, content_block=content_block), + event_to_fire = build( + ParsedBetaContentBlockStopEvent, + type="content_block_stop", + index=event.index, + content_block=content_block, ) + + events_to_fire.append(event_to_fire) else: # we only want exhaustive checking for linters, not at runtime if TYPE_CHECKING: # type: ignore[unreachable] @@ -408,9 +438,10 @@ def build_events( def accumulate_event( *, event: BetaRawMessageStreamEvent, - current_snapshot: BetaMessage | None, + current_snapshot: ParsedBetaMessage[ResponseFormatT] | None, request_headers: httpx.Headers, -) -> BetaMessage: + output_format: ResponseFormatT | NotGiven = NOT_GIVEN, +) -> ParsedBetaMessage[ResponseFormatT]: if not isinstance(cast(Any, event), BaseModel): event = cast( # pyright: ignore[reportUnnecessaryCast] BetaRawMessageStreamEvent, @@ -420,11 +451,15 @@ def accumulate_event( ), ) if not isinstance(cast(Any, event), BaseModel): - raise TypeError(f"Unexpected event runtime type, after deserialising twice - {event} - {type(event)}") + raise TypeError( + f"Unexpected event runtime type, after deserialising twice - {event} - {builtins.type(event)}" + ) if current_snapshot is None: if event.type == "message_start": - return BetaMessage.construct(**cast(Any, event.message.to_dict())) + return cast( + ParsedBetaMessage[ResponseFormatT], ParsedBetaMessage.construct(**cast(Any, event.message.to_dict())) + ) raise RuntimeError(f'Unexpected event order, got {event.type} before "message_start"') @@ -432,8 +467,8 @@ def accumulate_event( # TODO: check index current_snapshot.content.append( cast( - BetaContentBlock, - construct_type(type_=BetaContentBlock, value=event.content_block.model_dump()), + Any, # Pydantic does not support generic unions at runtime + construct_type(type_=ParsedBetaContentBlock, value=event.content_block.model_dump()), ), ) elif event.type == "content_block_delta": @@ -481,6 +516,10 @@ def accumulate_event( # we only want exhaustive checking for linters, not at runtime if TYPE_CHECKING: # type: ignore[unreachable] assert_never(event.delta) + elif event.type == "content_block_stop": + content_block = current_snapshot.content[event.index] + if content_block.type == "text" and is_given(output_format): + content_block.parsed_output = parse_text(content_block.text, output_format) elif event.type == "message_delta": current_snapshot.container = event.delta.container current_snapshot.stop_reason = event.delta.stop_reason diff --git a/src/anthropic/lib/streaming/_beta_types.py b/src/anthropic/lib/streaming/_beta_types.py index 24bb710c0..09ade76d8 100644 --- a/src/anthropic/lib/streaming/_beta_types.py +++ b/src/anthropic/lib/streaming/_beta_types.py @@ -1,10 +1,10 @@ -from typing import Union +from typing import TYPE_CHECKING, Any, Dict, Union, Generic, cast from typing_extensions import List, Literal, Annotated -from ..._models import BaseModel +import jiter + +from ..._models import BaseModel, GenericModel from ...types.beta import ( - BetaMessage, - BetaContentBlock, BetaRawMessageStopEvent, BetaRawMessageDeltaEvent, BetaRawMessageStartEvent, @@ -12,11 +12,13 @@ BetaRawContentBlockDeltaEvent, BetaRawContentBlockStartEvent, ) +from .._parse._response import ResponseFormatT from ..._utils._transform import PropertyInfo +from ...types.beta.parsed_beta_message import ParsedBetaMessage, ParsedBetaContentBlock from ...types.beta.beta_citations_delta import Citation -class BetaTextEvent(BaseModel): +class ParsedBetaTextEvent(BaseModel): type: Literal["text"] text: str @@ -25,6 +27,9 @@ class BetaTextEvent(BaseModel): snapshot: str """The entire accumulated text""" + def parsed_snapshot(self) -> Dict[str, Any]: + return cast(Dict[str, Any], jiter.from_json(self.snapshot.encode("utf-8"), partial_mode="trailing-strings")) + class BetaCitationEvent(BaseModel): type: Literal["citation"] @@ -70,31 +75,34 @@ class BetaInputJsonEvent(BaseModel): """ -class BetaMessageStopEvent(BetaRawMessageStopEvent): +class ParsedBetaMessageStopEvent(BetaRawMessageStopEvent, GenericModel, Generic[ResponseFormatT]): type: Literal["message_stop"] - message: BetaMessage + message: ParsedBetaMessage[ResponseFormatT] -class BetaContentBlockStopEvent(BetaRawContentBlockStopEvent): +class ParsedBetaContentBlockStopEvent(BetaRawContentBlockStopEvent, GenericModel, Generic[ResponseFormatT]): type: Literal["content_block_stop"] - content_block: BetaContentBlock + if TYPE_CHECKING: + content_block: ParsedBetaContentBlock[ResponseFormatT] + else: + content_block: ParsedBetaContentBlock -BetaMessageStreamEvent = Annotated[ +ParsedBetaMessageStreamEvent = Annotated[ Union[ - BetaTextEvent, + ParsedBetaTextEvent, BetaCitationEvent, BetaThinkingEvent, BetaSignatureEvent, BetaInputJsonEvent, BetaRawMessageStartEvent, BetaRawMessageDeltaEvent, - BetaMessageStopEvent, + ParsedBetaMessageStopEvent[ResponseFormatT], BetaRawContentBlockStartEvent, BetaRawContentBlockDeltaEvent, - BetaContentBlockStopEvent, + ParsedBetaContentBlockStopEvent[ResponseFormatT], ], PropertyInfo(discriminator="type"), ] diff --git a/src/anthropic/lib/tools/_beta_runner.py b/src/anthropic/lib/tools/_beta_runner.py index f466c1534..ba8ae1cad 100644 --- a/src/anthropic/lib/tools/_beta_runner.py +++ b/src/anthropic/lib/tools/_beta_runner.py @@ -15,13 +15,14 @@ Coroutine, AsyncIterator, ) +from contextlib import contextmanager, asynccontextmanager from typing_extensions import TypedDict, override import httpx from ..._types import Body, Query, Headers, NotGiven from ..._utils import consume_sync_iterator, consume_async_iterator -from ...types.beta import BetaMessage, BetaContentBlock, BetaMessageParam +from ...types.beta import BetaMessage, BetaMessageParam from ._beta_functions import ( BetaFunctionTool, BetaRunnableTool, @@ -31,7 +32,8 @@ BetaAsyncBuiltinFunctionTool, ) from ..streaming._beta_messages import BetaMessageStream, BetaAsyncMessageStream -from ...types.beta.message_create_params import MessageCreateParamsBase +from ...types.beta.parsed_beta_message import ResponseFormatT, ParsedBetaMessage, ParsedBetaContentBlock +from ...types.beta.message_create_params import ParseMessageCreateParamsBase from ...types.beta.beta_tool_result_block_param import BetaToolResultBlockParam if TYPE_CHECKING: @@ -56,17 +58,17 @@ class RequestOptions(TypedDict, total=False): timeout: float | httpx.Timeout | None | NotGiven -class BaseToolRunner(Generic[AnyFunctionToolT]): +class BaseToolRunner(Generic[AnyFunctionToolT, ResponseFormatT]): def __init__( self, *, - params: MessageCreateParamsBase, + params: ParseMessageCreateParamsBase[ResponseFormatT], options: RequestOptions, tools: Iterable[AnyFunctionToolT], max_iterations: int | None = None, ) -> None: self._tools_by_name = {tool.name: tool for tool in tools} - self._params: MessageCreateParamsBase = { + self._params: ParseMessageCreateParamsBase[ResponseFormatT] = { **params, "messages": [message for message in params["messages"]], } @@ -77,19 +79,21 @@ def __init__( self._iteration_count = 0 def set_messages_params( - self, params: MessageCreateParamsBase | Callable[[MessageCreateParamsBase], MessageCreateParamsBase] + self, + params: ParseMessageCreateParamsBase[ResponseFormatT] + | Callable[[ParseMessageCreateParamsBase[ResponseFormatT]], ParseMessageCreateParamsBase[ResponseFormatT]], ) -> None: """ Update the parameters for the next API call. This invalidates any cached tool responses. Args: - params (MessageCreateParamsBase | Callable): Either new parameters or a function to mutate existing parameters + params (ParsedMessageCreateParamsBase[ResponseFormatT] | Callable): Either new parameters or a function to mutate existing parameters """ if callable(params): params = params(self._params) self._params = params - def append_messages(self, *messages: BetaMessageParam | BetaMessage) -> None: + def append_messages(self, *messages: BetaMessageParam | ParsedBetaMessage[ResponseFormatT]) -> None: """Add one or more messages to the conversation history. This invalidates the cached tool response, i.e. if tools were already called, then they will @@ -109,11 +113,11 @@ def _should_stop(self) -> bool: return False -class BaseSyncToolRunner(BaseToolRunner[BetaRunnableTool], Generic[RunnerItemT], ABC): +class BaseSyncToolRunner(BaseToolRunner[BetaRunnableTool, ResponseFormatT], Generic[RunnerItemT, ResponseFormatT], ABC): def __init__( self, *, - params: MessageCreateParamsBase, + params: ParseMessageCreateParamsBase[ResponseFormatT], options: RequestOptions, tools: Iterable[BetaRunnableTool], client: Anthropic, @@ -122,7 +126,9 @@ def __init__( super().__init__(params=params, options=options, tools=tools, max_iterations=max_iterations) self._client = client self._iterator = self.__run__() - self._last_message: Callable[[], BetaMessage] | BetaMessage | None = None + self._last_message: ( + Callable[[], ParsedBetaMessage[ResponseFormatT]] | ParsedBetaMessage[ResponseFormatT] | None + ) = None def __next__(self) -> RunnerItemT: return self._iterator.__next__() @@ -132,10 +138,37 @@ def __iter__(self) -> Iterator[RunnerItemT]: yield item @abstractmethod - def __run__(self) -> Iterator[RunnerItemT]: + @contextmanager + def _handle_request(self) -> Iterator[RunnerItemT]: raise NotImplementedError() + yield # type: ignore[unreachable] + + def __run__(self) -> Iterator[RunnerItemT]: + with self._handle_request() as item: + yield item + message = self._get_last_message() + assert message is not None + self._iteration_count += 1 - def until_done(self) -> BetaMessage: + while not self._should_stop(): + response = self.generate_tool_call_response() + if response is None: + log.debug("Tool call was not requested, exiting from tool runner loop.") + return + + if not self._messages_modified: + self.append_messages(message, response) + + self._iteration_count += 1 + self._messages_modified = False + self._cached_tool_call_response = None + + with self._handle_request() as item: + yield item + message = self._get_last_message() + assert message is not None + + def until_done(self) -> ParsedBetaMessage[ResponseFormatT]: """ Consumes the tool runner stream and returns the last message if it has not been consumed yet. If it has, it simply returns the last message. @@ -199,12 +232,12 @@ def _generate_tool_call_response(self) -> BetaMessageParam | None: return {"role": "user", "content": results} - def _get_last_message(self) -> BetaMessage | None: + def _get_last_message(self) -> ParsedBetaMessage[ResponseFormatT] | None: if callable(self._last_message): return self._last_message() return self._last_message - def _get_last_assistant_message_content(self) -> list[BetaContentBlock] | None: + def _get_last_assistant_message_content(self) -> list[ParsedBetaContentBlock[ResponseFormatT]] | None: last_message = self._get_last_message() if last_message is None or last_message.role != "assistant" or not last_message.content: return None @@ -212,61 +245,31 @@ def _get_last_assistant_message_content(self) -> list[BetaContentBlock] | None: return last_message.content -class BetaToolRunner(BaseSyncToolRunner[BetaMessage]): +class BetaToolRunner(BaseSyncToolRunner[ParsedBetaMessage[ResponseFormatT], ResponseFormatT]): @override - def __run__(self) -> Iterator[BetaMessage]: - self._last_message = message = self._client.beta.messages.create(**self._params, **self._options) + @contextmanager + def _handle_request(self) -> Iterator[ParsedBetaMessage[ResponseFormatT]]: + message = self._client.beta.messages.parse(**self._params, **self._options) + self._last_message = message yield message - self._iteration_count += 1 - - while not self._should_stop(): - response = self.generate_tool_call_response() - if response is None: - log.debug("Tool call was not requested, exiting from tool runner loop.") - return - - if not self._messages_modified: - self.append_messages(message, response) - - self._iteration_count += 1 - self._messages_modified = False - self._cached_tool_call_response = None - self._last_message = message = self._client.beta.messages.create(**self._params, **self._options) - yield message -class BetaStreamingToolRunner(BaseSyncToolRunner[BetaMessageStream]): +class BetaStreamingToolRunner(BaseSyncToolRunner[BetaMessageStream[ResponseFormatT], ResponseFormatT]): @override - def __run__(self) -> Iterator[BetaMessageStream]: + @contextmanager + def _handle_request(self) -> Iterator[BetaMessageStream[ResponseFormatT]]: with self._client.beta.messages.stream(**self._params, **self._options) as stream: self._last_message = stream.get_final_message yield stream - message = stream.get_final_message() - self._iteration_count += 1 - - while not self._should_stop(): - response = self.generate_tool_call_response() - if response is None: - log.debug("Tool call was not requested, exiting from tool runner loop.") - return - - if not self._messages_modified: - self.append_messages(message, response) - self._iteration_count += 1 - self._messages_modified = False - - with self._client.beta.messages.stream(**self._params, **self._options) as stream: - self._cached_tool_call_response = None - self._last_message = stream.get_final_message - yield stream - message = stream.get_final_message() -class BaseAsyncToolRunner(BaseToolRunner[BetaAsyncRunnableTool], Generic[RunnerItemT], ABC): +class BaseAsyncToolRunner( + BaseToolRunner[BetaAsyncRunnableTool, ResponseFormatT], Generic[RunnerItemT, ResponseFormatT], ABC +): def __init__( self, *, - params: MessageCreateParamsBase, + params: ParseMessageCreateParamsBase[ResponseFormatT], options: RequestOptions, tools: Iterable[BetaAsyncRunnableTool], client: AsyncAnthropic, @@ -275,7 +278,11 @@ def __init__( super().__init__(params=params, options=options, tools=tools, max_iterations=max_iterations) self._client = client self._iterator = self.__run__() - self._last_message: Callable[[], Coroutine[None, None, BetaMessage]] | BetaMessage | None = None + self._last_message: ( + Callable[[], Coroutine[None, None, ParsedBetaMessage[ResponseFormatT]]] + | ParsedBetaMessage[ResponseFormatT] + | None + ) = None async def __anext__(self) -> RunnerItemT: return await self._iterator.__anext__() @@ -285,11 +292,36 @@ async def __aiter__(self) -> AsyncIterator[RunnerItemT]: yield item @abstractmethod - async def __run__(self) -> AsyncIterator[RunnerItemT]: + @asynccontextmanager + async def _handle_request(self) -> AsyncIterator[RunnerItemT]: raise NotImplementedError() yield # type: ignore[unreachable] - async def until_done(self) -> BetaMessage: + async def __run__(self) -> AsyncIterator[RunnerItemT]: + async with self._handle_request() as item: + yield item + message = await self._get_last_message() + assert message is not None + self._iteration_count += 1 + + while not self._should_stop(): + response = await self.generate_tool_call_response() + if response is None: + log.debug("Tool call was not requested, exiting from tool runner loop.") + return + + if not self._messages_modified: + self.append_messages(message, response) + self._iteration_count += 1 + self._messages_modified = False + self._cached_tool_call_response = None + + async with self._handle_request() as item: + yield item + message = await self._get_last_message() + assert message is not None + + async def until_done(self) -> ParsedBetaMessage[ResponseFormatT]: """ Consumes the tool runner stream and returns the last message if it has not been consumed yet. If it has, it simply returns the last message. @@ -314,12 +346,12 @@ async def generate_tool_call_response(self) -> BetaMessageParam | None: self._cached_tool_call_response = response return response - async def _get_last_message(self) -> BetaMessage | None: + async def _get_last_message(self) -> ParsedBetaMessage[ResponseFormatT] | None: if callable(self._last_message): return await self._last_message() return self._last_message - async def _get_last_assistant_message_content(self) -> list[BetaContentBlock] | None: + async def _get_last_assistant_message_content(self) -> list[ParsedBetaContentBlock[ResponseFormatT]] | None: last_message = await self._get_last_message() if last_message is None or last_message.role != "assistant" or not last_message.content: return None @@ -367,50 +399,19 @@ async def _generate_tool_call_response(self) -> BetaMessageParam | None: return {"role": "user", "content": results} -class BetaAsyncToolRunner(BaseAsyncToolRunner[BetaMessage]): +class BetaAsyncToolRunner(BaseAsyncToolRunner[ParsedBetaMessage[ResponseFormatT], ResponseFormatT]): @override - async def __run__(self) -> AsyncIterator[BetaMessage]: - self._last_message = message = await self._client.beta.messages.create(**self._params, **self._options) + @asynccontextmanager + async def _handle_request(self) -> AsyncIterator[ParsedBetaMessage[ResponseFormatT]]: + message = await self._client.beta.messages.parse(**self._params, **self._options) + self._last_message = message yield message - self._iteration_count += 1 - - while not self._should_stop(): - response = await self.generate_tool_call_response() - if response is None: - log.debug("Tool call was not requested, exiting from tool runner loop.") - return - - if not self._messages_modified: - self.append_messages(message, response) - self._iteration_count += 1 - self._messages_modified = False - self._cached_tool_call_response = None - self._last_message = message = await self._client.beta.messages.create(**self._params, **self._options) - yield message -class BetaAsyncStreamingToolRunner(BaseAsyncToolRunner[BetaAsyncMessageStream]): +class BetaAsyncStreamingToolRunner(BaseAsyncToolRunner[BetaAsyncMessageStream[ResponseFormatT], ResponseFormatT]): @override - async def __run__(self) -> AsyncIterator[BetaAsyncMessageStream]: + @asynccontextmanager + async def _handle_request(self) -> AsyncIterator[BetaAsyncMessageStream[ResponseFormatT]]: async with self._client.beta.messages.stream(**self._params, **self._options) as stream: self._last_message = stream.get_final_message yield stream - message = await stream.get_final_message() - self._iteration_count += 1 - - while not self._should_stop(): - response = await self.generate_tool_call_response() - if response is None: - log.debug("Tool call was not requested, exiting from tool runner loop.") - return - - if not self._messages_modified: - self.append_messages(message, response) - self._iteration_count += 1 - self._messages_modified = False - - async with self._client.beta.messages.stream(**self._params, **self._options) as stream: - self._last_message = stream.get_final_message - self._cached_tool_call_response = None - yield stream - message = await stream.get_final_message() diff --git a/src/anthropic/resources/beta/messages/batches.py b/src/anthropic/resources/beta/messages/batches.py index fc21dfa20..451a5014e 100644 --- a/src/anthropic/resources/beta/messages/batches.py +++ b/src/anthropic/resources/beta/messages/batches.py @@ -66,7 +66,7 @@ def create( can take up to 24 hours to complete. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: requests: List of requests for prompt completion. Each is an individual request to create @@ -121,7 +121,7 @@ def retrieve( `results_url` field in the response. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. @@ -177,7 +177,7 @@ def list( returned first. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: after_id: ID of the object to use as a cursor for pagination. When provided, returns the @@ -250,7 +250,7 @@ def delete( like to delete an in-progress batch, you must first cancel it. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. @@ -311,7 +311,7 @@ def cancel( non-interruptible. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. @@ -367,7 +367,7 @@ def results( requests. Use the `custom_id` field to match results to requests. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. @@ -453,7 +453,7 @@ async def create( can take up to 24 hours to complete. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: requests: List of requests for prompt completion. Each is an individual request to create @@ -508,7 +508,7 @@ async def retrieve( `results_url` field in the response. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. @@ -564,7 +564,7 @@ def list( returned first. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: after_id: ID of the object to use as a cursor for pagination. When provided, returns the @@ -637,7 +637,7 @@ async def delete( like to delete an in-progress batch, you must first cancel it. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. @@ -698,7 +698,7 @@ async def cancel( non-interruptible. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. @@ -754,7 +754,7 @@ async def results( requests. Use the `custom_id` field to match results to requests. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. diff --git a/src/anthropic/resources/beta/messages/messages.py b/src/anthropic/resources/beta/messages/messages.py index e7b7d1620..ed10bd7a0 100644 --- a/src/anthropic/resources/beta/messages/messages.py +++ b/src/anthropic/resources/beta/messages/messages.py @@ -2,13 +2,15 @@ from __future__ import annotations +import inspect import warnings -from typing import TYPE_CHECKING, List, Union, Iterable, Optional, cast +from typing import TYPE_CHECKING, List, Type, Union, Iterable, Optional, cast from functools import partial from itertools import chain from typing_extensions import Literal, overload import httpx +import pydantic from .... import _legacy_response from .batches import ( @@ -22,6 +24,7 @@ from ...._types import NOT_GIVEN, Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given from ...._utils import is_given, required_args, maybe_transform, strip_not_given, async_maybe_transform from ...._compat import cached_property +from ...._models import TypeAdapter from ...._resource import SyncAPIResource, AsyncAPIResource from ...._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper from ....lib.tools import ( @@ -41,16 +44,20 @@ from ....lib.streaming import BetaMessageStreamManager, BetaAsyncMessageStreamManager from ...messages.messages import DEPRECATED_MODELS from ....types.model_param import ModelParam +from ....lib._parse._response import ResponseFormatT, parse_response +from ....lib._parse._transform import transform_schema from ....types.beta.beta_message import BetaMessage from ....lib.tools._beta_functions import BetaRunnableTool, BetaAsyncRunnableTool from ....types.anthropic_beta_param import AnthropicBetaParam from ....types.beta.beta_message_param import BetaMessageParam from ....types.beta.beta_metadata_param import BetaMetadataParam +from ....types.beta.parsed_beta_message import ParsedBetaMessage from ....types.beta.beta_text_block_param import BetaTextBlockParam from ....types.beta.beta_tool_union_param import BetaToolUnionParam from ....types.beta.beta_tool_choice_param import BetaToolChoiceParam from ....types.beta.beta_message_tokens_count import BetaMessageTokensCount from ....types.beta.beta_thinking_config_param import BetaThinkingConfigParam +from ....types.beta.beta_json_output_format_param import BetaJSONOutputFormatParam from ....types.beta.beta_raw_message_stream_event import BetaRawMessageStreamEvent from ....types.beta.beta_context_management_config_param import BetaContextManagementConfigParam from ....types.beta.beta_request_mcp_server_url_definition_param import BetaRequestMCPServerURLDefinitionParam @@ -96,6 +103,7 @@ def create( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[BetaJSONOutputFormatParam] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, stream: Literal[False] | Omit = omit, @@ -121,7 +129,8 @@ def create( The Messages API can be used for either single queries or stateless multi-turn conversations. - Learn more about the Messages API in our [user guide](/en/docs/initial-setup) + Learn more about the Messages API in our + [user guide](https://docs.claude.com/en/docs/initial-setup) Args: max_tokens: The maximum number of tokens to generate before stopping. @@ -213,6 +222,8 @@ def create( metadata: An object describing metadata about the request. + output_format: A schema to specify Claude's output format in responses. + service_tier: Determines whether to use priority capacity (if available) or standard capacity for this request. @@ -379,6 +390,7 @@ def create( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[BetaJSONOutputFormatParam] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, system: Union[str, Iterable[BetaTextBlockParam]] | Omit = omit, @@ -403,7 +415,8 @@ def create( The Messages API can be used for either single queries or stateless multi-turn conversations. - Learn more about the Messages API in our [user guide](/en/docs/initial-setup) + Learn more about the Messages API in our + [user guide](https://docs.claude.com/en/docs/initial-setup) Args: max_tokens: The maximum number of tokens to generate before stopping. @@ -499,6 +512,8 @@ def create( metadata: An object describing metadata about the request. + output_format: A schema to specify Claude's output format in responses. + service_tier: Determines whether to use priority capacity (if available) or standard capacity for this request. @@ -661,6 +676,7 @@ def create( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[BetaJSONOutputFormatParam] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, system: Union[str, Iterable[BetaTextBlockParam]] | Omit = omit, @@ -685,7 +701,8 @@ def create( The Messages API can be used for either single queries or stateless multi-turn conversations. - Learn more about the Messages API in our [user guide](/en/docs/initial-setup) + Learn more about the Messages API in our + [user guide](https://docs.claude.com/en/docs/initial-setup) Args: max_tokens: The maximum number of tokens to generate before stopping. @@ -781,6 +798,8 @@ def create( metadata: An object describing metadata about the request. + output_format: A schema to specify Claude's output format in responses. + service_tier: Determines whether to use priority capacity (if available) or standard capacity for this request. @@ -942,6 +961,7 @@ def create( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[BetaJSONOutputFormatParam] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, stream: Literal[False] | Literal[True] | Omit = omit, @@ -960,6 +980,8 @@ def create( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BetaMessage | Stream[BetaRawMessageStreamEvent]: + validate_output_format(output_format) + if not stream and not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT: timeout = self._client._calculate_nonstreaming_timeout( max_tokens, MODEL_NONSTREAMING_TOKENS.get(model, None) @@ -987,6 +1009,7 @@ def create( "context_management": context_management, "mcp_servers": mcp_servers, "metadata": metadata, + "output_format": output_format, "service_tier": service_tier, "stop_sequences": stop_sequences, "stream": stream, @@ -1010,6 +1033,122 @@ def create( stream_cls=Stream[BetaRawMessageStreamEvent], ) + def parse( + self, + *, + max_tokens: int, + messages: Iterable[BetaMessageParam], + model: ModelParam, + container: Optional[message_create_params.Container] | Omit = omit, + context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, + mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, + metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[type[ResponseFormatT]] | Omit = omit, + service_tier: Literal["auto", "standard_only"] | Omit = omit, + stop_sequences: SequenceNotStr[str] | Omit = omit, + stream: Literal[False] | Literal[True] | Omit = omit, + system: Union[str, Iterable[BetaTextBlockParam]] | Omit = omit, + temperature: float | Omit = omit, + thinking: BetaThinkingConfigParam | Omit = omit, + tool_choice: BetaToolChoiceParam | Omit = omit, + tools: Iterable[BetaToolUnionParam] | Omit = omit, + top_k: int | Omit = omit, + top_p: float | Omit = omit, + betas: List[AnthropicBetaParam] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> ParsedBetaMessage[ResponseFormatT]: + if not stream and not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT: + timeout = self._client._calculate_nonstreaming_timeout( + max_tokens, MODEL_NONSTREAMING_TOKENS.get(model, None) + ) + + if model in DEPRECATED_MODELS: + warnings.warn( + f"The model '{model}' is deprecated and will reach end-of-life on {DEPRECATED_MODELS[model]}.\nPlease migrate to a newer model. Visit https://docs.anthropic.com/en/docs/resources/model-deprecations for more information.", + DeprecationWarning, + stacklevel=3, + ) + + betas = [beta for beta in betas] if is_given(betas) else [] + + if "structured-outputs-2025-09-17" not in betas: + # Ensure structured outputs beta is included for parse method + betas.append("structured-outputs-2025-09-17") + + extra_headers = { + "X-Stainless-Helper": "beta.messages.parse", + **strip_not_given({"anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else NOT_GIVEN}), + **(extra_headers or {}), + } + + transformed_output_format: Optional[message_create_params.OutputFormat] | NotGiven = NOT_GIVEN + + if is_given(output_format) and output_format is not None: + adapted_type: TypeAdapter[ResponseFormatT] = TypeAdapter(output_format) + + try: + schema = adapted_type.json_schema() + transformed_output_format = message_create_params.OutputFormat( + schema=transform_schema(schema), type="json_schema" + ) + except pydantic.errors.PydanticSchemaGenerationError as e: + raise TypeError( + ( + "Could not generate JSON schema for the given `output_format` type. " + "Use a type that works with `pydanitc.TypeAdapter`" + ) + ) from e + + def parser(response: BetaMessage) -> ParsedBetaMessage[ResponseFormatT]: + return parse_response( + response=response, + output_format=cast( + ResponseFormatT, + output_format if is_given(output_format) and output_format is not None else NOT_GIVEN, + ), + ) + + return self._post( + "/v1/messages?beta=true", + body=maybe_transform( + { + "max_tokens": max_tokens, + "messages": messages, + "model": model, + "container": container, + "context_management": context_management, + "mcp_servers": mcp_servers, + "metadata": metadata, + "output_format": transformed_output_format, + "service_tier": service_tier, + "stop_sequences": stop_sequences, + "stream": stream, + "system": system, + "temperature": temperature, + "thinking": thinking, + "tool_choice": tool_choice, + "tools": tools, + "top_k": top_k, + "top_p": top_p, + }, + message_create_params.MessageCreateParamsNonStreaming, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + post_parser=parser, + ), + cast_to=cast(Type[ParsedBetaMessage[ResponseFormatT]], BetaMessage), + stream=False, + ) + @overload def tool_runner( self, @@ -1018,11 +1157,12 @@ def tool_runner( messages: Iterable[BetaMessageParam], model: ModelParam, tools: Iterable[BetaRunnableTool], - max_iterations: int | Omit = omit, container: Optional[message_create_params.Container] | Omit = omit, context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, + max_iterations: int | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[type[ResponseFormatT]] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, stream: Literal[False] | Omit = omit, @@ -1039,7 +1179,7 @@ def tool_runner( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> BetaToolRunner: ... + ) -> BetaToolRunner[ResponseFormatT]: ... @overload def tool_runner( @@ -1055,6 +1195,7 @@ def tool_runner( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[type[ResponseFormatT]] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, system: Union[str, Iterable[BetaTextBlockParam]] | Omit = omit, @@ -1070,7 +1211,7 @@ def tool_runner( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> BetaStreamingToolRunner: ... + ) -> BetaStreamingToolRunner[ResponseFormatT]: ... @overload def tool_runner( @@ -1086,6 +1227,7 @@ def tool_runner( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[type[ResponseFormatT]] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, system: Union[str, Iterable[BetaTextBlockParam]] | Omit = omit, @@ -1101,7 +1243,7 @@ def tool_runner( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> BetaStreamingToolRunner | BetaToolRunner: ... + ) -> BetaStreamingToolRunner[ResponseFormatT] | BetaToolRunner[ResponseFormatT]: ... def tool_runner( self, @@ -1115,6 +1257,7 @@ def tool_runner( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[type[ResponseFormatT]] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, stream: bool | Omit = omit, @@ -1131,7 +1274,7 @@ def tool_runner( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> BetaToolRunner | BetaStreamingToolRunner: + ) -> BetaStreamingToolRunner[ResponseFormatT] | BetaToolRunner[ResponseFormatT]: """Create a Message stream""" if model in DEPRECATED_MODELS: warnings.warn( @@ -1147,7 +1290,7 @@ def tool_runner( } params = cast( - message_create_params.MessageCreateParamsNonStreaming, + message_create_params.ParseMessageCreateParamsBase[ResponseFormatT], { "max_tokens": max_tokens, "messages": messages, @@ -1156,6 +1299,7 @@ def tool_runner( "context_management": context_management, "mcp_servers": mcp_servers, "metadata": metadata, + "output_format": output_format, "service_tier": service_tier, "stop_sequences": stop_sequences, "system": system, @@ -1169,7 +1313,7 @@ def tool_runner( ) if stream: - return BetaStreamingToolRunner( + return BetaStreamingToolRunner[ResponseFormatT]( tools=tools, params=params, options={ @@ -1181,7 +1325,7 @@ def tool_runner( client=cast("Anthropic", self._client), max_iterations=max_iterations if is_given(max_iterations) else None, ) - return BetaToolRunner( + return BetaToolRunner[ResponseFormatT]( tools=tools, params=params, options={ @@ -1204,6 +1348,7 @@ def stream( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[type[ResponseFormatT]] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, system: Union[str, Iterable[BetaTextBlockParam]] | Omit = omit, @@ -1220,7 +1365,7 @@ def stream( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> BetaMessageStreamManager: + ) -> BetaMessageStreamManager[ResponseFormatT]: if model in DEPRECATED_MODELS: warnings.warn( f"The model '{model}' is deprecated and will reach end-of-life on {DEPRECATED_MODELS[model]}.\nPlease migrate to a newer model. Visit https://docs.anthropic.com/en/docs/resources/model-deprecations for more information.", @@ -1234,6 +1379,25 @@ def stream( **strip_not_given({"anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else NOT_GIVEN}), **(extra_headers or {}), } + + transformed_output_format: Optional[message_create_params.OutputFormat] | NotGiven = NOT_GIVEN + + if is_given(output_format) and output_format is not None: + adapted_type: TypeAdapter[ResponseFormatT] = TypeAdapter(output_format) + + try: + schema = adapted_type.json_schema() + transformed_output_format = message_create_params.OutputFormat( + schema=transform_schema(schema), type="json_schema" + ) + except pydantic.errors.PydanticSchemaGenerationError as e: + raise TypeError( + ( + "Could not generate JSON schema for the given `output_format` type. " + "Use a type that works with `pydanitc.TypeAdapter`" + ) + ) from e + make_request = partial( self._post, "/v1/messages?beta=true", @@ -1243,6 +1407,7 @@ def stream( "messages": messages, "model": model, "metadata": metadata, + "output_format": transformed_output_format, "container": container, "context_management": context_management, "mcp_servers": mcp_servers, @@ -1266,7 +1431,7 @@ def stream( stream=True, stream_cls=Stream[BetaRawMessageStreamEvent], ) - return BetaMessageStreamManager(make_request) + return BetaMessageStreamManager(make_request, output_format=cast(ResponseFormatT, output_format)) def count_tokens( self, @@ -1275,6 +1440,7 @@ def count_tokens( model: ModelParam, context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, + output_format: Optional[BetaJSONOutputFormatParam] | Omit = omit, system: Union[str, Iterable[BetaTextBlockParam]] | Omit = omit, thinking: BetaThinkingConfigParam | Omit = omit, tool_choice: BetaToolChoiceParam | Omit = omit, @@ -1294,7 +1460,7 @@ def count_tokens( including tools, images, and documents, without creating it. Learn more about token counting in our - [user guide](/en/docs/build-with-claude/token-counting) + [user guide](https://docs.claude.com/en/docs/build-with-claude/token-counting) Args: messages: Input messages. @@ -1374,6 +1540,8 @@ def count_tokens( mcp_servers: MCP servers to be utilized in this request + output_format: A schema to specify Claude's output format in responses. + system: System prompt. A system prompt is a way of providing context and instructions to Claude, such @@ -1498,6 +1666,7 @@ def count_tokens( "model": model, "context_management": context_management, "mcp_servers": mcp_servers, + "output_format": output_format, "system": system, "thinking": thinking, "tool_choice": tool_choice, @@ -1547,6 +1716,7 @@ async def create( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[BetaJSONOutputFormatParam] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, stream: Literal[False] | Omit = omit, @@ -1572,7 +1742,8 @@ async def create( The Messages API can be used for either single queries or stateless multi-turn conversations. - Learn more about the Messages API in our [user guide](/en/docs/initial-setup) + Learn more about the Messages API in our + [user guide](https://docs.claude.com/en/docs/initial-setup) Args: max_tokens: The maximum number of tokens to generate before stopping. @@ -1664,6 +1835,8 @@ async def create( metadata: An object describing metadata about the request. + output_format: A schema to specify Claude's output format in responses. + service_tier: Determines whether to use priority capacity (if available) or standard capacity for this request. @@ -1830,6 +2003,7 @@ async def create( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[BetaJSONOutputFormatParam] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, system: Union[str, Iterable[BetaTextBlockParam]] | Omit = omit, @@ -1854,7 +2028,8 @@ async def create( The Messages API can be used for either single queries or stateless multi-turn conversations. - Learn more about the Messages API in our [user guide](/en/docs/initial-setup) + Learn more about the Messages API in our + [user guide](https://docs.claude.com/en/docs/initial-setup) Args: max_tokens: The maximum number of tokens to generate before stopping. @@ -1950,6 +2125,8 @@ async def create( metadata: An object describing metadata about the request. + output_format: A schema to specify Claude's output format in responses. + service_tier: Determines whether to use priority capacity (if available) or standard capacity for this request. @@ -2112,6 +2289,7 @@ async def create( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[BetaJSONOutputFormatParam] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, system: Union[str, Iterable[BetaTextBlockParam]] | Omit = omit, @@ -2136,7 +2314,8 @@ async def create( The Messages API can be used for either single queries or stateless multi-turn conversations. - Learn more about the Messages API in our [user guide](/en/docs/initial-setup) + Learn more about the Messages API in our + [user guide](https://docs.claude.com/en/docs/initial-setup) Args: max_tokens: The maximum number of tokens to generate before stopping. @@ -2232,6 +2411,8 @@ async def create( metadata: An object describing metadata about the request. + output_format: A schema to specify Claude's output format in responses. + service_tier: Determines whether to use priority capacity (if available) or standard capacity for this request. @@ -2393,6 +2574,7 @@ async def create( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[BetaJSONOutputFormatParam] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, stream: Literal[False] | Literal[True] | Omit = omit, @@ -2411,6 +2593,8 @@ async def create( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BetaMessage | AsyncStream[BetaRawMessageStreamEvent]: + validate_output_format(output_format) + if not stream and not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT: timeout = self._client._calculate_nonstreaming_timeout( max_tokens, MODEL_NONSTREAMING_TOKENS.get(model, None) @@ -2438,6 +2622,7 @@ async def create( "context_management": context_management, "mcp_servers": mcp_servers, "metadata": metadata, + "output_format": output_format, "service_tier": service_tier, "stop_sequences": stop_sequences, "stream": stream, @@ -2461,6 +2646,121 @@ async def create( stream_cls=AsyncStream[BetaRawMessageStreamEvent], ) + async def parse( + self, + *, + max_tokens: int, + messages: Iterable[BetaMessageParam], + model: ModelParam, + container: Optional[message_create_params.Container] | Omit = omit, + context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, + mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, + metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[type[ResponseFormatT]] | Omit = omit, + service_tier: Literal["auto", "standard_only"] | Omit = omit, + stop_sequences: SequenceNotStr[str] | Omit = omit, + stream: Literal[False] | Literal[True] | Omit = omit, + system: Union[str, Iterable[BetaTextBlockParam]] | Omit = omit, + temperature: float | Omit = omit, + thinking: BetaThinkingConfigParam | Omit = omit, + tool_choice: BetaToolChoiceParam | Omit = omit, + tools: Iterable[BetaToolUnionParam] | Omit = omit, + top_k: int | Omit = omit, + top_p: float | Omit = omit, + betas: List[AnthropicBetaParam] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> ParsedBetaMessage[ResponseFormatT]: + if not stream and not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT: + timeout = self._client._calculate_nonstreaming_timeout( + max_tokens, MODEL_NONSTREAMING_TOKENS.get(model, None) + ) + + if model in DEPRECATED_MODELS: + warnings.warn( + f"The model '{model}' is deprecated and will reach end-of-life on {DEPRECATED_MODELS[model]}.\nPlease migrate to a newer model. Visit https://docs.anthropic.com/en/docs/resources/model-deprecations for more information.", + DeprecationWarning, + stacklevel=3, + ) + betas = [beta for beta in betas] if is_given(betas) else [] + + if "structured-outputs-2025-09-17" not in betas: + # Ensure structured outputs beta is included for parse method + betas.append("structured-outputs-2025-09-17") + + extra_headers = { + "X-Stainless-Helper": "beta.messages.parse", + **strip_not_given({"anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else NOT_GIVEN}), + **(extra_headers or {}), + } + + transformed_output_format: Optional[message_create_params.OutputFormat] | NotGiven = NOT_GIVEN + + if is_given(output_format) and output_format is not None: + adapted_type: TypeAdapter[ResponseFormatT] = TypeAdapter(output_format) + + try: + schema = adapted_type.json_schema() + transformed_output_format = message_create_params.OutputFormat( + schema=transform_schema(schema), type="json_schema" + ) + except pydantic.errors.PydanticSchemaGenerationError as e: + raise TypeError( + ( + "Could not generate JSON schema for the given `output_format` type. " + "Use a type that works with `pydanitc.TypeAdapter`" + ) + ) from e + + def parser(response: BetaMessage) -> ParsedBetaMessage[ResponseFormatT]: + return parse_response( + response=response, + output_format=cast( + ResponseFormatT, + output_format if is_given(output_format) and output_format is not None else NOT_GIVEN, + ), + ) + + return await self._post( + "/v1/messages?beta=true", + body=maybe_transform( + { + "max_tokens": max_tokens, + "messages": messages, + "model": model, + "container": container, + "context_management": context_management, + "mcp_servers": mcp_servers, + "metadata": metadata, + "output_format": transformed_output_format, + "service_tier": service_tier, + "stop_sequences": stop_sequences, + "stream": stream, + "system": system, + "temperature": temperature, + "thinking": thinking, + "tool_choice": tool_choice, + "tools": tools, + "top_k": top_k, + "top_p": top_p, + }, + message_create_params.MessageCreateParamsNonStreaming, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + post_parser=parser, + ), + cast_to=cast(Type[ParsedBetaMessage[ResponseFormatT]], BetaMessage), + stream=False, + ) + @overload def tool_runner( self, @@ -2474,6 +2774,7 @@ def tool_runner( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[type[ResponseFormatT]] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, stream: Literal[False] | Omit = omit, @@ -2490,7 +2791,7 @@ def tool_runner( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> BetaAsyncToolRunner: ... + ) -> BetaAsyncToolRunner[ResponseFormatT]: ... @overload def tool_runner( @@ -2506,6 +2807,7 @@ def tool_runner( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[type[ResponseFormatT]] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, system: Union[str, Iterable[BetaTextBlockParam]] | Omit = omit, @@ -2521,7 +2823,7 @@ def tool_runner( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> BetaAsyncStreamingToolRunner: ... + ) -> BetaAsyncStreamingToolRunner[ResponseFormatT]: ... @overload def tool_runner( @@ -2537,6 +2839,7 @@ def tool_runner( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[type[ResponseFormatT]] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, system: Union[str, Iterable[BetaTextBlockParam]] | Omit = omit, @@ -2552,7 +2855,7 @@ def tool_runner( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> BetaAsyncStreamingToolRunner | BetaAsyncToolRunner: ... + ) -> BetaAsyncStreamingToolRunner[ResponseFormatT] | BetaAsyncToolRunner[ResponseFormatT]: ... def tool_runner( self, @@ -2566,9 +2869,10 @@ def tool_runner( context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[type[ResponseFormatT]] | Omit = omit, service_tier: Literal["auto", "standard_only"] | Omit = omit, stop_sequences: SequenceNotStr[str] | Omit = omit, - stream: Literal[True] | Literal[False] | Omit = False, + stream: Literal[True] | Literal[False] | Omit = omit, system: Union[str, Iterable[BetaTextBlockParam]] | Omit = omit, temperature: float | Omit = omit, top_k: int | Omit = omit, @@ -2582,7 +2886,7 @@ def tool_runner( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> BetaAsyncToolRunner | BetaAsyncStreamingToolRunner: + ) -> BetaAsyncToolRunner[ResponseFormatT] | BetaAsyncStreamingToolRunner[ResponseFormatT]: """Create a Message stream""" if model in DEPRECATED_MODELS: warnings.warn( @@ -2598,7 +2902,7 @@ def tool_runner( } params = cast( - message_create_params.MessageCreateParamsBase, + message_create_params.ParseMessageCreateParamsBase[ResponseFormatT], { "max_tokens": max_tokens, "messages": messages, @@ -2607,6 +2911,7 @@ def tool_runner( "context_management": context_management, "mcp_servers": mcp_servers, "metadata": metadata, + "output_format": output_format, "service_tier": service_tier, "stop_sequences": stop_sequences, "system": system, @@ -2620,7 +2925,7 @@ def tool_runner( ) if stream: - return BetaAsyncStreamingToolRunner( + return BetaAsyncStreamingToolRunner[ResponseFormatT]( tools=tools, params=params, options={ @@ -2632,7 +2937,7 @@ def tool_runner( client=cast("AsyncAnthropic", self._client), max_iterations=max_iterations if is_given(max_iterations) else None, ) - return BetaAsyncToolRunner( + return BetaAsyncToolRunner[ResponseFormatT]( tools=tools, params=params, options={ @@ -2652,6 +2957,7 @@ def stream( messages: Iterable[BetaMessageParam], model: ModelParam, metadata: BetaMetadataParam | Omit = omit, + output_format: Optional[type[ResponseFormatT]] | Omit = omit, container: Optional[message_create_params.Container] | Omit = omit, context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, @@ -2671,7 +2977,7 @@ def stream( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> BetaAsyncMessageStreamManager: + ) -> BetaAsyncMessageStreamManager[ResponseFormatT]: if model in DEPRECATED_MODELS: warnings.warn( f"The model '{model}' is deprecated and will reach end-of-life on {DEPRECATED_MODELS[model]}.\nPlease migrate to a newer model. Visit https://docs.anthropic.com/en/docs/resources/model-deprecations for more information.", @@ -2684,6 +2990,24 @@ def stream( **strip_not_given({"anthropic-beta": ",".join(str(e) for e in betas) if is_given(betas) else NOT_GIVEN}), **(extra_headers or {}), } + + transformed_output_format: Optional[message_create_params.OutputFormat] | NotGiven = NOT_GIVEN + + if is_given(output_format) and output_format is not None: + adapted_type: TypeAdapter[ResponseFormatT] = TypeAdapter(output_format) + + try: + schema = adapted_type.json_schema() + transformed_output_format = message_create_params.OutputFormat( + schema=transform_schema(schema), type="json_schema" + ) + except pydantic.errors.PydanticSchemaGenerationError as e: + raise TypeError( + ( + "Could not generate JSON schema for the given `output_format` type. " + "Use a type that works with `pydanitc.TypeAdapter`" + ) + ) from e request = self._post( "/v1/messages", body=maybe_transform( @@ -2692,6 +3016,7 @@ def stream( "messages": messages, "model": model, "metadata": metadata, + "output_format": transformed_output_format, "container": container, "context_management": context_management, "mcp_servers": mcp_servers, @@ -2715,7 +3040,7 @@ def stream( stream=True, stream_cls=AsyncStream[BetaRawMessageStreamEvent], ) - return BetaAsyncMessageStreamManager(request) + return BetaAsyncMessageStreamManager(request, output_format=cast(ResponseFormatT, output_format)) async def count_tokens( self, @@ -2724,6 +3049,7 @@ async def count_tokens( model: ModelParam, context_management: Optional[BetaContextManagementConfigParam] | Omit = omit, mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] | Omit = omit, + output_format: Optional[BetaJSONOutputFormatParam] | Omit = omit, system: Union[str, Iterable[BetaTextBlockParam]] | Omit = omit, thinking: BetaThinkingConfigParam | Omit = omit, tool_choice: BetaToolChoiceParam | Omit = omit, @@ -2743,7 +3069,7 @@ async def count_tokens( including tools, images, and documents, without creating it. Learn more about token counting in our - [user guide](/en/docs/build-with-claude/token-counting) + [user guide](https://docs.claude.com/en/docs/build-with-claude/token-counting) Args: messages: Input messages. @@ -2823,6 +3149,8 @@ async def count_tokens( mcp_servers: MCP servers to be utilized in this request + output_format: A schema to specify Claude's output format in responses. + system: System prompt. A system prompt is a way of providing context and instructions to Claude, such @@ -2947,6 +3275,7 @@ async def count_tokens( "model": model, "context_management": context_management, "mcp_servers": mcp_servers, + "output_format": output_format, "system": system, "thinking": thinking, "tool_choice": tool_choice, @@ -3023,3 +3352,10 @@ def __init__(self, messages: AsyncMessages) -> None: @cached_property def batches(self) -> AsyncBatchesWithStreamingResponse: return AsyncBatchesWithStreamingResponse(self._messages.batches) + + +def validate_output_format(output_format: object) -> None: + if inspect.isclass(output_format) and issubclass(output_format, pydantic.BaseModel): + raise TypeError( + "You tried to pass a `BaseModel` class to `beta.messages.create()`; You must use `beta.messages.parse()` instead" + ) diff --git a/src/anthropic/resources/messages/batches.py b/src/anthropic/resources/messages/batches.py index fa9db12ee..a62a0d97f 100644 --- a/src/anthropic/resources/messages/batches.py +++ b/src/anthropic/resources/messages/batches.py @@ -63,7 +63,7 @@ def create( can take up to 24 hours to complete. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: requests: List of requests for prompt completion. Each is an individual request to create @@ -104,7 +104,7 @@ def retrieve( `results_url` field in the response. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. @@ -146,7 +146,7 @@ def list( returned first. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: after_id: ID of the object to use as a cursor for pagination. When provided, returns the @@ -205,7 +205,7 @@ def delete( like to delete an in-progress batch, you must first cancel it. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. @@ -252,7 +252,7 @@ def cancel( non-interruptible. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. @@ -294,7 +294,7 @@ def results( requests. Use the `custom_id` field to match results to requests. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. @@ -366,7 +366,7 @@ async def create( can take up to 24 hours to complete. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: requests: List of requests for prompt completion. Each is an individual request to create @@ -407,7 +407,7 @@ async def retrieve( `results_url` field in the response. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. @@ -449,7 +449,7 @@ def list( returned first. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: after_id: ID of the object to use as a cursor for pagination. When provided, returns the @@ -508,7 +508,7 @@ async def delete( like to delete an in-progress batch, you must first cancel it. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. @@ -555,7 +555,7 @@ async def cancel( non-interruptible. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. @@ -597,7 +597,7 @@ async def results( requests. Use the `custom_id` field to match results to requests. Learn more about the Message Batches API in our - [user guide](/en/docs/build-with-claude/batch-processing) + [user guide](https://docs.claude.com/en/docs/build-with-claude/batch-processing) Args: message_batch_id: ID of the Message Batch. diff --git a/src/anthropic/resources/messages/messages.py b/src/anthropic/resources/messages/messages.py index a8baee48a..783653de1 100644 --- a/src/anthropic/resources/messages/messages.py +++ b/src/anthropic/resources/messages/messages.py @@ -118,7 +118,8 @@ def create( The Messages API can be used for either single queries or stateless multi-turn conversations. - Learn more about the Messages API in our [user guide](/en/docs/initial-setup) + Learn more about the Messages API in our + [user guide](https://docs.claude.com/en/docs/initial-setup) Args: max_tokens: The maximum number of tokens to generate before stopping. @@ -385,7 +386,8 @@ def create( The Messages API can be used for either single queries or stateless multi-turn conversations. - Learn more about the Messages API in our [user guide](/en/docs/initial-setup) + Learn more about the Messages API in our + [user guide](https://docs.claude.com/en/docs/initial-setup) Args: max_tokens: The maximum number of tokens to generate before stopping. @@ -652,7 +654,8 @@ def create( The Messages API can be used for either single queries or stateless multi-turn conversations. - Learn more about the Messages API in our [user guide](/en/docs/initial-setup) + Learn more about the Messages API in our + [user guide](https://docs.claude.com/en/docs/initial-setup) Args: max_tokens: The maximum number of tokens to generate before stopping. @@ -1046,7 +1049,7 @@ def count_tokens( including tools, images, and documents, without creating it. Learn more about token counting in our - [user guide](/en/docs/build-with-claude/token-counting) + [user guide](https://docs.claude.com/en/docs/build-with-claude/token-counting) Args: messages: Input messages. @@ -1298,7 +1301,8 @@ async def create( The Messages API can be used for either single queries or stateless multi-turn conversations. - Learn more about the Messages API in our [user guide](/en/docs/initial-setup) + Learn more about the Messages API in our + [user guide](https://docs.claude.com/en/docs/initial-setup) Args: max_tokens: The maximum number of tokens to generate before stopping. @@ -1565,7 +1569,8 @@ async def create( The Messages API can be used for either single queries or stateless multi-turn conversations. - Learn more about the Messages API in our [user guide](/en/docs/initial-setup) + Learn more about the Messages API in our + [user guide](https://docs.claude.com/en/docs/initial-setup) Args: max_tokens: The maximum number of tokens to generate before stopping. @@ -1832,7 +1837,8 @@ async def create( The Messages API can be used for either single queries or stateless multi-turn conversations. - Learn more about the Messages API in our [user guide](/en/docs/initial-setup) + Learn more about the Messages API in our + [user guide](https://docs.claude.com/en/docs/initial-setup) Args: max_tokens: The maximum number of tokens to generate before stopping. @@ -2225,7 +2231,7 @@ async def count_tokens( including tools, images, and documents, without creating it. Learn more about token counting in our - [user guide](/en/docs/build-with-claude/token-counting) + [user guide](https://docs.claude.com/en/docs/build-with-claude/token-counting) Args: messages: Input messages. diff --git a/src/anthropic/types/beta/__init__.py b/src/anthropic/types/beta/__init__.py index ef3ac62f3..34e09af2f 100644 --- a/src/anthropic/types/beta/__init__.py +++ b/src/anthropic/types/beta/__init__.py @@ -83,6 +83,7 @@ from .beta_tool_uses_trigger_param import BetaToolUsesTriggerParam as BetaToolUsesTriggerParam from .beta_web_search_result_block import BetaWebSearchResultBlock as BetaWebSearchResultBlock from .beta_all_thinking_turns_param import BetaAllThinkingTurnsParam as BetaAllThinkingTurnsParam +from .beta_json_output_format_param import BetaJSONOutputFormatParam as BetaJSONOutputFormatParam from .beta_mcp_tool_use_block_param import BetaMCPToolUseBlockParam as BetaMCPToolUseBlockParam from .beta_raw_message_stream_event import BetaRawMessageStreamEvent as BetaRawMessageStreamEvent from .beta_tool_bash_20241022_param import BetaToolBash20241022Param as BetaToolBash20241022Param diff --git a/src/anthropic/types/beta/beta_code_execution_tool_20250522_param.py b/src/anthropic/types/beta/beta_code_execution_tool_20250522_param.py index c35315e1b..65e653a83 100644 --- a/src/anthropic/types/beta/beta_code_execution_tool_20250522_param.py +++ b/src/anthropic/types/beta/beta_code_execution_tool_20250522_param.py @@ -21,3 +21,5 @@ class BetaCodeExecutionTool20250522Param(TypedDict, total=False): cache_control: Optional[BetaCacheControlEphemeralParam] """Create a cache control breakpoint at this content block.""" + + strict: bool diff --git a/src/anthropic/types/beta/beta_code_execution_tool_20250825_param.py b/src/anthropic/types/beta/beta_code_execution_tool_20250825_param.py index c4d1fbc42..df83f7285 100644 --- a/src/anthropic/types/beta/beta_code_execution_tool_20250825_param.py +++ b/src/anthropic/types/beta/beta_code_execution_tool_20250825_param.py @@ -21,3 +21,5 @@ class BetaCodeExecutionTool20250825Param(TypedDict, total=False): cache_control: Optional[BetaCacheControlEphemeralParam] """Create a cache control breakpoint at this content block.""" + + strict: bool diff --git a/src/anthropic/types/beta/beta_json_output_format_param.py b/src/anthropic/types/beta/beta_json_output_format_param.py new file mode 100644 index 000000000..48f9fb0b1 --- /dev/null +++ b/src/anthropic/types/beta/beta_json_output_format_param.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["BetaJSONOutputFormatParam"] + + +class BetaJSONOutputFormatParam(TypedDict, total=False): + schema: Required[Dict[str, object]] + """The JSON schema of the format""" + + type: Required[Literal["json_schema"]] diff --git a/src/anthropic/types/beta/beta_memory_tool_20250818_param.py b/src/anthropic/types/beta/beta_memory_tool_20250818_param.py index 67f0f20b8..bbda44d97 100644 --- a/src/anthropic/types/beta/beta_memory_tool_20250818_param.py +++ b/src/anthropic/types/beta/beta_memory_tool_20250818_param.py @@ -21,3 +21,5 @@ class BetaMemoryTool20250818Param(TypedDict, total=False): cache_control: Optional[BetaCacheControlEphemeralParam] """Create a cache control breakpoint at this content block.""" + + strict: bool diff --git a/src/anthropic/types/beta/beta_tool_bash_20241022_param.py b/src/anthropic/types/beta/beta_tool_bash_20241022_param.py index 51576c581..705890bbc 100644 --- a/src/anthropic/types/beta/beta_tool_bash_20241022_param.py +++ b/src/anthropic/types/beta/beta_tool_bash_20241022_param.py @@ -21,3 +21,5 @@ class BetaToolBash20241022Param(TypedDict, total=False): cache_control: Optional[BetaCacheControlEphemeralParam] """Create a cache control breakpoint at this content block.""" + + strict: bool diff --git a/src/anthropic/types/beta/beta_tool_bash_20250124_param.py b/src/anthropic/types/beta/beta_tool_bash_20250124_param.py index d1af1c359..228e403eb 100644 --- a/src/anthropic/types/beta/beta_tool_bash_20250124_param.py +++ b/src/anthropic/types/beta/beta_tool_bash_20250124_param.py @@ -21,3 +21,5 @@ class BetaToolBash20250124Param(TypedDict, total=False): cache_control: Optional[BetaCacheControlEphemeralParam] """Create a cache control breakpoint at this content block.""" + + strict: bool diff --git a/src/anthropic/types/beta/beta_tool_computer_use_20241022_param.py b/src/anthropic/types/beta/beta_tool_computer_use_20241022_param.py index 4ea036ac2..0b2b0f2ba 100644 --- a/src/anthropic/types/beta/beta_tool_computer_use_20241022_param.py +++ b/src/anthropic/types/beta/beta_tool_computer_use_20241022_param.py @@ -30,3 +30,5 @@ class BetaToolComputerUse20241022Param(TypedDict, total=False): display_number: Optional[int] """The X11 display number (e.g. 0, 1) for the display.""" + + strict: bool diff --git a/src/anthropic/types/beta/beta_tool_computer_use_20250124_param.py b/src/anthropic/types/beta/beta_tool_computer_use_20250124_param.py index e68f462de..45dbff00d 100644 --- a/src/anthropic/types/beta/beta_tool_computer_use_20250124_param.py +++ b/src/anthropic/types/beta/beta_tool_computer_use_20250124_param.py @@ -30,3 +30,5 @@ class BetaToolComputerUse20250124Param(TypedDict, total=False): display_number: Optional[int] """The X11 display number (e.g. 0, 1) for the display.""" + + strict: bool diff --git a/src/anthropic/types/beta/beta_tool_param.py b/src/anthropic/types/beta/beta_tool_param.py index 8298978ab..39ebd3410 100644 --- a/src/anthropic/types/beta/beta_tool_param.py +++ b/src/anthropic/types/beta/beta_tool_param.py @@ -48,4 +48,6 @@ class BetaToolParam(TypedDict, total=False): aspects of the tool input JSON schema. """ + strict: bool + type: Optional[Literal["custom"]] diff --git a/src/anthropic/types/beta/beta_tool_text_editor_20241022_param.py b/src/anthropic/types/beta/beta_tool_text_editor_20241022_param.py index d6e72f87d..d055069ac 100644 --- a/src/anthropic/types/beta/beta_tool_text_editor_20241022_param.py +++ b/src/anthropic/types/beta/beta_tool_text_editor_20241022_param.py @@ -21,3 +21,5 @@ class BetaToolTextEditor20241022Param(TypedDict, total=False): cache_control: Optional[BetaCacheControlEphemeralParam] """Create a cache control breakpoint at this content block.""" + + strict: bool diff --git a/src/anthropic/types/beta/beta_tool_text_editor_20250124_param.py b/src/anthropic/types/beta/beta_tool_text_editor_20250124_param.py index bcf35dc3b..9d5b0b036 100644 --- a/src/anthropic/types/beta/beta_tool_text_editor_20250124_param.py +++ b/src/anthropic/types/beta/beta_tool_text_editor_20250124_param.py @@ -21,3 +21,5 @@ class BetaToolTextEditor20250124Param(TypedDict, total=False): cache_control: Optional[BetaCacheControlEphemeralParam] """Create a cache control breakpoint at this content block.""" + + strict: bool diff --git a/src/anthropic/types/beta/beta_tool_text_editor_20250429_param.py b/src/anthropic/types/beta/beta_tool_text_editor_20250429_param.py index d9080371e..08d1710f4 100644 --- a/src/anthropic/types/beta/beta_tool_text_editor_20250429_param.py +++ b/src/anthropic/types/beta/beta_tool_text_editor_20250429_param.py @@ -21,3 +21,5 @@ class BetaToolTextEditor20250429Param(TypedDict, total=False): cache_control: Optional[BetaCacheControlEphemeralParam] """Create a cache control breakpoint at this content block.""" + + strict: bool diff --git a/src/anthropic/types/beta/beta_tool_text_editor_20250728_param.py b/src/anthropic/types/beta/beta_tool_text_editor_20250728_param.py index 3f825d5de..00f21aabb 100644 --- a/src/anthropic/types/beta/beta_tool_text_editor_20250728_param.py +++ b/src/anthropic/types/beta/beta_tool_text_editor_20250728_param.py @@ -27,3 +27,5 @@ class BetaToolTextEditor20250728Param(TypedDict, total=False): If not specified, defaults to displaying the full file. """ + + strict: bool diff --git a/src/anthropic/types/beta/beta_web_fetch_tool_20250910_param.py b/src/anthropic/types/beta/beta_web_fetch_tool_20250910_param.py index 8577405b2..d211e46e6 100644 --- a/src/anthropic/types/beta/beta_web_fetch_tool_20250910_param.py +++ b/src/anthropic/types/beta/beta_web_fetch_tool_20250910_param.py @@ -44,3 +44,5 @@ class BetaWebFetchTool20250910Param(TypedDict, total=False): max_uses: Optional[int] """Maximum number of times the tool can be used in the API request.""" + + strict: bool diff --git a/src/anthropic/types/beta/beta_web_search_tool_20250305_param.py b/src/anthropic/types/beta/beta_web_search_tool_20250305_param.py index 088938957..a3b538ec5 100644 --- a/src/anthropic/types/beta/beta_web_search_tool_20250305_param.py +++ b/src/anthropic/types/beta/beta_web_search_tool_20250305_param.py @@ -58,6 +58,8 @@ class BetaWebSearchTool20250305Param(TypedDict, total=False): max_uses: Optional[int] """Maximum number of times the tool can be used in the API request.""" + strict: bool + user_location: Optional[UserLocation] """Parameters for the user's location. diff --git a/src/anthropic/types/beta/message_count_tokens_params.py b/src/anthropic/types/beta/message_count_tokens_params.py index 128b03d9c..f6faeb31b 100644 --- a/src/anthropic/types/beta/message_count_tokens_params.py +++ b/src/anthropic/types/beta/message_count_tokens_params.py @@ -13,6 +13,7 @@ from .beta_text_block_param import BetaTextBlockParam from .beta_tool_choice_param import BetaToolChoiceParam from .beta_thinking_config_param import BetaThinkingConfigParam +from .beta_json_output_format_param import BetaJSONOutputFormatParam from .beta_tool_bash_20241022_param import BetaToolBash20241022Param from .beta_tool_bash_20250124_param import BetaToolBash20250124Param from .beta_memory_tool_20250818_param import BetaMemoryTool20250818Param @@ -118,6 +119,9 @@ class MessageCountTokensParams(TypedDict, total=False): mcp_servers: Iterable[BetaRequestMCPServerURLDefinitionParam] """MCP servers to be utilized in this request""" + output_format: Optional[BetaJSONOutputFormatParam] + """A schema to specify Claude's output format in responses.""" + system: Union[str, Iterable[BetaTextBlockParam]] """System prompt. diff --git a/src/anthropic/types/beta/message_create_params.py b/src/anthropic/types/beta/message_create_params.py index 6a1152a26..1202c91b3 100644 --- a/src/anthropic/types/beta/message_create_params.py +++ b/src/anthropic/types/beta/message_create_params.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import List, Union, Iterable, Optional +from typing import List, Union, Generic, Iterable, Optional from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict from ..._types import SequenceNotStr @@ -10,16 +10,24 @@ from ..model_param import ModelParam from .beta_message_param import BetaMessageParam from .beta_metadata_param import BetaMetadataParam +from .parsed_beta_message import ResponseFormatT from ..anthropic_beta_param import AnthropicBetaParam from .beta_container_params import BetaContainerParams from .beta_text_block_param import BetaTextBlockParam from .beta_tool_union_param import BetaToolUnionParam from .beta_tool_choice_param import BetaToolChoiceParam from .beta_thinking_config_param import BetaThinkingConfigParam +from .beta_json_output_format_param import BetaJSONOutputFormatParam from .beta_context_management_config_param import BetaContextManagementConfigParam from .beta_request_mcp_server_url_definition_param import BetaRequestMCPServerURLDefinitionParam -__all__ = ["MessageCreateParamsBase", "Container", "MessageCreateParamsNonStreaming", "MessageCreateParamsStreaming"] +__all__ = [ + "MessageCreateParamsBase", + "Container", + "MessageCreateParamsNonStreaming", + "MessageCreateParamsStreaming", + "OutputFormat", +] class MessageCreateParamsBase(TypedDict, total=False): @@ -124,6 +132,9 @@ class MessageCreateParamsBase(TypedDict, total=False): metadata: BetaMetadataParam """An object describing metadata about the request.""" + output_format: Optional[BetaJSONOutputFormatParam] + """A schema to specify Claude's output format in responses.""" + service_tier: Literal["auto", "standard_only"] """ Determines whether to use priority capacity (if available) or standard capacity @@ -290,6 +301,17 @@ class MessageCreateParamsBase(TypedDict, total=False): Container: TypeAlias = Union[BetaContainerParams, str] +class ParseMessageCreateParamsBase(MessageCreateParamsBase, Generic[ResponseFormatT]): + output_format: type[ResponseFormatT] # type: ignore[misc] + + +class OutputFormat(TypedDict, total=False): + schema: Required[object] + """The JSON schema of the format""" + + type: Required[Literal["json_schema"]] + + class MessageCreateParamsNonStreaming(MessageCreateParamsBase, total=False): stream: Literal[False] """Whether to incrementally stream the response using server-sent events. diff --git a/src/anthropic/types/beta/messages/batch_create_params.py b/src/anthropic/types/beta/messages/batch_create_params.py index 6afb8e56f..cf98dcb66 100644 --- a/src/anthropic/types/beta/messages/batch_create_params.py +++ b/src/anthropic/types/beta/messages/batch_create_params.py @@ -3,13 +3,13 @@ from __future__ import annotations from typing import List, Iterable -from typing_extensions import Required, Annotated, TypedDict +from typing_extensions import Literal, Required, Annotated, TypedDict from ...._utils import PropertyInfo from ...anthropic_beta_param import AnthropicBetaParam from ..message_create_params import MessageCreateParamsNonStreaming -__all__ = ["BatchCreateParams", "Request"] +__all__ = ["BatchCreateParams", "Request", "RequestParamsOutputFormat"] class BatchCreateParams(TypedDict, total=False): @@ -23,6 +23,12 @@ class BatchCreateParams(TypedDict, total=False): """Optional header to specify the beta version(s) you want to use.""" +class RequestParamsOutputFormat(TypedDict, total=False): + schema: Required[object] + """The JSON schema of the format""" + + type: Required[Literal["json_schema"]] + class Request(TypedDict, total=False): custom_id: Required[str] diff --git a/src/anthropic/types/beta/parsed_beta_message.py b/src/anthropic/types/beta/parsed_beta_message.py new file mode 100644 index 000000000..7f4ec4a6a --- /dev/null +++ b/src/anthropic/types/beta/parsed_beta_message.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Union, Generic, Optional +from typing_extensions import TypeVar, Annotated, TypeAlias + +from ..._utils import PropertyInfo +from .beta_message import BetaMessage +from .beta_text_block import BetaTextBlock +from .beta_thinking_block import BetaThinkingBlock +from .beta_tool_use_block import BetaToolUseBlock +from .beta_mcp_tool_use_block import BetaMCPToolUseBlock +from .beta_mcp_tool_result_block import BetaMCPToolResultBlock +from .beta_server_tool_use_block import BetaServerToolUseBlock +from .beta_container_upload_block import BetaContainerUploadBlock +from .beta_redacted_thinking_block import BetaRedactedThinkingBlock +from .beta_web_search_tool_result_block import BetaWebSearchToolResultBlock +from .beta_code_execution_tool_result_block import BetaCodeExecutionToolResultBlock +from .beta_bash_code_execution_tool_result_block import BetaBashCodeExecutionToolResultBlock +from .beta_text_editor_code_execution_tool_result_block import BetaTextEditorCodeExecutionToolResultBlock + +ResponseFormatT = TypeVar("ResponseFormatT", default=None) + + +__all__ = [ + "ParsedBetaTextBlock", + "ParsedBetaContentBlock", + "ParsedBetaMessage", +] + + +class ParsedBetaTextBlock(BetaTextBlock, Generic[ResponseFormatT]): + parsed_output: Optional[ResponseFormatT] = None + + __api_exclude__ = {"parsed_output"} + + +# Note that generic unions are not valid for pydantic at runtime +ParsedBetaContentBlock: TypeAlias = Annotated[ + Union[ + ParsedBetaTextBlock[ResponseFormatT], + BetaThinkingBlock, + BetaRedactedThinkingBlock, + BetaToolUseBlock, + BetaServerToolUseBlock, + BetaWebSearchToolResultBlock, + BetaCodeExecutionToolResultBlock, + BetaBashCodeExecutionToolResultBlock, + BetaTextEditorCodeExecutionToolResultBlock, + BetaMCPToolUseBlock, + BetaMCPToolResultBlock, + BetaContainerUploadBlock, + ], + PropertyInfo(discriminator="type"), +] + + +class ParsedBetaMessage(BetaMessage, Generic[ResponseFormatT]): + if TYPE_CHECKING: + content: List[ParsedBetaContentBlock[ResponseFormatT]] # type: ignore[assignment] + else: + content: List[ParsedBetaContentBlock] + + @property + def parsed_output(self) -> Optional[ResponseFormatT]: + for content in self.content: + if content.type == "text" and content.parsed_output: + return content.parsed_output + return None diff --git a/src/anthropic/types/messages/batch_create_params.py b/src/anthropic/types/messages/batch_create_params.py index f86065924..a82a5ff08 100644 --- a/src/anthropic/types/messages/batch_create_params.py +++ b/src/anthropic/types/messages/batch_create_params.py @@ -18,7 +18,6 @@ class BatchCreateParams(TypedDict, total=False): """ - class Request(TypedDict, total=False): custom_id: Required[str] """Developer-provided ID created for each request in a Message Batch. diff --git a/tests/api_resources/beta/messages/test_batches.py b/tests/api_resources/beta/messages/test_batches.py index 223611422..fe4110bad 100644 --- a/tests/api_resources/beta/messages/test_batches.py +++ b/tests/api_resources/beta/messages/test_batches.py @@ -106,6 +106,10 @@ def test_method_create_with_all_params(self, client: Anthropic) -> None: } ], "metadata": {"user_id": "13803d75-b4b5-4c3e-b2a2-6f21399b021b"}, + "output_format": { + "schema": {"foo": "bar"}, + "type": "json_schema", + }, "service_tier": "auto", "stop_sequences": ["string"], "stream": False, @@ -154,6 +158,7 @@ def test_method_create_with_all_params(self, client: Anthropic) -> None: "ttl": "5m", }, "description": "Get the current weather in a given location", + "strict": True, "type": "custom", } ], @@ -520,6 +525,10 @@ async def test_method_create_with_all_params(self, async_client: AsyncAnthropic) } ], "metadata": {"user_id": "13803d75-b4b5-4c3e-b2a2-6f21399b021b"}, + "output_format": { + "schema": {"foo": "bar"}, + "type": "json_schema", + }, "service_tier": "auto", "stop_sequences": ["string"], "stream": False, @@ -568,6 +577,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncAnthropic) "ttl": "5m", }, "description": "Get the current weather in a given location", + "strict": True, "type": "custom", } ], diff --git a/tests/api_resources/beta/test_messages.py b/tests/api_resources/beta/test_messages.py index b77a45387..f62ed3f6d 100644 --- a/tests/api_resources/beta/test_messages.py +++ b/tests/api_resources/beta/test_messages.py @@ -6,6 +6,7 @@ from typing import Any, cast import pytest +import pydantic from anthropic import Anthropic, AsyncAnthropic from tests.utils import assert_matches_type @@ -91,6 +92,10 @@ def test_method_create_with_all_params_overload_1(self, client: Anthropic) -> No } ], metadata={"user_id": "13803d75-b4b5-4c3e-b2a2-6f21399b021b"}, + output_format={ + "schema": {"foo": "bar"}, + "type": "json_schema", + }, service_tier="auto", stop_sequences=["string"], stream=False, @@ -139,6 +144,7 @@ def test_method_create_with_all_params_overload_1(self, client: Anthropic) -> No "ttl": "5m", }, "description": "Get the current weather in a given location", + "strict": True, "type": "custom", } ], @@ -261,6 +267,10 @@ def test_method_create_with_all_params_overload_2(self, client: Anthropic) -> No } ], metadata={"user_id": "13803d75-b4b5-4c3e-b2a2-6f21399b021b"}, + output_format={ + "schema": {"foo": "bar"}, + "type": "json_schema", + }, service_tier="auto", stop_sequences=["string"], system=[ @@ -308,6 +318,7 @@ def test_method_create_with_all_params_overload_2(self, client: Anthropic) -> No "ttl": "5m", }, "description": "Get the current weather in a given location", + "strict": True, "type": "custom", } ], @@ -416,6 +427,10 @@ def test_method_count_tokens_with_all_params(self, client: Anthropic) -> None: }, } ], + output_format={ + "schema": {"foo": "bar"}, + "type": "json_schema", + }, system=[ { "text": "Today's date is 2024-06-01.", @@ -460,6 +475,7 @@ def test_method_count_tokens_with_all_params(self, client: Anthropic) -> None: "ttl": "5m", }, "description": "Get the current weather in a given location", + "strict": True, "type": "custom", } ], @@ -505,6 +521,23 @@ def test_streaming_response_count_tokens(self, client: Anthropic) -> None: assert cast(Any, response.is_closed) is True + @parametrize + def test_pydantic_error_in_create(self, client: Anthropic) -> None: + class MyModel(pydantic.BaseModel): + name: str + age: int + + with pytest.raises(TypeError) as exc_info: + client.beta.messages.create( + max_tokens=1024, + messages=[{"role": "user", "content": "Test"}], + model="claude-sonnet-4-5-20250929", + output_format=MyModel, # type: ignore + ) + + error_message = str(exc_info.value) + assert "parse()" in error_message + class TestAsyncMessages: parametrize = pytest.mark.parametrize( @@ -582,6 +615,10 @@ async def test_method_create_with_all_params_overload_1(self, async_client: Asyn } ], metadata={"user_id": "13803d75-b4b5-4c3e-b2a2-6f21399b021b"}, + output_format={ + "schema": {"foo": "bar"}, + "type": "json_schema", + }, service_tier="auto", stop_sequences=["string"], stream=False, @@ -630,6 +667,7 @@ async def test_method_create_with_all_params_overload_1(self, async_client: Asyn "ttl": "5m", }, "description": "Get the current weather in a given location", + "strict": True, "type": "custom", } ], @@ -752,6 +790,10 @@ async def test_method_create_with_all_params_overload_2(self, async_client: Asyn } ], metadata={"user_id": "13803d75-b4b5-4c3e-b2a2-6f21399b021b"}, + output_format={ + "schema": {"foo": "bar"}, + "type": "json_schema", + }, service_tier="auto", stop_sequences=["string"], system=[ @@ -799,6 +841,7 @@ async def test_method_create_with_all_params_overload_2(self, async_client: Asyn "ttl": "5m", }, "description": "Get the current weather in a given location", + "strict": True, "type": "custom", } ], @@ -907,6 +950,10 @@ async def test_method_count_tokens_with_all_params(self, async_client: AsyncAnth }, } ], + output_format={ + "schema": {"foo": "bar"}, + "type": "json_schema", + }, system=[ { "text": "Today's date is 2024-06-01.", @@ -951,6 +998,7 @@ async def test_method_count_tokens_with_all_params(self, async_client: AsyncAnth "ttl": "5m", }, "description": "Get the current weather in a given location", + "strict": True, "type": "custom", } ], @@ -995,3 +1043,20 @@ async def test_streaming_response_count_tokens(self, async_client: AsyncAnthropi assert_matches_type(BetaMessageTokensCount, message, path=["response"]) assert cast(Any, response.is_closed) is True + + @parametrize + async def test_pydantic_error_in_create(self, async_client: AsyncAnthropic) -> None: + class MyModel(pydantic.BaseModel): + name: str + age: int + + with pytest.raises(TypeError) as exc_info: + await async_client.beta.messages.create( + max_tokens=1024, + messages=[{"role": "user", "content": "Test"}], + model="claude-sonnet-4-5-20250929", + output_format=MyModel, # type: ignore + ) + + error_message = str(exc_info.value) + assert "parse()" in error_message diff --git a/tests/lib/_parse/test_transform.py b/tests/lib/_parse/test_transform.py new file mode 100644 index 000000000..a819782dc --- /dev/null +++ b/tests/lib/_parse/test_transform.py @@ -0,0 +1,191 @@ +from copy import deepcopy + +import pytest +from inline_snapshot import snapshot + +from anthropic.lib._parse._transform import transform_schema + + +def test_ref_schema(): + schema = {"$ref": "#/components/schemas/SomeSchema"} + result = transform_schema(schema) + assert result == snapshot({"$ref": "#/components/schemas/SomeSchema"}) + + +def test_anyof_schema(): + schema = { + "anyOf": [ + {"type": "string"}, + {"type": "integer", "minimum": 1}, + ] + } + result = transform_schema(schema) + assert result == snapshot( + { + "anyOf": [ + {"type": "string"}, + { + "type": "integer", + "description": "{minimum: 1}", + }, + ] + } + ) + + +def test_allof(): + schema = { + "allOf": [ + {"type": "object", "properties": {"name": {"type": "string"}}}, + {"type": "object", "properties": {"age": {"type": "integer", "minimum": 0}}}, + ] + } + result = transform_schema(schema) + assert result == snapshot( + { + "allOf": [ + { + "type": "object", + "properties": {"name": {"type": "string"}}, + "additionalProperties": False, + }, + { + "type": "object", + "properties": {"age": {"type": "integer", "description": "{minimum: 0}"}}, + "additionalProperties": False, + }, + ] + } + ) + + +def test_object_schema(): + schema = { + "type": "object", + "properties": { + "name": {"type": "string", "default": "John"}, + "age": {"type": "integer", "minimum": 0}, + }, + "required": ["name"], + "description": "Person object", + } + result = transform_schema(schema) + assert result == snapshot( + { + "type": "object", + "description": "Person object", + "properties": { + "name": {"type": "string", "description": "{default: John}"}, + "age": {"type": "integer", "description": "{minimum: 0}"}, + }, + "additionalProperties": False, + "required": ["name"], + } + ) + + +def test_array_schema(): + schema = { + "type": "array", + "items": {"type": "string"}, + "minItems": 2, + "description": "A list of strings", + } + result = transform_schema(schema) + assert result == snapshot( + { + "type": "array", + "description": """\ +A list of strings + +{minItems: 2}\ +""", + "items": {"type": "string"}, + } + ) + + +def test_string_schema_with_format_and_default(): + schema = { + "type": "string", + "format": "email", + "default": "user@example.com", + "description": "User email", + } + result = transform_schema(schema) + assert result == snapshot( + { + "type": "string", + "description": """\ +User email + +{default: user@example.com}\ +""", + "format": "email", + } + ) + + +def test_string_schema_without_format(): + schema = {"type": "string"} + result = transform_schema(schema) + assert result == snapshot({"type": "string"}) + + +def test_integer_schema_with_min_max_exclusive(): + schema = { + "type": "integer", + "minimum": 1, + "maximum": 10, + "exclusiveMinimum": 0, + "exclusiveMaximum": 20, + "description": "A number", + } + result = transform_schema(schema) + assert result == snapshot( + { + "type": "integer", + "description": """\ +A number + +{minimum: 1, maximum: 10, exclusiveMinimum: 0, exclusiveMaximum: 20}\ +""", + } + ) + + +def test_boolean_schema(): + schema = {"type": "boolean", "description": "A flag"} + result = transform_schema(schema) + assert result == snapshot({"type": "boolean", "description": "A flag"}) + + +def test_null_schema(): + schema = {"type": "null"} + result = transform_schema(schema) + assert result == snapshot({"type": "null"}) + + +def test_unsupported_type_asserts(): + schema = {"type": "unsupported"} + with pytest.raises(AssertionError): + transform_schema(schema) + + +def test_original_schema_not_mutated(): + original_schema = { + "type": "object", + "properties": { + "name": {"type": "string", "default": "John"}, + "age": {"type": "integer", "minimum": 0}, + }, + "required": ["name"], + "description": "Person object", + "additionalProperties": True, + } + + original_schema_backup = deepcopy(original_schema) + + transform_schema(original_schema) + + assert original_schema == original_schema_backup diff --git a/tests/lib/streaming/test_beta_messages.py b/tests/lib/streaming/test_beta_messages.py index d27729992..d32730b51 100644 --- a/tests/lib/streaming/test_beta_messages.py +++ b/tests/lib/streaming/test_beta_messages.py @@ -13,7 +13,7 @@ from anthropic import Anthropic, AsyncAnthropic from anthropic._compat import PYDANTIC_V1 from anthropic.types.beta.beta_message import BetaMessage -from anthropic.lib.streaming._beta_types import BetaMessageStreamEvent +from anthropic.lib.streaming._beta_types import ParsedBetaMessageStreamEvent from anthropic.resources.messages.messages import DEPRECATED_MODELS from anthropic.lib.streaming._beta_messages import TRACKS_TOOL_INPUT, BetaMessageStream, BetaAsyncMessageStream @@ -170,17 +170,17 @@ def assert_message_matches(message: BetaMessage, expected: Dict[str, Any]) -> No test_case.assertEqual(expected, json.loads(actual_message_json)) -def assert_basic_response(events: list[BetaMessageStreamEvent], message: BetaMessage) -> None: +def assert_basic_response(events: list[ParsedBetaMessageStreamEvent], message: BetaMessage) -> None: assert_message_matches(message, EXPECTED_BASIC_MESSAGE) assert [e.type for e in events] == EXPECTED_BASIC_EVENT_TYPES -def assert_tool_use_response(events: list[BetaMessageStreamEvent], message: BetaMessage) -> None: +def assert_tool_use_response(events: list[ParsedBetaMessageStreamEvent], message: BetaMessage) -> None: assert_message_matches(message, EXPECTED_TOOL_USE_MESSAGE) assert [e.type for e in events] == EXPECTED_TOOL_USE_EVENT_TYPES -def assert_incomplete_partial_input_response(events: list[BetaMessageStreamEvent], message: BetaMessage) -> None: +def assert_incomplete_partial_input_response(events: list[ParsedBetaMessageStreamEvent], message: BetaMessage) -> None: assert_message_matches(message, EXPECTED_INCOMPLETE_MESSAGE) assert [e.type for e in events] == EXPECTED_INCOMPLETE_EVENT_TYPES @@ -386,6 +386,9 @@ def test_stream_method_definition_in_sync(sync: bool) -> None: # intentionally excluded continue + if name == "output_format": + continue + custom_param = sig.parameters.get(name) if not custom_param: errors.append(f"the `{name}` param is missing") diff --git a/tests/lib/streaming/test_partial_json.py b/tests/lib/streaming/test_partial_json.py index fd319acd2..4815adac1 100644 --- a/tests/lib/streaming/test_partial_json.py +++ b/tests/lib/streaming/test_partial_json.py @@ -5,9 +5,9 @@ from anthropic.types.tool_use_block import ToolUseBlock from anthropic.types.beta.beta_usage import BetaUsage -from anthropic.types.beta.beta_message import BetaMessage from anthropic.lib.streaming._beta_messages import accumulate_event from anthropic.types.beta.beta_tool_use_block import BetaToolUseBlock +from anthropic.types.beta.parsed_beta_message import ParsedBetaMessage from anthropic.types.beta.beta_input_json_delta import BetaInputJSONDelta from anthropic.types.beta.beta_raw_content_block_delta_event import BetaRawContentBlockDeltaEvent @@ -15,7 +15,7 @@ class TestPartialJson: def test_trailing_strings_mode_header(self) -> None: """Test behavior differences with and without the beta header for JSON parsing.""" - message = BetaMessage( + message = ParsedBetaMessage( id="msg_123", type="message", role="assistant", @@ -101,7 +101,7 @@ def test_trailing_strings_mode_header(self) -> None: # test that with invalid JSON we throw the correct error def test_partial_json_with_invalid_json(self) -> None: """Test that invalid JSON raises an error.""" - message = BetaMessage( + message = ParsedBetaMessage( id="msg_123", type="message", role="assistant", diff --git a/tests/lib/tools/__inline_snapshot__/test_runners/TestSyncRunTools.test_max_iterations/ef758469-6fa6-454c-b2e6-19d0b450a8c5.json b/tests/lib/tools/__inline_snapshot__/test_runners/TestSyncRunTools.test_max_iterations/ef758469-6fa6-454c-b2e6-19d0b450a8c5.json deleted file mode 100644 index 926e8e267..000000000 --- a/tests/lib/tools/__inline_snapshot__/test_runners/TestSyncRunTools.test_max_iterations/ef758469-6fa6-454c-b2e6-19d0b450a8c5.json +++ /dev/null @@ -1,4 +0,0 @@ -[ - "{\"id\": \"msg_0184u1shM6AjCNAaoknBoEpf\", \"type\": \"message\", \"role\": \"assistant\", \"model\": \"claude-3-5-sonnet-20241022\", \"content\": [{\"type\": \"text\", \"text\": \"I'll help you check the weather for each city one by one. I'll use Fahrenheit units by default. Let me check them sequentially.\\n\\nFirst, let's check San Francisco:\"}, {\"type\": \"tool_use\", \"id\": \"toolu_01GiQJzt5d2ThB4fSUsRCSML\", \"name\": \"get_weather\", \"input\": {\"location\": \"San Francisco, CA\", \"units\": \"f\"}}], \"stop_reason\": \"tool_use\", \"stop_sequence\": null, \"usage\": {\"input_tokens\": 518, \"cache_creation_input_tokens\": 0, \"cache_read_input_tokens\": 0, \"cache_creation\": {\"ephemeral_5m_input_tokens\": 0, \"ephemeral_1h_input_tokens\": 0}, \"output_tokens\": 114, \"service_tier\": \"standard\"}}", - "{\"id\": \"msg_015zyD3V5WXG8r3hgkUaNGCZ\", \"type\": \"message\", \"role\": \"assistant\", \"model\": \"claude-3-5-sonnet-20241022\", \"content\": [{\"type\": \"text\", \"text\": \"Now for New York:\"}, {\"type\": \"tool_use\", \"id\": \"toolu_015yzRQ92SwYGz5Veoq7A3P7\", \"name\": \"get_weather\", \"input\": {\"location\": \"New York, NY\", \"units\": \"f\"}}], \"stop_reason\": \"tool_use\", \"stop_sequence\": null, \"usage\": {\"input_tokens\": 671, \"cache_creation_input_tokens\": 0, \"cache_read_input_tokens\": 0, \"cache_creation\": {\"ephemeral_5m_input_tokens\": 0, \"ephemeral_1h_input_tokens\": 0}, \"output_tokens\": 80, \"service_tier\": \"standard\"}}" -] \ No newline at end of file diff --git a/tests/lib/tools/__inline_snapshot__/test_runners/TestSyncRunTools.test_streaming_call_sync_events/9cb114c8-69bd-4111-841b-edee30333afd.json b/tests/lib/tools/__inline_snapshot__/test_runners/TestSyncRunTools.test_streaming_call_sync_events/9cb114c8-69bd-4111-841b-edee30333afd.json index 138efe8eb..0c9f8df6e 100644 --- a/tests/lib/tools/__inline_snapshot__/test_runners/TestSyncRunTools.test_streaming_call_sync_events/9cb114c8-69bd-4111-841b-edee30333afd.json +++ b/tests/lib/tools/__inline_snapshot__/test_runners/TestSyncRunTools.test_streaming_call_sync_events/9cb114c8-69bd-4111-841b-edee30333afd.json @@ -1,4 +1,4 @@ [ - "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_01YRiDgGtAnVWtzGJqRSZjAn\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":473,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I'll\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" help you check the weather in\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" San Francisco. Since\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" the location parameter\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" requires city and state format\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\", I'll use \\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"San Francisco, CA\\\".\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" I'll show\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" you the temperature\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" in both Celsius an\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d Fahrenheit.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_012hgxfzW99WoVYezL1WcYey\",\"name\":\"get_weather\",\"input\":{}} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"l\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ocation\\\": \\\"S\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"an Franc\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"isco, C\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"A\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\", \\\"units\\\":\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" \\\"c\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":2,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01NuZ5a7BDewKGEjC5XMchFi\",\"name\":\"get_weather\",\"input\":{}} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"location\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\": \\\"San Fran\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"cisco, CA\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\", \\\"unit\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"s\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":2,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\": \\\"f\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":2 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":473,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":175} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", - "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_01FXBHYjKkNTFc6PnCiarAoh\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":766,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" weather in San Francisco,\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" CA is currently\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" sunny with a temperature of \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"20\u00b0C (68\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\u00b0F).\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":766,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":27} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01919EJZ2QnfiQMaJsEDgX1t\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":656,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":26,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01ACkkNu59xcFXwQQdfjR5dC\",\"name\":\"get_weather\",\"input\":{}} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"loca\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"tion\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\": \\\"San Fran\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"cisco,\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" CA\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\", \\\"units\\\": \\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"f\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":656,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":74} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01Qq1aGWEp7xgSohVeUX4bGc\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":770,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":8,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The weather in San Francisco, CA is\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" currently **\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Sunny** with a temperature of **\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"68°F**.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":770,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":25} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" ] \ No newline at end of file diff --git a/tests/lib/tools/__inline_snapshot__/test_runners/test_streaming_call_sync/e2140c0f-07db-47ee-b86b-c2ec476866d5.json b/tests/lib/tools/__inline_snapshot__/test_runners/test_streaming_call_sync/e2140c0f-07db-47ee-b86b-c2ec476866d5.json deleted file mode 100644 index 72d731c3b..000000000 --- a/tests/lib/tools/__inline_snapshot__/test_runners/test_streaming_call_sync/e2140c0f-07db-47ee-b86b-c2ec476866d5.json +++ /dev/null @@ -1,4 +0,0 @@ -[ - "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_013ABQ6nbL2CSWP8qCEzyrzd\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":473,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I'll\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" help you check the weather in San Francisco. Let\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" me look that up for you.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01Lyjynae6j6wHfNVRKaMRK5\",\"name\":\"get_weather\",\"input\":{}} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"location\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\": \\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"San Francis\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"co, CA\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\", \\\"unit\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"s\\\": \\\"f\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":473,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":93} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n", - "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_01FtWrpBLsm99NpQCoFrhuf9\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":605,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" weather in San Francisco is currently \"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"68\u00b0F and sunny.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":605,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":18} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" -] \ No newline at end of file diff --git a/tests/lib/tools/test_runners.py b/tests/lib/tools/test_runners.py index 28040a297..c08703cca 100644 --- a/tests/lib/tools/test_runners.py +++ b/tests/lib/tools/test_runners.py @@ -31,114 +31,43 @@ "basic": { "responses": snapshot( [ - '{"id": "msg_01Lf1uRSXq1sB9df6EigSkXA", "type": "message", "role": "assistant", "model": "claude-3-5-sonnet-20241022", "content": [{"type": "text", "text": "I\'ll help you check the weather in San Francisco. I\'ll use the get_weather function, and I\'ll show you the temperature in both Celsius and Fahrenheit for completeness."}, {"type": "tool_use", "id": "toolu_013bzsyqF4LyvJj6CF5gYCEn", "name": "get_weather", "input": {"location": "San Francisco, CA", "units": "c"}}, {"type": "tool_use", "id": "toolu_01Ugb5BSmDUth8vbdkUsNYrs", "name": "get_weather", "input": {"location": "San Francisco, CA", "units": "f"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 473, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 169, "service_tier": "standard"}}', - '{"id": "msg_01SUujjdE6BMF3CYWCTR4vHF", "type": "message", "role": "assistant", "model": "claude-3-5-sonnet-20241022", "content": [{"type": "text", "text": "The weather in San Francisco is currently sunny with a temperature of 20\\u00b0C (68\\u00b0F)."}], "stop_reason": "end_turn", "stop_sequence": null, "usage": {"input_tokens": 760, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 25, "service_tier": "standard"}}', + '{"model": "claude-haiku-4-5-20251001", "id": "msg_0133AjAuLSKXatUZqNkpALPx", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01DGiQScbZKPwUBYN79rFUb8", "name": "get_weather", "input": {"location": "San Francisco, CA", "units": "f"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 656, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 74, "service_tier": "standard"}}', + '{"model": "claude-haiku-4-5-20251001", "id": "msg_014x2Sxq2p6sewFyUbJp8Mg3", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "The weather in San Francisco, CA is currently **68\\u00b0F** and **Sunny**. It\'s a nice day! \\u2600\\ufe0f"}], "stop_reason": "end_turn", "stop_sequence": null, "usage": {"input_tokens": 770, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 33, "service_tier": "standard"}}', ] ), - "result": snapshot("""\ -BetaMessage( - container=None, - content=[ - BetaTextBlock( - citations=None, - text='The weather in San Francisco is currently sunny with a temperature of 20°C (68°F).', - type='text' - ) - ], - context_management=None, - id='msg_01SUujjdE6BMF3CYWCTR4vHF', - model='claude-3-5-sonnet-20241022', - role='assistant', - stop_reason='end_turn', - stop_sequence=None, - type='message', - usage=BetaUsage( - cache_creation=BetaCacheCreation(ephemeral_1h_input_tokens=0, ephemeral_5m_input_tokens=0), - cache_creation_input_tokens=0, - cache_read_input_tokens=0, - input_tokens=760, - output_tokens=25, - server_tool_use=None, - service_tier='standard' - ) -) -"""), + "result": snapshot( + "ParsedBetaMessage(container=None, content=[ParsedBetaTextBlock(citations=None, parsed_output=None, text=\"The weather in San Francisco, CA is currently **68°F** and **Sunny**. It's a nice day! ☀️\", type='text')], context_management=None, id='msg_014x2Sxq2p6sewFyUbJp8Mg3', model='claude-haiku-4-5-20251001', role='assistant', stop_reason='end_turn', stop_sequence=None, type='message', usage=BetaUsage(cache_creation=BetaCacheCreation(ephemeral_1h_input_tokens=0, ephemeral_5m_input_tokens=0), cache_creation_input_tokens=0, cache_read_input_tokens=0, input_tokens=770, output_tokens=33, server_tool_use=None, service_tier='standard'))\n" + ), }, "custom": { "responses": snapshot( [ - '{"id": "msg_01QebvpjSMHnjRVYDQpthDCM", "type": "message", "role": "assistant", "model": "claude-3-5-sonnet-20241022", "content": [{"type": "text", "text": "I\'ll help you check the weather in San Francisco using the get_weather function. Since you want it in Celsius, I\'ll use \'c\' for the units."}, {"type": "tool_use", "id": "toolu_01W8QFaZz5X8w6UezBfvJaHG", "name": "get_weather", "input": {"location": "San Francisco, CA", "units": "c"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 476, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 110, "service_tier": "standard"}}', - '{"id": "msg_01GQD2QBjkCMtD8rEfbF7J7y", "type": "message", "role": "assistant", "model": "claude-3-5-sonnet-20241022", "content": [{"type": "text", "text": "The weather in San Francisco is currently 20\\u00b0C and it\'s sunny."}], "stop_reason": "end_turn", "stop_sequence": null, "usage": {"input_tokens": 625, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 20, "service_tier": "standard"}}', + '{"model": "claude-haiku-4-5-20251001", "id": "msg_01FKEKbzbqHmJv5ozwH7tz99", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Let me check the weather for San Francisco for you in Celsius."}, {"type": "tool_use", "id": "toolu_01MxFFv4azdWzubHT3dXurMY", "name": "get_weather", "input": {"location": "San Francisco, CA", "units": "c"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 659, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 88, "service_tier": "standard"}}', + '{"model": "claude-haiku-4-5-20251001", "id": "msg_01DSPL7PHKQYTe9VAFkHzsA3", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "The weather in San Francisco, CA is currently **20\\u00b0C** and **Sunny**. Nice weather!"}], "stop_reason": "end_turn", "stop_sequence": null, "usage": {"input_tokens": 787, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 26, "service_tier": "standard"}}', ] ), - "result": snapshot("""\ -BetaMessage( - container=None, - content=[ - BetaTextBlock( - citations=None, - text="The weather in San Francisco is currently 20°C and it's sunny.", - type='text' - ) - ], - context_management=None, - id='msg_01GQD2QBjkCMtD8rEfbF7J7y', - model='claude-3-5-sonnet-20241022', - role='assistant', - stop_reason='end_turn', - stop_sequence=None, - type='message', - usage=BetaUsage( - cache_creation=BetaCacheCreation(ephemeral_1h_input_tokens=0, ephemeral_5m_input_tokens=0), - cache_creation_input_tokens=0, - cache_read_input_tokens=0, - input_tokens=625, - output_tokens=20, - server_tool_use=None, - service_tier='standard' - ) -) -"""), + "result": snapshot( + "ParsedBetaMessage(container=None, content=[ParsedBetaTextBlock(citations=None, parsed_output=None, text='The weather in San Francisco, CA is currently **20°C** and **Sunny**. Nice weather!', type='text')], context_management=None, id='msg_01DSPL7PHKQYTe9VAFkHzsA3', model='claude-haiku-4-5-20251001', role='assistant', stop_reason='end_turn', stop_sequence=None, type='message', usage=BetaUsage(cache_creation=BetaCacheCreation(ephemeral_1h_input_tokens=0, ephemeral_5m_input_tokens=0), cache_creation_input_tokens=0, cache_read_input_tokens=0, input_tokens=787, output_tokens=26, server_tool_use=None, service_tier='standard'))\n" + ), }, "streaming": { - "result": snapshot("""\ -BetaMessage( - container=None, - content=[ - BetaTextBlock(citations=None, text='The weather in San Francisco is currently 68°F and sunny.', type='text') - ], - context_management=None, - id='msg_01FtWrpBLsm99NpQCoFrhuf9', - model='claude-3-5-sonnet-20241022', - role='assistant', - stop_reason='end_turn', - stop_sequence=None, - type='message', - usage=BetaUsage( - cache_creation=BetaCacheCreation(ephemeral_1h_input_tokens=0, ephemeral_5m_input_tokens=0), - cache_creation_input_tokens=0, - cache_read_input_tokens=0, - input_tokens=605, - output_tokens=18, - server_tool_use=None, - service_tier='standard' - ) -) -""") + "result": snapshot( + "ParsedBetaMessage(container=None, content=[ParsedBetaTextBlock(citations=None, parsed_output=None, text='The weather in San Francisco, CA is currently **Sunny** with a temperature of **68°F**.', type='text')], context_management=None, id='msg_01Vm8Ddgc8qm4iuUSKbf6jku', model='claude-haiku-4-5-20251001', role='assistant', stop_reason='end_turn', stop_sequence=None, type='message', usage=BetaUsage(cache_creation=BetaCacheCreation(ephemeral_1h_input_tokens=0, ephemeral_5m_input_tokens=0), cache_creation_input_tokens=0, cache_read_input_tokens=0, input_tokens=781, output_tokens=25, server_tool_use=None, service_tier='standard'))\n" + ) }, "tool_call": { "responses": snapshot( [ - '{"id": "msg_01CcxTJKA7URvATmjs9yemNw", "type": "message", "role": "assistant", "model": "claude-3-5-sonnet-20241022", "content": [{"type": "text", "text": "I\'ll help you check the weather in San Francisco using Celsius units."}, {"type": "tool_use", "id": "toolu_01X4rAg6afq9WTkdXDwNdo9g", "name": "get_weather", "input": {"location": "SF", "units": "c"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 414, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 86, "service_tier": "standard"}}', - '{"id": "msg_01Hswpqi8rjN9k6Erfof4NML", "type": "message", "role": "assistant", "model": "claude-3-5-sonnet-20241022", "content": [{"type": "text", "text": "The weather in San Francisco is currently 20\\u00b0C and it\'s sunny."}], "stop_reason": "end_turn", "stop_sequence": null, "usage": {"input_tokens": 536, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 20, "service_tier": "standard"}}', + '{"model": "claude-haiku-4-5-20251001", "id": "msg_01NzLkujbJ7VQgzNHFx76Ab4", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01SPe52JjANtJDVJ5yUZj4jz", "name": "get_weather", "input": {"location": "SF", "units": "c"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 597, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 71, "service_tier": "standard"}}', + '{"model": "claude-haiku-4-5-20251001", "id": "msg_016bjf5SAczxp28ES4yX7Z7U", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "The weather in SF (San Francisco) is currently **20\\u00b0C** and **sunny**!"}], "stop_reason": "end_turn", "stop_sequence": null, "usage": {"input_tokens": 705, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 23, "service_tier": "standard"}}', ] ), }, "tool_call_error": { "responses": snapshot( [ - '{"id": "msg_01UCU1h4ayreA2Ridzbpk5ut", "type": "message", "role": "assistant", "model": "claude-3-5-sonnet-20241022", "content": [{"type": "text", "text": "I\'ll help you check the weather in San Francisco. Since the location format should include the state, I\'ll use \\"San Francisco, CA\\". I\'ll provide the temperature in both Celsius and Fahrenheit for completeness."}, {"type": "tool_use", "id": "toolu_01ECouLXJaT6yocMNDstufPc", "name": "get_weather", "input": {"location": "San Francisco, CA", "units": "c"}}, {"type": "tool_use", "id": "toolu_01FHQTcVXvPoLL3bzxsAUtJJ", "name": "get_weather", "input": {"location": "San Francisco, CA", "units": "f"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 473, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 176, "service_tier": "standard"}}', - '{"id": "msg_01PYwhqAdduuZYymTokQ4JQU", "type": "message", "role": "assistant", "model": "claude-3-5-sonnet-20241022", "content": [{"type": "text", "text": "The weather in San Francisco, CA is currently sunny with a temperature of 68\\u00b0F."}], "stop_reason": "end_turn", "stop_sequence": null, "usage": {"input_tokens": 749, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 23, "service_tier": "standard"}}', + '{"model": "claude-haiku-4-5-20251001", "id": "msg_01QhmJFoA3mxD2mxPFnjLHrT", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01Do4cDVNxt51EuosKoxdmii", "name": "get_weather", "input": {"location": "San Francisco, CA", "units": "f"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 656, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 74, "service_tier": "standard"}}', + '{"model": "claude-haiku-4-5-20251001", "id": "msg_0137FupJYD4A3Mc6jUUxKpU6", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "I apologize, but I encountered an error when trying to fetch the weather for San Francisco. This appears to be a temporary issue with the weather service. Could you please try again in a moment, or let me know if you\'d like me to attempt the lookup again?"}], "stop_reason": "end_turn", "stop_sequence": null, "usage": {"input_tokens": 760, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 58, "service_tier": "standard"}}', ] ) }, @@ -164,7 +93,7 @@ def get_weather(location: str, units: Literal["c", "f"]) -> BetaFunctionToolResu message = make_snapshot_request( lambda c: c.beta.messages.tool_runner( max_tokens=1024, - model="claude-3-5-sonnet-latest", + model="claude-haiku-4-5", tools=[get_weather], messages=[{"role": "user", "content": "What is the weather in SF?"}], ).until_done(), @@ -206,7 +135,7 @@ def get_weather(location: str, units: Literal["c", "f"]) -> BetaFunctionToolResu def tool_runner(client: Anthropic) -> List[Union[BetaMessageParam, None]]: runner = client.beta.messages.tool_runner( max_tokens=1024, - model="claude-3-5-sonnet-latest", + model="claude-haiku-4-5", tools=[get_weather], messages=[{"role": "user", "content": "What is the weather in SF?"}], ) @@ -235,26 +164,9 @@ def tool_runner(client: Anthropic) -> List[Union[BetaMessageParam, None]]: "Error occurred while calling tool: get_weather", ), ] - assert print_obj(message, monkeypatch) == snapshot("""\ -[ - { - 'role': 'user', - 'content': [ - { - 'type': 'tool_result', - 'tool_use_id': 'toolu_01ECouLXJaT6yocMNDstufPc', - 'content': "RuntimeError('Unexpected error, try again')", - 'is_error': True - }, - { - 'type': 'tool_result', - 'tool_use_id': 'toolu_01FHQTcVXvPoLL3bzxsAUtJJ', - 'content': '{"location": "San Francisco, CA", "temperature": "68\\\\u00b0F", "condition": "Sunny"}' - } - ] - } -] -""") + assert print_obj(message, monkeypatch) == snapshot( + "[{'role': 'user', 'content': [{'type': 'tool_result', 'tool_use_id': 'toolu_01Do4cDVNxt51EuosKoxdmii', 'content': \"RuntimeError('Unexpected error, try again')\", 'is_error': True}]}]\n" + ) @pytest.mark.respx(base_url=base_url) def test_custom_message_handling( @@ -274,7 +186,7 @@ def get_weather(location: str, units: Literal["c", "f"]) -> BetaFunctionToolResu def custom_message_handling(client: Anthropic) -> BetaMessage: runner = client.beta.messages.tool_runner( - model="claude-3-5-sonnet-latest", + model="claude-haiku-4-5", messages=[{"role": "user", "content": "What's the weather in SF in Celsius?"}], tools=[get_weather], max_tokens=1024, @@ -330,7 +242,7 @@ def get_weather(location: str, units: Literal["c", "f"]) -> BetaFunctionToolResu def tool_runner(client: Anthropic) -> None: runner = client.beta.messages.tool_runner( - model="claude-3-5-sonnet-latest", + model="claude-haiku-4-5", messages=[{"role": "user", "content": "What's the weather in SF in Celsius?"}], tools=[get_weather], max_tokens=1024, @@ -370,12 +282,12 @@ def get_weather(location: str, units: Literal["c", "f"]) -> BetaFunctionToolResu last_response_messsage = make_stream_snapshot_request( lambda c: c.beta.messages.tool_runner( max_tokens=1024, - model="claude-3-5-sonnet-latest", + model="claude-haiku-4-5", tools=[get_weather], messages=[{"role": "user", "content": "What is the weather in SF?"}], stream=True, ).until_done(), - content_snapshot=snapshot(external("uuid:e2140c0f-07db-47ee-b86b-c2ec476866d5.json")), + content_snapshot=snapshot(external("hash:cd8d3d185e7a*.json")), path="/v1/messages", mock_client=client, respx_mock=respx_mock, @@ -400,7 +312,7 @@ def get_weather(location: str, units: Literal["c", "f"]) -> BetaFunctionToolResu def get_weather_answers(client: Anthropic) -> List[Union[BetaMessageParam, None]]: runner = client.beta.messages.tool_runner( max_tokens=1024, - model="claude-3-5-sonnet-latest", + model="claude-haiku-4-5", tools=[get_weather], messages=[ { @@ -424,36 +336,20 @@ def get_weather_answers(client: Anthropic) -> List[Union[BetaMessageParam, None] answers = make_snapshot_request( get_weather_answers, - content_snapshot=snapshot(external("uuid:ef758469-6fa6-454c-b2e6-19d0b450a8c5.json")), + content_snapshot=snapshot( + [ + '{"model": "claude-haiku-4-5-20251001", "id": "msg_017GvdrboNn8hipoMJUcK8m6", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "I\'ll get the weather for each of these cities one at a time. Let me start with San Francisco."}, {"type": "tool_use", "id": "toolu_011Q6hjHnpWegJvV1Zn6Cm1h", "name": "get_weather", "input": {"location": "San Francisco, CA", "units": "f"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 701, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 96, "service_tier": "standard"}}', + '{"model": "claude-haiku-4-5-20251001", "id": "msg_01PYFQH4AkK3NBgSpFkWD16q", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Now let me check New York."}, {"type": "tool_use", "id": "toolu_011QaaAuMeNWTwHjkxcxce1D", "name": "get_weather", "input": {"location": "New York, NY", "units": "f"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 837, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 81, "service_tier": "standard"}}', + ] + ), path="/v1/messages", mock_client=client, respx_mock=respx_mock, ) - assert print_obj(answers, monkeypatch) == snapshot("""\ -[ - { - 'role': 'user', - 'content': [ - { - 'type': 'tool_result', - 'tool_use_id': 'toolu_01GiQJzt5d2ThB4fSUsRCSML', - 'content': '{"location": "San Francisco, CA", "temperature": "68\\\\u00b0F", "condition": "Sunny"}' - } - ] - }, - { - 'role': 'user', - 'content': [ - { - 'type': 'tool_result', - 'tool_use_id': 'toolu_015yzRQ92SwYGz5Veoq7A3P7', - 'content': '{"location": "New York, NY", "temperature": "68\\\\u00b0F", "condition": "Sunny"}' - } - ] - } -] -""") + assert print_obj(answers, monkeypatch) == snapshot( + "[{'role': 'user', 'content': [{'type': 'tool_result', 'tool_use_id': 'toolu_011Q6hjHnpWegJvV1Zn6Cm1h', 'content': '{\"location\": \"San Francisco, CA\", \"temperature\": \"68\\\\u00b0F\", \"condition\": \"Sunny\"}'}]}, {'role': 'user', 'content': [{'type': 'tool_result', 'tool_use_id': 'toolu_011QaaAuMeNWTwHjkxcxce1D', 'content': '{\"location\": \"New York, NY\", \"temperature\": \"68\\\\u00b0F\", \"condition\": \"Sunny\"}'}]}]\n" + ) @pytest.mark.respx(base_url=base_url) def test_streaming_call_sync_events(self, client: Anthropic, respx_mock: MockRouter) -> None: @@ -473,7 +369,7 @@ def accumulate_events(client: Anthropic) -> List[str]: events: list[str] = [] runner = client.beta.messages.tool_runner( max_tokens=1024, - model="claude-3-5-sonnet-latest", + model="claude-haiku-4-5", tools=[get_weather], messages=[{"role": "user", "content": "What is the weather in SF?"}], stream=True, @@ -525,7 +421,7 @@ async def get_weather(location: str, units: Literal["c", "f"]) -> BetaFunctionTo message = await make_async_snapshot_request( lambda c: c.beta.messages.tool_runner( max_tokens=1024, - model="claude-3-5-sonnet-latest", + model="claude-3-7", tools=[get_weather], messages=[{"role": "user", "content": "What is the weather in SF?"}], ).until_done(), @@ -557,7 +453,7 @@ def _get_weather(location: str, units: Literal["c", "f"]) -> Dict[str, Any]: @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) -def test_parse_method_in_sync(sync: bool, client: Anthropic, async_client: AsyncAnthropic) -> None: +def test_tool_runner_method_in_sync(sync: bool, client: Anthropic, async_client: AsyncAnthropic) -> None: checking_client: "Anthropic | AsyncAnthropic" = client if sync else async_client assert_signatures_in_sync( @@ -565,6 +461,7 @@ def test_parse_method_in_sync(sync: bool, client: Anthropic, async_client: Async checking_client.beta.messages.tool_runner, exclude_params={ "tools", + "output_format", # TODO "stream", }, diff --git a/tests/lib/utils.py b/tests/lib/utils.py index 215a96a96..d6093dd35 100644 --- a/tests/lib/utils.py +++ b/tests/lib/utils.py @@ -23,8 +23,14 @@ def print_obj(obj: object, monkeypatch: pytest.MonkeyPatch) -> str: def __repr_args__(self: pydantic.BaseModel) -> ReprArgs: return sorted(original_repr(self), key=lambda arg: arg[0] or arg) + def __repr_name__(self: pydantic.BaseModel) -> str: + # Drop generic parameters from the name + # e.g. `GenericModel[Location]` -> `GenericModel` + return self.__class__.__name__.split("[", maxsplit=1)[0] + with monkeypatch.context() as m: m.setattr(pydantic.BaseModel, "__repr_args__", __repr_args__) + m.setattr(pydantic.BaseModel, "__repr_name__", __repr_name__) string = rich_print_str(obj) @@ -54,23 +60,11 @@ def clear_locals(string: str, *, stacklevel: int) -> str: return string.replace(f"{caller}..", "") -def get_snapshot_value(snapshot: Any) -> Any: - if not hasattr(snapshot, "_old_value"): - return snapshot - - old = snapshot._old_value - if not hasattr(old, "value"): - return old - - loader = getattr(old.value, "_load_value", None) - return loader() if loader else old.value - - def rich_print_str(obj: object) -> str: """Like `rich.print()` but returns the string instead""" buf = io.StringIO() console = rich.console.Console(file=buf, width=120) - console.print(obj) + console.out(obj) return buf.getvalue() diff --git a/uv.lock b/uv.lock index 6626965a5..2ae12615a 100644 --- a/uv.lock +++ b/uv.lock @@ -146,7 +146,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.72.0" +version = "0.72.1" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -1281,9 +1281,9 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, + { name = "annotated-types", marker = "extra == 'group-9-anthropic-pydantic-v2' or extra != 'group-9-anthropic-pydantic-v1'" }, + { name = "pydantic-core", marker = "extra == 'group-9-anthropic-pydantic-v2' or extra != 'group-9-anthropic-pydantic-v1'" }, + { name = "typing-extensions", marker = "extra == 'group-9-anthropic-pydantic-v2' or extra != 'group-9-anthropic-pydantic-v1'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/45/0f/27908242621b14e649a84e62b133de45f84c255eecb350ab02979844a788/pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9", size = 786486, upload-time = "2024-12-03T15:59:02.347Z" } wheels = [ @@ -1295,7 +1295,7 @@ name = "pydantic-core" version = "2.27.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "extra == 'group-9-anthropic-pydantic-v2' or extra != 'group-9-anthropic-pydantic-v1'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/9f/7de1f19b6aea45aeb441838782d68352e71bfa98ee6fa048d5041991b33e/pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", size = 412785, upload-time = "2024-11-22T00:24:49.865Z" } wheels = [