coco-framework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coco/__init__.py +3 -0
- coco/agents/__init__.py +9 -0
- coco/agents/_subagent_transformer.py +307 -0
- coco/agents/factory.py +2164 -0
- coco/agents/middleware/__init__.py +100 -0
- coco/agents/middleware/_execution.py +385 -0
- coco/agents/middleware/_redaction.py +454 -0
- coco/agents/middleware/_retry.py +134 -0
- coco/agents/middleware/_trace_policy.py +61 -0
- coco/agents/middleware/context_editing.py +306 -0
- coco/agents/middleware/file_search.py +433 -0
- coco/agents/middleware/human_in_the_loop.py +680 -0
- coco/agents/middleware/internal_call_transformer.py +108 -0
- coco/agents/middleware/model_call_limit.py +267 -0
- coco/agents/middleware/model_fallback.py +417 -0
- coco/agents/middleware/model_retry.py +327 -0
- coco/agents/middleware/pii.py +878 -0
- coco/agents/middleware/provider_tool_search.py +307 -0
- coco/agents/middleware/shell_tool.py +949 -0
- coco/agents/middleware/summarization.py +910 -0
- coco/agents/middleware/todo.py +357 -0
- coco/agents/middleware/tool_call_limit.py +495 -0
- coco/agents/middleware/tool_emulator.py +251 -0
- coco/agents/middleware/tool_error.py +209 -0
- coco/agents/middleware/tool_retry.py +421 -0
- coco/agents/middleware/tool_selection.py +515 -0
- coco/agents/middleware/types.py +2201 -0
- coco/agents/structured_output.py +472 -0
- coco/chat_models/__init__.py +7 -0
- coco/chat_models/base.py +1082 -0
- coco/embeddings/__init__.py +18 -0
- coco/embeddings/base.py +279 -0
- coco/mcp/__init__.py +36 -0
- coco/mcp/adapter.py +245 -0
- coco/mcp/elicitation.py +394 -0
- coco/mcp/tools.py +307 -0
- coco/messages/__init__.py +73 -0
- coco/py.typed +0 -0
- coco/rate_limiters/__init__.py +13 -0
- coco/tools/__init__.py +22 -0
- coco/tools/tool_node.py +20 -0
- coco_anthropic/__init__.py +15 -0
- coco_anthropic/_client_utils.py +81 -0
- coco_anthropic/_compat.py +228 -0
- coco_anthropic/_sdk_compat.py +124 -0
- coco_anthropic/_version.py +3 -0
- coco_anthropic/chat_models.py +3349 -0
- coco_anthropic/data/__init__.py +1 -0
- coco_anthropic/data/_profiles.py +491 -0
- coco_anthropic/data/profile_augmentations.toml +69 -0
- coco_anthropic/experimental.py +140 -0
- coco_anthropic/llms.py +428 -0
- coco_anthropic/middleware/__init__.py +25 -0
- coco_anthropic/middleware/anthropic_tools.py +1197 -0
- coco_anthropic/middleware/bash.py +84 -0
- coco_anthropic/middleware/file_search.py +360 -0
- coco_anthropic/middleware/prompt_caching.py +276 -0
- coco_anthropic/output_parsers.py +93 -0
- coco_anthropic/py.typed +0 -0
- coco_core/__init__.py +20 -0
- coco_core/_api/__init__.py +87 -0
- coco_core/_api/beta_decorator.py +266 -0
- coco_core/_api/deprecation.py +637 -0
- coco_core/_api/internal.py +23 -0
- coco_core/_api/path.py +50 -0
- coco_core/_import_utils.py +41 -0
- coco_core/_protocol.py +625 -0
- coco_core/_security/__init__.py +36 -0
- coco_core/_security/_exceptions.py +9 -0
- coco_core/_security/_policy.py +313 -0
- coco_core/_security/_ssrf_protection.py +155 -0
- coco_core/_security/_transport.py +254 -0
- coco_core/agents.py +258 -0
- coco_core/caches.py +272 -0
- coco_core/callbacks/__init__.py +132 -0
- coco_core/callbacks/base.py +1229 -0
- coco_core/callbacks/file.py +266 -0
- coco_core/callbacks/manager.py +2826 -0
- coco_core/callbacks/stdout.py +123 -0
- coco_core/callbacks/streaming_stdout.py +154 -0
- coco_core/callbacks/usage.py +124 -0
- coco_core/chat_history.py +267 -0
- coco_core/chat_loaders.py +26 -0
- coco_core/chat_sessions.py +19 -0
- coco_core/cross_encoders.py +18 -0
- coco_core/document_loaders/__init__.py +36 -0
- coco_core/document_loaders/base.py +155 -0
- coco_core/document_loaders/blob_loaders.py +38 -0
- coco_core/documents/__init__.py +55 -0
- coco_core/documents/base.py +347 -0
- coco_core/documents/compressor.py +74 -0
- coco_core/documents/transformers.py +79 -0
- coco_core/embeddings/__init__.py +31 -0
- coco_core/embeddings/embeddings.py +78 -0
- coco_core/embeddings/fake.py +129 -0
- coco_core/env.py +22 -0
- coco_core/example_selectors/__init__.py +47 -0
- coco_core/example_selectors/base.py +60 -0
- coco_core/example_selectors/length_based.py +131 -0
- coco_core/example_selectors/semantic_similarity.py +366 -0
- coco_core/exceptions.py +166 -0
- coco_core/globals.py +72 -0
- coco_core/indexing/__init__.py +53 -0
- coco_core/indexing/api.py +954 -0
- coco_core/indexing/base.py +661 -0
- coco_core/indexing/in_memory.py +104 -0
- coco_core/language_models/__init__.py +116 -0
- coco_core/language_models/_compat_bridge.py +844 -0
- coco_core/language_models/_utils.py +343 -0
- coco_core/language_models/base.py +500 -0
- coco_core/language_models/chat_model_stream.py +1525 -0
- coco_core/language_models/chat_models.py +2748 -0
- coco_core/language_models/fake.py +137 -0
- coco_core/language_models/fake_chat_models.py +396 -0
- coco_core/language_models/llms.py +1569 -0
- coco_core/language_models/model_profile.py +183 -0
- coco_core/load/__init__.py +44 -0
- coco_core/load/_validation.py +191 -0
- coco_core/load/dump.py +120 -0
- coco_core/load/load.py +828 -0
- coco_core/load/mapping.py +1097 -0
- coco_core/load/serializable.py +406 -0
- coco_core/messages/__init__.py +198 -0
- coco_core/messages/ai.py +854 -0
- coco_core/messages/base.py +518 -0
- coco_core/messages/block_translators/__init__.py +112 -0
- coco_core/messages/block_translators/anthropic.py +534 -0
- coco_core/messages/block_translators/bedrock.py +108 -0
- coco_core/messages/block_translators/bedrock_converse.py +329 -0
- coco_core/messages/block_translators/coco_v0.py +303 -0
- coco_core/messages/block_translators/google_genai.py +610 -0
- coco_core/messages/block_translators/google_vertexai.py +21 -0
- coco_core/messages/block_translators/groq.py +157 -0
- coco_core/messages/block_translators/openai.py +1089 -0
- coco_core/messages/chat.py +64 -0
- coco_core/messages/content.py +1488 -0
- coco_core/messages/function.py +62 -0
- coco_core/messages/human.py +70 -0
- coco_core/messages/modifier.py +33 -0
- coco_core/messages/system.py +70 -0
- coco_core/messages/tool.py +418 -0
- coco_core/messages/utils.py +2442 -0
- coco_core/output_parsers/__init__.py +101 -0
- coco_core/output_parsers/base.py +361 -0
- coco_core/output_parsers/format_instructions.py +16 -0
- coco_core/output_parsers/json.py +139 -0
- coco_core/output_parsers/list.py +249 -0
- coco_core/output_parsers/openai_functions.py +324 -0
- coco_core/output_parsers/openai_tools.py +383 -0
- coco_core/output_parsers/pydantic.py +141 -0
- coco_core/output_parsers/string.py +63 -0
- coco_core/output_parsers/transform.py +175 -0
- coco_core/output_parsers/xml.py +301 -0
- coco_core/outputs/__init__.py +66 -0
- coco_core/outputs/chat_generation.py +147 -0
- coco_core/outputs/chat_result.py +39 -0
- coco_core/outputs/generation.py +80 -0
- coco_core/outputs/llm_result.py +112 -0
- coco_core/outputs/run_info.py +22 -0
- coco_core/prompt_values.py +161 -0
- coco_core/prompts/__init__.py +101 -0
- coco_core/prompts/base.py +508 -0
- coco_core/prompts/chat.py +1495 -0
- coco_core/prompts/dict.py +177 -0
- coco_core/prompts/few_shot.py +483 -0
- coco_core/prompts/few_shot_with_templates.py +239 -0
- coco_core/prompts/image.py +177 -0
- coco_core/prompts/loading.py +296 -0
- coco_core/prompts/message.py +97 -0
- coco_core/prompts/prompt.py +312 -0
- coco_core/prompts/string.py +399 -0
- coco_core/prompts/structured.py +214 -0
- coco_core/py.typed +0 -0
- coco_core/rate_limiters.py +254 -0
- coco_core/retrievers.py +328 -0
- coco_core/runnables/__init__.py +136 -0
- coco_core/runnables/base.py +6725 -0
- coco_core/runnables/branch.py +457 -0
- coco_core/runnables/config.py +712 -0
- coco_core/runnables/configurable.py +710 -0
- coco_core/runnables/fallbacks.py +663 -0
- coco_core/runnables/graph.py +741 -0
- coco_core/runnables/graph_ascii.py +366 -0
- coco_core/runnables/graph_mermaid.py +503 -0
- coco_core/runnables/graph_png.py +215 -0
- coco_core/runnables/history.py +631 -0
- coco_core/runnables/passthrough.py +849 -0
- coco_core/runnables/retry.py +379 -0
- coco_core/runnables/router.py +239 -0
- coco_core/runnables/schema.py +188 -0
- coco_core/runnables/utils.py +798 -0
- coco_core/stores.py +291 -0
- coco_core/structured_query.py +203 -0
- coco_core/sys_info.py +154 -0
- coco_core/tools/__init__.py +95 -0
- coco_core/tools/base.py +1948 -0
- coco_core/tools/convert.py +497 -0
- coco_core/tools/render.py +67 -0
- coco_core/tools/retriever.py +94 -0
- coco_core/tools/simple.py +208 -0
- coco_core/tools/structured.py +322 -0
- coco_core/tracers/__init__.py +47 -0
- coco_core/tracers/_compat.py +95 -0
- coco_core/tracers/_context.py +337 -0
- coco_core/tracers/_streaming.py +53 -0
- coco_core/tracers/base.py +955 -0
- coco_core/tracers/coco.py +279 -0
- coco_core/tracers/context.py +205 -0
- coco_core/tracers/core.py +766 -0
- coco_core/tracers/event_stream.py +1101 -0
- coco_core/tracers/log_stream.py +765 -0
- coco_core/tracers/memory_stream.py +200 -0
- coco_core/tracers/root_listeners.py +130 -0
- coco_core/tracers/run_collector.py +40 -0
- coco_core/tracers/schemas.py +14 -0
- coco_core/tracers/stdout.py +205 -0
- coco_core/utils/__init__.py +111 -0
- coco_core/utils/_gateway.py +274 -0
- coco_core/utils/_merge.py +218 -0
- coco_core/utils/aiter.py +357 -0
- coco_core/utils/env.py +86 -0
- coco_core/utils/formatting.py +80 -0
- coco_core/utils/function_calling.py +869 -0
- coco_core/utils/html.py +132 -0
- coco_core/utils/image.py +15 -0
- coco_core/utils/input.py +82 -0
- coco_core/utils/interactive_env.py +12 -0
- coco_core/utils/iter.py +229 -0
- coco_core/utils/json.py +230 -0
- coco_core/utils/json_schema.py +273 -0
- coco_core/utils/mustache.py +706 -0
- coco_core/utils/pydantic.py +631 -0
- coco_core/utils/strings.py +69 -0
- coco_core/utils/usage.py +61 -0
- coco_core/utils/utils.py +528 -0
- coco_core/utils/uuid.py +57 -0
- coco_core/vectorstores/__init__.py +53 -0
- coco_core/vectorstores/base.py +1111 -0
- coco_core/vectorstores/in_memory.py +546 -0
- coco_core/vectorstores/utils.py +163 -0
- coco_core/version.py +3 -0
- coco_framework-0.1.0.dist-info/METADATA +184 -0
- coco_framework-0.1.0.dist-info/RECORD +400 -0
- coco_framework-0.1.0.dist-info/WHEEL +4 -0
- coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
- coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
- coco_graph/_internal/__init__.py +4 -0
- coco_graph/_internal/_cache.py +31 -0
- coco_graph/_internal/_config.py +474 -0
- coco_graph/_internal/_constants.py +140 -0
- coco_graph/_internal/_fields.py +213 -0
- coco_graph/_internal/_future.py +220 -0
- coco_graph/_internal/_pydantic.py +275 -0
- coco_graph/_internal/_queue.py +124 -0
- coco_graph/_internal/_replay.py +90 -0
- coco_graph/_internal/_retry.py +29 -0
- coco_graph/_internal/_runnable.py +969 -0
- coco_graph/_internal/_scratchpad.py +19 -0
- coco_graph/_internal/_serde.py +253 -0
- coco_graph/_internal/_timeout.py +25 -0
- coco_graph/_internal/_typing.py +54 -0
- coco_graph/cache/base/__init__.py +48 -0
- coco_graph/cache/base/py.typed +0 -0
- coco_graph/cache/memory/__init__.py +73 -0
- coco_graph/cache/redis/__init__.py +144 -0
- coco_graph/cache/sqlite/__init__.py +120 -0
- coco_graph/callbacks.py +394 -0
- coco_graph/channels/__init__.py +29 -0
- coco_graph/channels/any_value.py +72 -0
- coco_graph/channels/base.py +121 -0
- coco_graph/channels/binop.py +155 -0
- coco_graph/channels/delta.py +202 -0
- coco_graph/channels/ephemeral_value.py +79 -0
- coco_graph/channels/last_value.py +151 -0
- coco_graph/channels/named_barrier_value.py +167 -0
- coco_graph/channels/topic.py +94 -0
- coco_graph/channels/untracked_value.py +73 -0
- coco_graph/checkpoint/base/__init__.py +861 -0
- coco_graph/checkpoint/base/id.py +109 -0
- coco_graph/checkpoint/base/py.typed +0 -0
- coco_graph/checkpoint/memory/__init__.py +698 -0
- coco_graph/checkpoint/memory/py.typed +0 -0
- coco_graph/checkpoint/postgres/__init__.py +602 -0
- coco_graph/checkpoint/postgres/_ainternal.py +23 -0
- coco_graph/checkpoint/postgres/_internal.py +21 -0
- coco_graph/checkpoint/postgres/aio.py +691 -0
- coco_graph/checkpoint/postgres/base.py +666 -0
- coco_graph/checkpoint/postgres/py.typed +0 -0
- coco_graph/checkpoint/postgres/shallow.py +967 -0
- coco_graph/checkpoint/serde/__init__.py +0 -0
- coco_graph/checkpoint/serde/_msgpack.py +97 -0
- coco_graph/checkpoint/serde/base.py +64 -0
- coco_graph/checkpoint/serde/encrypted.py +80 -0
- coco_graph/checkpoint/serde/event_hooks.py +52 -0
- coco_graph/checkpoint/serde/jsonplus.py +883 -0
- coco_graph/checkpoint/serde/py.typed +0 -0
- coco_graph/checkpoint/serde/types.py +68 -0
- coco_graph/checkpoint/sqlite/__init__.py +645 -0
- coco_graph/checkpoint/sqlite/_delta.py +172 -0
- coco_graph/checkpoint/sqlite/aio.py +738 -0
- coco_graph/checkpoint/sqlite/py.typed +0 -0
- coco_graph/checkpoint/sqlite/utils.py +116 -0
- coco_graph/config.py +196 -0
- coco_graph/constants.py +64 -0
- coco_graph/errors.py +241 -0
- coco_graph/func/__init__.py +620 -0
- coco_graph/graph/__init__.py +12 -0
- coco_graph/graph/_branch.py +225 -0
- coco_graph/graph/_node.py +103 -0
- coco_graph/graph/message.py +437 -0
- coco_graph/graph/state.py +1978 -0
- coco_graph/graph/ui.py +227 -0
- coco_graph/managed/__init__.py +3 -0
- coco_graph/managed/base.py +31 -0
- coco_graph/managed/is_last_step.py +24 -0
- coco_graph/prebuilt/__init__.py +23 -0
- coco_graph/prebuilt/_tool_call_stream.py +117 -0
- coco_graph/prebuilt/_tool_call_transformer.py +165 -0
- coco_graph/prebuilt/chat_agent_executor.py +1015 -0
- coco_graph/prebuilt/interrupt.py +105 -0
- coco_graph/prebuilt/py.typed +0 -0
- coco_graph/prebuilt/tool_node.py +2030 -0
- coco_graph/prebuilt/tool_validator.py +221 -0
- coco_graph/pregel/__init__.py +3 -0
- coco_graph/pregel/_algo.py +1460 -0
- coco_graph/pregel/_call.py +298 -0
- coco_graph/pregel/_checkpoint.py +331 -0
- coco_graph/pregel/_config.py +0 -0
- coco_graph/pregel/_draw.py +294 -0
- coco_graph/pregel/_executor.py +223 -0
- coco_graph/pregel/_io.py +174 -0
- coco_graph/pregel/_log.py +3 -0
- coco_graph/pregel/_loop.py +1988 -0
- coco_graph/pregel/_messages.py +461 -0
- coco_graph/pregel/_read.py +312 -0
- coco_graph/pregel/_retry.py +854 -0
- coco_graph/pregel/_runner.py +941 -0
- coco_graph/pregel/_tools.py +268 -0
- coco_graph/pregel/_utils.py +221 -0
- coco_graph/pregel/_validate.py +120 -0
- coco_graph/pregel/_write.py +192 -0
- coco_graph/pregel/debug.py +302 -0
- coco_graph/pregel/main.py +4364 -0
- coco_graph/pregel/protocol.py +288 -0
- coco_graph/pregel/types.py +38 -0
- coco_graph/py.typed +0 -0
- coco_graph/runtime.py +350 -0
- coco_graph/store/base/__init__.py +1322 -0
- coco_graph/store/base/batch.py +371 -0
- coco_graph/store/base/embed.py +436 -0
- coco_graph/store/base/py.typed +0 -0
- coco_graph/store/memory/__init__.py +592 -0
- coco_graph/store/memory/py.typed +0 -0
- coco_graph/store/postgres/__init__.py +4 -0
- coco_graph/store/postgres/aio.py +592 -0
- coco_graph/store/postgres/base.py +1480 -0
- coco_graph/store/postgres/py.typed +0 -0
- coco_graph/store/sqlite/__init__.py +4 -0
- coco_graph/store/sqlite/aio.py +632 -0
- coco_graph/store/sqlite/base.py +1514 -0
- coco_graph/stream/__init__.py +45 -0
- coco_graph/stream/_convert.py +32 -0
- coco_graph/stream/_mux.py +523 -0
- coco_graph/stream/_types.py +330 -0
- coco_graph/stream/run_stream.py +735 -0
- coco_graph/stream/stream_channel.py +341 -0
- coco_graph/stream/transformers.py +1039 -0
- coco_graph/types.py +1079 -0
- coco_graph/typing.py +48 -0
- coco_graph/utils/__init__.py +1 -0
- coco_graph/utils/config.py +4 -0
- coco_graph/utils/runnable.py +3 -0
- coco_graph/version.py +13 -0
- coco_graph/warnings.py +69 -0
- coco_openai/__init__.py +20 -0
- coco_openai/_compat.py +45 -0
- coco_openai/_version.py +3 -0
- coco_openai/chat_models/__init__.py +6 -0
- coco_openai/chat_models/_client_utils.py +684 -0
- coco_openai/chat_models/_compat.py +525 -0
- coco_openai/chat_models/azure.py +1227 -0
- coco_openai/chat_models/base.py +5654 -0
- coco_openai/chat_models/codex.py +557 -0
- coco_openai/chatgpt_oauth.py +1063 -0
- coco_openai/data/__init__.py +1 -0
- coco_openai/data/_profiles.py +1655 -0
- coco_openai/data/profile_augmentations.toml +127 -0
- coco_openai/embeddings/__init__.py +6 -0
- coco_openai/embeddings/azure.py +240 -0
- coco_openai/embeddings/base.py +820 -0
- coco_openai/llms/__init__.py +6 -0
- coco_openai/llms/azure.py +249 -0
- coco_openai/llms/base.py +919 -0
- coco_openai/middleware/__init__.py +11 -0
- coco_openai/middleware/openai_moderation.py +484 -0
- coco_openai/output_parsers/__init__.py +9 -0
- coco_openai/output_parsers/tools.py +9 -0
- coco_openai/py.typed +0 -0
- coco_openai/tools/__init__.py +5 -0
- coco_openai/tools/custom_tool.py +111 -0
|
@@ -0,0 +1,3349 @@
|
|
|
1
|
+
"""Anthropic chat models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import datetime
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import warnings
|
|
11
|
+
from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
|
|
12
|
+
from functools import cached_property
|
|
13
|
+
from operator import itemgetter
|
|
14
|
+
from typing import Any, Final, Literal, TypeGuard, cast
|
|
15
|
+
|
|
16
|
+
import anthropic
|
|
17
|
+
from coco_core.callbacks import (
|
|
18
|
+
AsyncCallbackManagerForLLMRun,
|
|
19
|
+
CallbackManagerForLLMRun,
|
|
20
|
+
)
|
|
21
|
+
from coco_core.exceptions import (
|
|
22
|
+
ContextOverflowError,
|
|
23
|
+
ModelAPIError,
|
|
24
|
+
ModelAuthenticationError,
|
|
25
|
+
ModelConnectionError,
|
|
26
|
+
ModelInvalidRequestError,
|
|
27
|
+
ModelNotFoundError,
|
|
28
|
+
ModelPermissionDeniedError,
|
|
29
|
+
ModelRateLimitError,
|
|
30
|
+
ModelTimeoutError,
|
|
31
|
+
OutputParserException,
|
|
32
|
+
)
|
|
33
|
+
from coco_core.language_models import (
|
|
34
|
+
LanguageModelInput,
|
|
35
|
+
ModelProfile,
|
|
36
|
+
ModelProfileRegistry,
|
|
37
|
+
)
|
|
38
|
+
from coco_core.language_models.chat_models import BaseChatModel, LangSmithParams
|
|
39
|
+
from coco_core.messages import (
|
|
40
|
+
AIMessage,
|
|
41
|
+
AIMessageChunk,
|
|
42
|
+
BaseMessage,
|
|
43
|
+
HumanMessage,
|
|
44
|
+
SystemMessage,
|
|
45
|
+
ToolCall,
|
|
46
|
+
ToolMessage,
|
|
47
|
+
is_data_content_block,
|
|
48
|
+
)
|
|
49
|
+
from coco_core.messages import content as types
|
|
50
|
+
from coco_core.messages.ai import (
|
|
51
|
+
InputTokenDetails,
|
|
52
|
+
OutputTokenDetails,
|
|
53
|
+
UsageMetadata,
|
|
54
|
+
)
|
|
55
|
+
from coco_core.messages.tool import tool_call_chunk as create_tool_call_chunk
|
|
56
|
+
from coco_core.output_parsers import (
|
|
57
|
+
JsonOutputKeyToolsParser,
|
|
58
|
+
JsonOutputParser,
|
|
59
|
+
PydanticOutputParser,
|
|
60
|
+
PydanticToolsParser,
|
|
61
|
+
)
|
|
62
|
+
from coco_core.output_parsers.base import OutputParserLike
|
|
63
|
+
from coco_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
|
|
64
|
+
from coco_core.runnables import Runnable, RunnableMap, RunnablePassthrough
|
|
65
|
+
from coco_core.tools import BaseTool
|
|
66
|
+
from coco_core.utils import from_env, get_pydantic_field_names
|
|
67
|
+
from coco_core.utils._gateway import (
|
|
68
|
+
GATEWAY_METADATA_RESPONSE_KEY,
|
|
69
|
+
_apply_gateway_config,
|
|
70
|
+
_parse_gateway_metadata,
|
|
71
|
+
)
|
|
72
|
+
from coco_core.utils.function_calling import (
|
|
73
|
+
convert_to_json_schema,
|
|
74
|
+
convert_to_openai_tool,
|
|
75
|
+
)
|
|
76
|
+
from coco_core.utils.pydantic import is_basemodel_subclass
|
|
77
|
+
from coco_core.utils.utils import _build_model_kwargs
|
|
78
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
|
|
79
|
+
from typing_extensions import NotRequired, Self, TypedDict
|
|
80
|
+
|
|
81
|
+
from coco_anthropic import __version__
|
|
82
|
+
from coco_anthropic._client_utils import (
|
|
83
|
+
_get_default_async_httpx_client,
|
|
84
|
+
_get_default_httpx_client,
|
|
85
|
+
)
|
|
86
|
+
from coco_anthropic._compat import (
|
|
87
|
+
_convert_from_v1_to_anthropic,
|
|
88
|
+
_unwrap_non_standard,
|
|
89
|
+
)
|
|
90
|
+
from coco_anthropic._sdk_compat import (
|
|
91
|
+
_aparse,
|
|
92
|
+
_route_unsupported_sampling_params,
|
|
93
|
+
)
|
|
94
|
+
from coco_anthropic.data._profiles import _PROFILES
|
|
95
|
+
from coco_anthropic.output_parsers import extract_tool_calls
|
|
96
|
+
|
|
97
|
+
_message_type_lookups = {
|
|
98
|
+
"human": "user",
|
|
99
|
+
"ai": "assistant",
|
|
100
|
+
"AIMessageChunk": "assistant",
|
|
101
|
+
"HumanMessageChunk": "user",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_MODEL_PROFILES = cast(ModelProfileRegistry, _PROFILES)
|
|
105
|
+
|
|
106
|
+
_USER_AGENT: Final[str] = f"trustcoco-anthropic/{__version__}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _add_gateway_metadata(generation_info: dict[str, Any], raw_response: Any) -> None:
|
|
110
|
+
"""Add parsed LangSmith gateway metadata to `generation_info`, if present.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
generation_info: Generation info to mutate in place.
|
|
114
|
+
raw_response: The raw provider response, or None.
|
|
115
|
+
"""
|
|
116
|
+
headers = getattr(raw_response, "headers", None)
|
|
117
|
+
if headers is None:
|
|
118
|
+
return
|
|
119
|
+
gateway_metadata = _parse_gateway_metadata(headers)
|
|
120
|
+
if gateway_metadata is not None:
|
|
121
|
+
generation_info[GATEWAY_METADATA_RESPONSE_KEY] = gateway_metadata
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _get_default_model_profile(model_name: str) -> ModelProfile:
|
|
125
|
+
"""Get the default profile for a model.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
model_name: The model identifier.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The model profile dictionary, or an empty dict if not found.
|
|
132
|
+
"""
|
|
133
|
+
default = _MODEL_PROFILES.get(model_name)
|
|
134
|
+
if default:
|
|
135
|
+
return default.copy()
|
|
136
|
+
return {}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_FALLBACK_MAX_OUTPUT_TOKENS: Final[int] = 4096
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class AnthropicTool(TypedDict):
|
|
143
|
+
"""Anthropic tool definition for custom (user-defined) tools.
|
|
144
|
+
|
|
145
|
+
Custom tools use `name` and `input_schema` fields to define the tool's
|
|
146
|
+
interface. These are converted from Coco tool formats (functions, Pydantic
|
|
147
|
+
models, `BaseTool` objects) via `convert_to_anthropic_tool`.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
name: str
|
|
151
|
+
|
|
152
|
+
input_schema: dict[str, Any]
|
|
153
|
+
|
|
154
|
+
description: NotRequired[str]
|
|
155
|
+
|
|
156
|
+
strict: NotRequired[bool]
|
|
157
|
+
|
|
158
|
+
cache_control: NotRequired[dict[str, str]]
|
|
159
|
+
|
|
160
|
+
defer_loading: NotRequired[bool]
|
|
161
|
+
|
|
162
|
+
input_examples: NotRequired[list[dict[str, Any]]]
|
|
163
|
+
|
|
164
|
+
allowed_callers: NotRequired[list[str]]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Built-in Tool Support
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# When Anthropic releases new built-in tools, two places may need updating:
|
|
171
|
+
#
|
|
172
|
+
# 1. _TOOL_TYPE_TO_BETA (below) - Add mapping if the tool requires a beta header.
|
|
173
|
+
# Not all tools need this; only add if the API requires a beta header.
|
|
174
|
+
#
|
|
175
|
+
# 2. _is_builtin_tool() - Add the tool type prefix to _BUILTIN_TOOL_PREFIXES.
|
|
176
|
+
# This ensures the tool dict is passed through to the API unchanged (instead
|
|
177
|
+
# of being converted via convert_to_anthropic_tool, which may fail).
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
_TOOL_TYPE_TO_BETA: dict[str, str] = {
|
|
181
|
+
"web_fetch_20250910": "web-fetch-2025-09-10",
|
|
182
|
+
"code_execution_20250522": "code-execution-2025-05-22",
|
|
183
|
+
"mcp_toolset": "mcp-client-2025-11-20",
|
|
184
|
+
"memory_20250818": "context-management-2025-06-27",
|
|
185
|
+
"computer_20250124": "computer-use-2025-01-24",
|
|
186
|
+
"computer_20251124": "computer-use-2025-11-24",
|
|
187
|
+
"tool_search_tool_regex_20251119": "advanced-tool-use-2025-11-20",
|
|
188
|
+
"tool_search_tool_bm25_20251119": "advanced-tool-use-2025-11-20",
|
|
189
|
+
"advisor_20260301": "advisor-tool-2026-03-01",
|
|
190
|
+
}
|
|
191
|
+
"""Mapping of tool type to required beta header.
|
|
192
|
+
|
|
193
|
+
Some tool types require specific beta headers to be enabled.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
_BUILTIN_TOOL_PREFIXES = [
|
|
197
|
+
"text_editor_",
|
|
198
|
+
"computer_",
|
|
199
|
+
"bash_",
|
|
200
|
+
"web_search_",
|
|
201
|
+
"web_fetch_",
|
|
202
|
+
"code_execution_",
|
|
203
|
+
"mcp_toolset",
|
|
204
|
+
"memory_",
|
|
205
|
+
"tool_search_",
|
|
206
|
+
"advisor_",
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
_ANTHROPIC_EXTRA_FIELDS: set[str] = {
|
|
210
|
+
"allowed_callers",
|
|
211
|
+
"cache_control",
|
|
212
|
+
"defer_loading",
|
|
213
|
+
"eager_input_streaming",
|
|
214
|
+
"input_examples",
|
|
215
|
+
}
|
|
216
|
+
"""Valid Anthropic-specific extra fields"""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _is_builtin_tool(tool: Any) -> TypeGuard[dict[str, Any]]:
|
|
220
|
+
"""Check if a tool is a built-in (server-side) Anthropic tool.
|
|
221
|
+
|
|
222
|
+
`tool` must be a `dict` and have a `type` key starting with one of the known
|
|
223
|
+
built-in tool prefixes.
|
|
224
|
+
|
|
225
|
+
[Claude docs](https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview)
|
|
226
|
+
"""
|
|
227
|
+
if not isinstance(tool, dict):
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
tool_type = tool.get("type")
|
|
231
|
+
if not tool_type or not isinstance(tool_type, str):
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
return any(tool_type.startswith(prefix) for prefix in _BUILTIN_TOOL_PREFIXES)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _format_image(url: str) -> dict:
|
|
238
|
+
"""Convert part["image_url"]["url"] strings (OpenAI format) to Anthropic format.
|
|
239
|
+
|
|
240
|
+
{
|
|
241
|
+
"type": "base64",
|
|
242
|
+
"media_type": "image/jpeg",
|
|
243
|
+
"data": "/9j/4AAQSkZJRg...",
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
Or
|
|
247
|
+
|
|
248
|
+
{
|
|
249
|
+
"type": "url",
|
|
250
|
+
"url": "https://example.com/image.jpg",
|
|
251
|
+
}
|
|
252
|
+
"""
|
|
253
|
+
# Base64 encoded image
|
|
254
|
+
base64_regex = r"^data:(?P<media_type>image/.+);base64,(?P<data>.+)$"
|
|
255
|
+
base64_match = re.match(base64_regex, url)
|
|
256
|
+
|
|
257
|
+
if base64_match:
|
|
258
|
+
return {
|
|
259
|
+
"type": "base64",
|
|
260
|
+
"media_type": base64_match.group("media_type"),
|
|
261
|
+
"data": base64_match.group("data"),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
# Url
|
|
265
|
+
url_regex = r"^https?://.*$"
|
|
266
|
+
url_match = re.match(url_regex, url)
|
|
267
|
+
|
|
268
|
+
if url_match:
|
|
269
|
+
return {
|
|
270
|
+
"type": "url",
|
|
271
|
+
"url": url,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
msg = (
|
|
275
|
+
"Malformed url parameter."
|
|
276
|
+
" Must be either an image URL (https://example.com/image.jpg)"
|
|
277
|
+
" or base64 encoded string (data:image/png;base64,'/9j/4AAQSk'...)"
|
|
278
|
+
)
|
|
279
|
+
raise ValueError(
|
|
280
|
+
msg,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
_TOOL_CALL_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
|
|
285
|
+
"""Anthropic requires `tool_use`/`tool_result` IDs to match this pattern."""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _normalize_tool_call_id(tool_call_id: str | None) -> str | None:
|
|
289
|
+
"""Map a tool-call ID to an Anthropic-compatible form if needed.
|
|
290
|
+
|
|
291
|
+
Anthropic rejects `tool_use`/`tool_result` IDs that don't match
|
|
292
|
+
`^[a-zA-Z0-9_-]+$`. IDs minted by other providers can violate this when a
|
|
293
|
+
thread is replayed across providers (e.g. Fireworks/Kimi emits
|
|
294
|
+
`functions.write_todos:0`, whose `.` and `:` are invalid). Valid IDs are
|
|
295
|
+
returned unchanged; invalid ones are hashed deterministically so that a
|
|
296
|
+
rewritten `tool_use.id` and its paired `tool_use_id` resolve to the same
|
|
297
|
+
value, both within a request and across turns.
|
|
298
|
+
|
|
299
|
+
Empty and `None` IDs are passed through unchanged so that a genuinely
|
|
300
|
+
malformed request surfaces as a clear error from Anthropic rather than
|
|
301
|
+
being masked by a synthesized ID.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
tool_call_id: The tool-call ID to normalize.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
The original ID if it is empty, `None`, or already valid; otherwise a
|
|
308
|
+
deterministic Anthropic-compatible replacement.
|
|
309
|
+
"""
|
|
310
|
+
if not tool_call_id or _TOOL_CALL_ID_PATTERN.match(tool_call_id):
|
|
311
|
+
return tool_call_id
|
|
312
|
+
digest = hashlib.sha256(tool_call_id.encode()).hexdigest()
|
|
313
|
+
return f"toolu_{digest[:24]}"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _normalize_block_tool_use_id(block: dict) -> dict:
|
|
317
|
+
"""Return `block` with its `tool_use_id` normalized, if it carries one.
|
|
318
|
+
|
|
319
|
+
Mirrors `_normalize_tool_call_id` for `tool_result`-style content blocks so
|
|
320
|
+
that a `tool_use_id` arriving pre-structured (e.g. on a `ToolMessage` whose
|
|
321
|
+
content is already a list of `tool_result` blocks) stays consistent with its
|
|
322
|
+
paired, normalized `tool_use.id`. A no-op for already-valid IDs.
|
|
323
|
+
"""
|
|
324
|
+
if "tool_use_id" in block:
|
|
325
|
+
return {**block, "tool_use_id": _normalize_tool_call_id(block["tool_use_id"])}
|
|
326
|
+
return block
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _merge_messages(
|
|
330
|
+
messages: Sequence[BaseMessage],
|
|
331
|
+
) -> list[SystemMessage | AIMessage | HumanMessage]:
|
|
332
|
+
"""Merge runs of human/tool messages into single human messages with content blocks.""" # noqa: E501
|
|
333
|
+
merged: list = []
|
|
334
|
+
for curr in messages:
|
|
335
|
+
if isinstance(curr, ToolMessage):
|
|
336
|
+
if (
|
|
337
|
+
isinstance(curr.content, list)
|
|
338
|
+
and curr.content
|
|
339
|
+
and all(
|
|
340
|
+
isinstance(block, dict) and block.get("type") == "tool_result"
|
|
341
|
+
for block in curr.content
|
|
342
|
+
)
|
|
343
|
+
):
|
|
344
|
+
curr = HumanMessage(curr.content) # type: ignore[misc]
|
|
345
|
+
else:
|
|
346
|
+
tool_content = curr.content
|
|
347
|
+
cache_ctrl = None
|
|
348
|
+
# Extract cache_control from content blocks and hoist it
|
|
349
|
+
# to the tool_result level. Anthropic's API does not
|
|
350
|
+
# support cache_control on tool_result content sub-blocks.
|
|
351
|
+
if isinstance(tool_content, list):
|
|
352
|
+
cleaned = []
|
|
353
|
+
for block in tool_content:
|
|
354
|
+
if isinstance(block, dict) and "cache_control" in block:
|
|
355
|
+
cache_ctrl = block["cache_control"]
|
|
356
|
+
block = {
|
|
357
|
+
k: v for k, v in block.items() if k != "cache_control"
|
|
358
|
+
}
|
|
359
|
+
cleaned.append(block)
|
|
360
|
+
tool_content = cleaned
|
|
361
|
+
tool_result: dict = {
|
|
362
|
+
"type": "tool_result",
|
|
363
|
+
"content": tool_content,
|
|
364
|
+
"tool_use_id": _normalize_tool_call_id(curr.tool_call_id),
|
|
365
|
+
"is_error": curr.status == "error",
|
|
366
|
+
}
|
|
367
|
+
if cache_ctrl:
|
|
368
|
+
tool_result["cache_control"] = cache_ctrl
|
|
369
|
+
curr = HumanMessage( # type: ignore[misc]
|
|
370
|
+
[tool_result],
|
|
371
|
+
)
|
|
372
|
+
last = merged[-1] if merged else None
|
|
373
|
+
if any(
|
|
374
|
+
all(isinstance(m, c) for m in (curr, last))
|
|
375
|
+
for c in (SystemMessage, HumanMessage)
|
|
376
|
+
):
|
|
377
|
+
if isinstance(cast("BaseMessage", last).content, str):
|
|
378
|
+
new_content: list = [
|
|
379
|
+
{"type": "text", "text": cast("BaseMessage", last).content},
|
|
380
|
+
]
|
|
381
|
+
else:
|
|
382
|
+
new_content = copy.copy(cast("list", cast("BaseMessage", last).content))
|
|
383
|
+
if isinstance(curr.content, str):
|
|
384
|
+
new_content.append({"type": "text", "text": curr.content})
|
|
385
|
+
else:
|
|
386
|
+
new_content.extend(curr.content)
|
|
387
|
+
merged[-1] = curr.model_copy(update={"content": new_content})
|
|
388
|
+
else:
|
|
389
|
+
merged.append(curr)
|
|
390
|
+
return merged
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _format_data_content_block(block: dict) -> dict:
|
|
394
|
+
"""Format standard data content block to format expected by Anthropic."""
|
|
395
|
+
if block["type"] == "image":
|
|
396
|
+
if "url" in block:
|
|
397
|
+
if block["url"].startswith("data:"):
|
|
398
|
+
# Data URI
|
|
399
|
+
formatted_block = {
|
|
400
|
+
"type": "image",
|
|
401
|
+
"source": _format_image(block["url"]),
|
|
402
|
+
}
|
|
403
|
+
else:
|
|
404
|
+
formatted_block = {
|
|
405
|
+
"type": "image",
|
|
406
|
+
"source": {"type": "url", "url": block["url"]},
|
|
407
|
+
}
|
|
408
|
+
elif "base64" in block or block.get("source_type") == "base64":
|
|
409
|
+
formatted_block = {
|
|
410
|
+
"type": "image",
|
|
411
|
+
"source": {
|
|
412
|
+
"type": "base64",
|
|
413
|
+
"media_type": block["mime_type"],
|
|
414
|
+
"data": block.get("base64") or block.get("data", ""),
|
|
415
|
+
},
|
|
416
|
+
}
|
|
417
|
+
elif "file_id" in block:
|
|
418
|
+
formatted_block = {
|
|
419
|
+
"type": "image",
|
|
420
|
+
"source": {
|
|
421
|
+
"type": "file",
|
|
422
|
+
"file_id": block["file_id"],
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
elif block.get("source_type") == "id":
|
|
426
|
+
formatted_block = {
|
|
427
|
+
"type": "image",
|
|
428
|
+
"source": {
|
|
429
|
+
"type": "file",
|
|
430
|
+
"file_id": block["id"],
|
|
431
|
+
},
|
|
432
|
+
}
|
|
433
|
+
else:
|
|
434
|
+
msg = (
|
|
435
|
+
"Anthropic only supports 'url', 'base64', or 'id' keys for image "
|
|
436
|
+
"content blocks."
|
|
437
|
+
)
|
|
438
|
+
raise ValueError(
|
|
439
|
+
msg,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
elif block["type"] == "file":
|
|
443
|
+
if "url" in block:
|
|
444
|
+
formatted_block = {
|
|
445
|
+
"type": "document",
|
|
446
|
+
"source": {
|
|
447
|
+
"type": "url",
|
|
448
|
+
"url": block["url"],
|
|
449
|
+
},
|
|
450
|
+
}
|
|
451
|
+
elif "base64" in block or block.get("source_type") == "base64":
|
|
452
|
+
formatted_block = {
|
|
453
|
+
"type": "document",
|
|
454
|
+
"source": {
|
|
455
|
+
"type": "base64",
|
|
456
|
+
"media_type": block.get("mime_type") or "application/pdf",
|
|
457
|
+
"data": block.get("base64") or block.get("data", ""),
|
|
458
|
+
},
|
|
459
|
+
}
|
|
460
|
+
elif block.get("source_type") == "text":
|
|
461
|
+
formatted_block = {
|
|
462
|
+
"type": "document",
|
|
463
|
+
"source": {
|
|
464
|
+
"type": "text",
|
|
465
|
+
"media_type": block.get("mime_type") or "text/plain",
|
|
466
|
+
"data": block["text"],
|
|
467
|
+
},
|
|
468
|
+
}
|
|
469
|
+
elif "file_id" in block:
|
|
470
|
+
formatted_block = {
|
|
471
|
+
"type": "document",
|
|
472
|
+
"source": {
|
|
473
|
+
"type": "file",
|
|
474
|
+
"file_id": block["file_id"],
|
|
475
|
+
},
|
|
476
|
+
}
|
|
477
|
+
elif block.get("source_type") == "id":
|
|
478
|
+
formatted_block = {
|
|
479
|
+
"type": "document",
|
|
480
|
+
"source": {
|
|
481
|
+
"type": "file",
|
|
482
|
+
"file_id": block["id"],
|
|
483
|
+
},
|
|
484
|
+
}
|
|
485
|
+
else:
|
|
486
|
+
msg = (
|
|
487
|
+
"Anthropic only supports 'url', 'base64', or 'id' keys for file "
|
|
488
|
+
"content blocks."
|
|
489
|
+
)
|
|
490
|
+
raise ValueError(msg)
|
|
491
|
+
|
|
492
|
+
elif block["type"] == "text-plain":
|
|
493
|
+
formatted_block = {
|
|
494
|
+
"type": "document",
|
|
495
|
+
"source": {
|
|
496
|
+
"type": "text",
|
|
497
|
+
"media_type": block.get("mime_type") or "text/plain",
|
|
498
|
+
"data": block["text"],
|
|
499
|
+
},
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
else:
|
|
503
|
+
msg = f"Block of type {block['type']} is not supported."
|
|
504
|
+
raise ValueError(msg)
|
|
505
|
+
|
|
506
|
+
if formatted_block:
|
|
507
|
+
for key in ["cache_control", "citations", "title", "context"]:
|
|
508
|
+
if key in block:
|
|
509
|
+
formatted_block[key] = block[key]
|
|
510
|
+
elif (metadata := block.get("extras")) and key in metadata:
|
|
511
|
+
formatted_block[key] = metadata[key]
|
|
512
|
+
elif (metadata := block.get("metadata")) and key in metadata:
|
|
513
|
+
# Backward compat
|
|
514
|
+
formatted_block[key] = metadata[key]
|
|
515
|
+
|
|
516
|
+
return formatted_block
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _format_text_block(block: dict) -> dict:
|
|
520
|
+
"""Narrow a text content block to fields supported by Anthropic's API.
|
|
521
|
+
|
|
522
|
+
Drops Coco-internal fields (e.g. the ``id`` minted by
|
|
523
|
+
``create_text_block``) that Anthropic rejects as extra inputs.
|
|
524
|
+
"""
|
|
525
|
+
formatted_block = {
|
|
526
|
+
k: v
|
|
527
|
+
for k, v in block.items()
|
|
528
|
+
if k in ("type", "text", "cache_control", "citations")
|
|
529
|
+
}
|
|
530
|
+
# Clean up citations to remove null file_id fields
|
|
531
|
+
if formatted_block.get("citations"):
|
|
532
|
+
cleaned_citations = []
|
|
533
|
+
for citation in formatted_block["citations"]:
|
|
534
|
+
cleaned_citation = {
|
|
535
|
+
k: v for k, v in citation.items() if not (k == "file_id" and v is None)
|
|
536
|
+
}
|
|
537
|
+
cleaned_citations.append(cleaned_citation)
|
|
538
|
+
formatted_block["citations"] = cleaned_citations
|
|
539
|
+
return formatted_block
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
_TOOL_CHANGE_BLOCK_TYPES = ("tool_addition", "tool_removal")
|
|
543
|
+
"""Anthropic-native system content blocks that change the tool set mid-conversation."""
|
|
544
|
+
|
|
545
|
+
_MID_CONVERSATION_TOOL_CHANGES_BETA = "mid-conversation-tool-changes-2026-07-01"
|
|
546
|
+
"""Beta header required to send `tool_addition` / `tool_removal` blocks."""
|
|
547
|
+
|
|
548
|
+
_INLINE_TOOLS_BETA = "inline-tools-2026-09-15"
|
|
549
|
+
"""Beta header required to define a tool in a `tool_addition` block."""
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _is_tool_change_block(block: object) -> bool:
|
|
553
|
+
"""Return whether a content block changes the tool set, in either spelling."""
|
|
554
|
+
return (
|
|
555
|
+
isinstance(block, dict)
|
|
556
|
+
and _unwrap_non_standard(block).get("type") in _TOOL_CHANGE_BLOCK_TYPES
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _has_tool_change_block(content: object) -> bool:
|
|
561
|
+
"""Return whether any block in `content` changes the tool set."""
|
|
562
|
+
return isinstance(content, list) and any(_is_tool_change_block(b) for b in content)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _has_inline_tool_definition(content: object) -> bool:
|
|
566
|
+
"""Return whether `content` defines a tool in a `tool_addition` block."""
|
|
567
|
+
if not isinstance(content, list):
|
|
568
|
+
return False
|
|
569
|
+
return any(
|
|
570
|
+
isinstance(block, dict)
|
|
571
|
+
and block.get("type") == "tool_addition"
|
|
572
|
+
and isinstance(tool := block.get("tool"), dict)
|
|
573
|
+
and tool.get("type") == "tool_definition"
|
|
574
|
+
for block in content
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _format_system_content(
|
|
579
|
+
content: str | list[Any],
|
|
580
|
+
*,
|
|
581
|
+
model: str | None = None,
|
|
582
|
+
preserve_tool_changes: bool = False,
|
|
583
|
+
stacklevel: int = 3,
|
|
584
|
+
) -> str | list[dict]:
|
|
585
|
+
"""Narrow system message content to what Anthropic accepts.
|
|
586
|
+
|
|
587
|
+
String content is passed through unchanged; promoting it to a single-element
|
|
588
|
+
block array would invalidate existing callers' prompt caches.
|
|
589
|
+
|
|
590
|
+
Anthropic documents a closed set of system content blocks: `text` anywhere, plus
|
|
591
|
+
`tool_addition` / `tool_removal` on a mid-conversation `system` turn. Anything
|
|
592
|
+
else is rejected by the API, so it is dropped with a warning rather than
|
|
593
|
+
forwarded. Blocks wrapped in core's `non_standard` escape hatch are unwrapped
|
|
594
|
+
first, so both spellings behave identically.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
content: The system message's content.
|
|
598
|
+
model: The model the request targets, used only in warning text.
|
|
599
|
+
preserve_tool_changes: Whether tool-change blocks should be forwarded instead
|
|
600
|
+
of dropped.
|
|
601
|
+
stacklevel: Frames to skip when attributing a warning, so it points at the
|
|
602
|
+
caller of `_format_messages` rather than at this module. The default
|
|
603
|
+
suits a direct call; a caller reached through a helper adds a frame.
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
Content narrowed to the blocks Anthropic accepts in this position.
|
|
607
|
+
"""
|
|
608
|
+
if not isinstance(content, list):
|
|
609
|
+
return content
|
|
610
|
+
|
|
611
|
+
formatted: list[dict] = []
|
|
612
|
+
for raw_block in content:
|
|
613
|
+
if not isinstance(raw_block, dict):
|
|
614
|
+
formatted.append({"type": "text", "text": raw_block})
|
|
615
|
+
continue
|
|
616
|
+
|
|
617
|
+
block = _unwrap_non_standard(raw_block)
|
|
618
|
+
block_type = block.get("type")
|
|
619
|
+
if block_type == "text":
|
|
620
|
+
formatted.append(_format_text_block(block))
|
|
621
|
+
elif block_type in _TOOL_CHANGE_BLOCK_TYPES:
|
|
622
|
+
if preserve_tool_changes:
|
|
623
|
+
formatted.append(block)
|
|
624
|
+
else:
|
|
625
|
+
warnings.warn(
|
|
626
|
+
f"Tool-change block {block_type!r} was dropped: it is only "
|
|
627
|
+
"valid on a `SystemMessage` sent in place, and this one was "
|
|
628
|
+
"hoisted into the top-level `system` field. Use a model that "
|
|
629
|
+
"supports mid-conversation system messages and place the "
|
|
630
|
+
"message after a human or tool message, either last or before "
|
|
631
|
+
f"an AI message (model: {model!r}).",
|
|
632
|
+
UserWarning,
|
|
633
|
+
stacklevel=stacklevel,
|
|
634
|
+
)
|
|
635
|
+
else:
|
|
636
|
+
warnings.warn(
|
|
637
|
+
f"Unrecognized system content block {block_type!r} was dropped. "
|
|
638
|
+
"Anthropic accepts `text` in any system message, plus "
|
|
639
|
+
"`tool_addition` and `tool_removal` on a mid-conversation one.",
|
|
640
|
+
UserWarning,
|
|
641
|
+
stacklevel=stacklevel,
|
|
642
|
+
)
|
|
643
|
+
return formatted
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _warn_system_message_hoisted(model: str | None) -> None:
|
|
647
|
+
"""Warn that a non-leading system message was hoisted."""
|
|
648
|
+
warnings.warn(
|
|
649
|
+
"A non-leading `SystemMessage` was moved to the top-level `system` field "
|
|
650
|
+
"and now applies to the entire conversation. To send it in place, use a "
|
|
651
|
+
"supported model and place it after a human or tool message, either last "
|
|
652
|
+
f"or before an AI message (model: {model!r}).",
|
|
653
|
+
UserWarning,
|
|
654
|
+
stacklevel=3,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _is_server_tool_result_block(block: object) -> bool:
|
|
659
|
+
"""Return whether a content block is a server-side tool result."""
|
|
660
|
+
return (
|
|
661
|
+
isinstance(block, dict)
|
|
662
|
+
and isinstance(block.get("type"), str)
|
|
663
|
+
and block["type"].endswith("_tool_result")
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _previous_turn_allows_system(previous_turn: dict | None) -> bool:
|
|
668
|
+
"""Return whether a system message may follow the formatted turn."""
|
|
669
|
+
if previous_turn is None:
|
|
670
|
+
return False
|
|
671
|
+
|
|
672
|
+
previous_role = previous_turn.get("role")
|
|
673
|
+
previous_content = previous_turn.get("content")
|
|
674
|
+
return previous_role == "user" or (
|
|
675
|
+
previous_role == "assistant"
|
|
676
|
+
and isinstance(previous_content, list)
|
|
677
|
+
and bool(previous_content)
|
|
678
|
+
and _is_server_tool_result_block(previous_content[-1])
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _format_in_place_system_messages(
|
|
683
|
+
pending_system: Sequence[BaseMessage],
|
|
684
|
+
*,
|
|
685
|
+
model: str | None,
|
|
686
|
+
) -> list[dict]:
|
|
687
|
+
"""Format system messages that keep their position in the message array.
|
|
688
|
+
|
|
689
|
+
A message whose content is narrowed away entirely is omitted: Anthropic
|
|
690
|
+
rejects a `system` turn with empty content, and the dropped blocks have
|
|
691
|
+
already been warned about.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
pending_system: System messages awaiting emission, in order.
|
|
695
|
+
model: The model the request targets, used only in warning text.
|
|
696
|
+
|
|
697
|
+
Returns:
|
|
698
|
+
Formatted `system`-role turns.
|
|
699
|
+
"""
|
|
700
|
+
turns: list[dict] = []
|
|
701
|
+
for pending in pending_system:
|
|
702
|
+
content = _format_system_content(
|
|
703
|
+
pending.content,
|
|
704
|
+
model=model,
|
|
705
|
+
preserve_tool_changes=True,
|
|
706
|
+
# This helper sits between `_format_messages` and the warning site.
|
|
707
|
+
stacklevel=4,
|
|
708
|
+
)
|
|
709
|
+
if content == []:
|
|
710
|
+
continue
|
|
711
|
+
turns.append({"role": "system", "content": content})
|
|
712
|
+
return turns
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _format_messages(
|
|
716
|
+
messages: Sequence[BaseMessage],
|
|
717
|
+
*,
|
|
718
|
+
model: str | None,
|
|
719
|
+
) -> tuple[str | list[dict] | None, list[dict]]:
|
|
720
|
+
"""Format messages for Anthropic's API."""
|
|
721
|
+
system: str | list[dict] | None = None
|
|
722
|
+
formatted_messages: list[dict] = []
|
|
723
|
+
merged_messages = _merge_messages(messages)
|
|
724
|
+
last_non_system_index = max(
|
|
725
|
+
(i for i, m in enumerate(merged_messages) if m.type != "system"),
|
|
726
|
+
default=-1,
|
|
727
|
+
)
|
|
728
|
+
pending_system: list[BaseMessage] = []
|
|
729
|
+
for _i, message in enumerate(merged_messages):
|
|
730
|
+
if message.type == "system":
|
|
731
|
+
if _i == 0:
|
|
732
|
+
system = _format_system_content(
|
|
733
|
+
message.content,
|
|
734
|
+
model=model,
|
|
735
|
+
preserve_tool_changes=True,
|
|
736
|
+
)
|
|
737
|
+
continue
|
|
738
|
+
if _supports_mid_conversation_system_messages(model) and (
|
|
739
|
+
pending_system
|
|
740
|
+
or _previous_turn_allows_system(
|
|
741
|
+
formatted_messages[-1] if formatted_messages else None
|
|
742
|
+
)
|
|
743
|
+
):
|
|
744
|
+
pending_system.append(message)
|
|
745
|
+
continue
|
|
746
|
+
if system is not None:
|
|
747
|
+
msg = "Received multiple non-consecutive system messages."
|
|
748
|
+
raise ValueError(msg)
|
|
749
|
+
system = _format_system_content(message.content, model=model)
|
|
750
|
+
_warn_system_message_hoisted(model)
|
|
751
|
+
continue
|
|
752
|
+
|
|
753
|
+
role = _message_type_lookups[message.type]
|
|
754
|
+
content: str | list
|
|
755
|
+
|
|
756
|
+
if not isinstance(message.content, str):
|
|
757
|
+
# parse as dict
|
|
758
|
+
if not isinstance(message.content, list):
|
|
759
|
+
msg = "Anthropic message content must be str or list of dicts"
|
|
760
|
+
raise ValueError(
|
|
761
|
+
msg,
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
# populate content
|
|
765
|
+
content = []
|
|
766
|
+
for block in message.content:
|
|
767
|
+
if isinstance(block, str):
|
|
768
|
+
content.append({"type": "text", "text": block})
|
|
769
|
+
elif isinstance(block, dict):
|
|
770
|
+
if "type" not in block:
|
|
771
|
+
msg = "Dict content block must have a type key"
|
|
772
|
+
raise ValueError(msg)
|
|
773
|
+
if block["type"] in ("reasoning", "function_call") and (
|
|
774
|
+
not isinstance(message, AIMessage)
|
|
775
|
+
or message.response_metadata.get("model_provider")
|
|
776
|
+
!= "anthropic"
|
|
777
|
+
):
|
|
778
|
+
continue
|
|
779
|
+
if block["type"] == "image_url":
|
|
780
|
+
# convert format
|
|
781
|
+
source = _format_image(block["image_url"]["url"])
|
|
782
|
+
content.append({"type": "image", "source": source})
|
|
783
|
+
elif is_data_content_block(block):
|
|
784
|
+
content.append(_format_data_content_block(block))
|
|
785
|
+
elif block["type"] == "tool_use":
|
|
786
|
+
# If a tool_call with the same id as a tool_use content block
|
|
787
|
+
# exists, the tool_call is preferred.
|
|
788
|
+
if (
|
|
789
|
+
isinstance(message, AIMessage)
|
|
790
|
+
and (block["id"] in [tc["id"] for tc in message.tool_calls])
|
|
791
|
+
and not block.get("caller")
|
|
792
|
+
):
|
|
793
|
+
overlapping = [
|
|
794
|
+
tc
|
|
795
|
+
for tc in message.tool_calls
|
|
796
|
+
if tc["id"] == block["id"]
|
|
797
|
+
]
|
|
798
|
+
content.extend(
|
|
799
|
+
_lc_tool_calls_to_anthropic_tool_use_blocks(
|
|
800
|
+
overlapping,
|
|
801
|
+
),
|
|
802
|
+
)
|
|
803
|
+
else:
|
|
804
|
+
if tool_input := block.get("input"):
|
|
805
|
+
args = tool_input
|
|
806
|
+
elif "partial_json" in block:
|
|
807
|
+
try:
|
|
808
|
+
args = json.loads(block["partial_json"] or "{}")
|
|
809
|
+
except json.JSONDecodeError:
|
|
810
|
+
args = {}
|
|
811
|
+
else:
|
|
812
|
+
args = {}
|
|
813
|
+
tool_use_block = _AnthropicToolUse(
|
|
814
|
+
type="tool_use",
|
|
815
|
+
name=block["name"],
|
|
816
|
+
input=args,
|
|
817
|
+
id=cast("str", _normalize_tool_call_id(block["id"])),
|
|
818
|
+
)
|
|
819
|
+
if caller := block.get("caller"):
|
|
820
|
+
tool_use_block["caller"] = caller
|
|
821
|
+
content.append(tool_use_block)
|
|
822
|
+
elif block["type"] in ("server_tool_use", "mcp_tool_use"):
|
|
823
|
+
formatted_block = {
|
|
824
|
+
k: v
|
|
825
|
+
for k, v in block.items()
|
|
826
|
+
if k
|
|
827
|
+
in (
|
|
828
|
+
"type",
|
|
829
|
+
"id",
|
|
830
|
+
"input",
|
|
831
|
+
"name",
|
|
832
|
+
"server_name", # for mcp_tool_use
|
|
833
|
+
"cache_control",
|
|
834
|
+
)
|
|
835
|
+
}
|
|
836
|
+
# Attempt to parse streamed output
|
|
837
|
+
if block.get("input") == {} and "partial_json" in block:
|
|
838
|
+
try:
|
|
839
|
+
input_ = json.loads(block["partial_json"])
|
|
840
|
+
if input_:
|
|
841
|
+
formatted_block["input"] = input_
|
|
842
|
+
except json.JSONDecodeError:
|
|
843
|
+
pass
|
|
844
|
+
content.append(formatted_block)
|
|
845
|
+
elif block["type"] == "text":
|
|
846
|
+
text = block.get("text", "")
|
|
847
|
+
# Only add non-empty strings for now as empty ones are not
|
|
848
|
+
# accepted.
|
|
849
|
+
# https://github.com/anthropics/anthropic-sdk-python/issues/461
|
|
850
|
+
if text.strip():
|
|
851
|
+
content.append(_format_text_block(block))
|
|
852
|
+
elif block["type"] == "thinking":
|
|
853
|
+
formatted_thinking = {
|
|
854
|
+
k: v
|
|
855
|
+
for k, v in block.items()
|
|
856
|
+
if k in ("type", "thinking", "cache_control", "signature")
|
|
857
|
+
}
|
|
858
|
+
if "signature" in formatted_thinking:
|
|
859
|
+
formatted_thinking.setdefault("thinking", "")
|
|
860
|
+
content.append(formatted_thinking)
|
|
861
|
+
elif block["type"] == "redacted_thinking":
|
|
862
|
+
content.append(
|
|
863
|
+
{
|
|
864
|
+
k: v
|
|
865
|
+
for k, v in block.items()
|
|
866
|
+
if k in ("type", "cache_control", "data")
|
|
867
|
+
},
|
|
868
|
+
)
|
|
869
|
+
elif (
|
|
870
|
+
block["type"] == "tool_result"
|
|
871
|
+
and isinstance(block.get("content"), list)
|
|
872
|
+
and any(
|
|
873
|
+
isinstance(item, dict)
|
|
874
|
+
and item.get("type") == "tool_reference"
|
|
875
|
+
for item in block["content"]
|
|
876
|
+
)
|
|
877
|
+
):
|
|
878
|
+
# Tool search results with tool_reference blocks
|
|
879
|
+
content.append(
|
|
880
|
+
_normalize_block_tool_use_id(
|
|
881
|
+
{
|
|
882
|
+
k: v
|
|
883
|
+
for k, v in block.items()
|
|
884
|
+
if k
|
|
885
|
+
in (
|
|
886
|
+
"type",
|
|
887
|
+
"content",
|
|
888
|
+
"tool_use_id",
|
|
889
|
+
"cache_control",
|
|
890
|
+
)
|
|
891
|
+
},
|
|
892
|
+
),
|
|
893
|
+
)
|
|
894
|
+
elif block["type"] == "tool_search_tool_result":
|
|
895
|
+
# Omit streaming-only fields, such as `index`, from results.
|
|
896
|
+
content.append(
|
|
897
|
+
_normalize_block_tool_use_id(
|
|
898
|
+
{
|
|
899
|
+
k: v
|
|
900
|
+
for k, v in block.items()
|
|
901
|
+
if k
|
|
902
|
+
in (
|
|
903
|
+
"type",
|
|
904
|
+
"content",
|
|
905
|
+
"tool_use_id",
|
|
906
|
+
"cache_control",
|
|
907
|
+
)
|
|
908
|
+
},
|
|
909
|
+
),
|
|
910
|
+
)
|
|
911
|
+
elif block["type"] == "tool_result":
|
|
912
|
+
# Regular tool results that need content formatting
|
|
913
|
+
tool_content = _format_messages(
|
|
914
|
+
[HumanMessage(block["content"])],
|
|
915
|
+
model=None,
|
|
916
|
+
)[1][0]["content"]
|
|
917
|
+
content.append(
|
|
918
|
+
_normalize_block_tool_use_id(
|
|
919
|
+
{**block, "content": tool_content},
|
|
920
|
+
),
|
|
921
|
+
)
|
|
922
|
+
elif block["type"] in (
|
|
923
|
+
"code_execution_tool_result",
|
|
924
|
+
"bash_code_execution_tool_result",
|
|
925
|
+
"text_editor_code_execution_tool_result",
|
|
926
|
+
"mcp_tool_result",
|
|
927
|
+
"web_search_tool_result",
|
|
928
|
+
"web_fetch_tool_result",
|
|
929
|
+
):
|
|
930
|
+
content.append(
|
|
931
|
+
_normalize_block_tool_use_id(
|
|
932
|
+
{
|
|
933
|
+
k: v
|
|
934
|
+
for k, v in block.items()
|
|
935
|
+
if k
|
|
936
|
+
in (
|
|
937
|
+
"type",
|
|
938
|
+
"content",
|
|
939
|
+
"tool_use_id",
|
|
940
|
+
"is_error", # for mcp_tool_result
|
|
941
|
+
"cache_control",
|
|
942
|
+
"retrieved_at", # for web_fetch_tool_result
|
|
943
|
+
)
|
|
944
|
+
},
|
|
945
|
+
),
|
|
946
|
+
)
|
|
947
|
+
else:
|
|
948
|
+
content.append(block)
|
|
949
|
+
else:
|
|
950
|
+
msg = (
|
|
951
|
+
f"Content blocks must be str or dict, instead was: "
|
|
952
|
+
f"{type(block)}"
|
|
953
|
+
)
|
|
954
|
+
raise ValueError(
|
|
955
|
+
msg,
|
|
956
|
+
)
|
|
957
|
+
else:
|
|
958
|
+
content = message.content
|
|
959
|
+
|
|
960
|
+
if isinstance(message, AIMessage) and (
|
|
961
|
+
message.tool_calls or message.invalid_tool_calls
|
|
962
|
+
):
|
|
963
|
+
content = content or []
|
|
964
|
+
content = (
|
|
965
|
+
[{"type": "text", "text": message.content}]
|
|
966
|
+
if isinstance(content, str) and content
|
|
967
|
+
else content
|
|
968
|
+
)
|
|
969
|
+
tool_use_ids = [
|
|
970
|
+
cast("dict", block)["id"]
|
|
971
|
+
for block in content
|
|
972
|
+
if cast("dict", block)["type"] == "tool_use"
|
|
973
|
+
]
|
|
974
|
+
# `tool_use_ids` are already normalized via the branches above, so
|
|
975
|
+
# compare against the normalized tool-call ID to avoid emitting a
|
|
976
|
+
# duplicate `tool_use` block when the original ID was rewritten.
|
|
977
|
+
missing_tool_calls = [
|
|
978
|
+
tc
|
|
979
|
+
for tc in message.tool_calls
|
|
980
|
+
if _normalize_tool_call_id(tc["id"]) not in tool_use_ids
|
|
981
|
+
]
|
|
982
|
+
cast("list", content).extend(
|
|
983
|
+
_lc_tool_calls_to_anthropic_tool_use_blocks(missing_tool_calls),
|
|
984
|
+
)
|
|
985
|
+
tool_use_ids.extend(
|
|
986
|
+
cast("str", _normalize_tool_call_id(tc["id"]))
|
|
987
|
+
for tc in missing_tool_calls
|
|
988
|
+
)
|
|
989
|
+
for invalid_call in message.invalid_tool_calls:
|
|
990
|
+
tool_call_id = invalid_call.get("id")
|
|
991
|
+
tool_name = invalid_call.get("name")
|
|
992
|
+
if not tool_call_id or not tool_name:
|
|
993
|
+
continue
|
|
994
|
+
normalized_id = _normalize_tool_call_id(tool_call_id)
|
|
995
|
+
if normalized_id in tool_use_ids:
|
|
996
|
+
continue
|
|
997
|
+
try:
|
|
998
|
+
args = json.loads(invalid_call.get("args") or "{}")
|
|
999
|
+
except json.JSONDecodeError:
|
|
1000
|
+
args = {}
|
|
1001
|
+
cast("list", content).append(
|
|
1002
|
+
_AnthropicToolUse(
|
|
1003
|
+
type="tool_use",
|
|
1004
|
+
name=tool_name,
|
|
1005
|
+
input=args if isinstance(args, dict) else {},
|
|
1006
|
+
id=cast("str", normalized_id),
|
|
1007
|
+
)
|
|
1008
|
+
)
|
|
1009
|
+
tool_use_ids.append(normalized_id)
|
|
1010
|
+
|
|
1011
|
+
if role == "assistant" and _i == last_non_system_index:
|
|
1012
|
+
if isinstance(content, str):
|
|
1013
|
+
content = content.rstrip()
|
|
1014
|
+
elif (
|
|
1015
|
+
isinstance(content, list)
|
|
1016
|
+
and content
|
|
1017
|
+
and isinstance(content[-1], dict)
|
|
1018
|
+
and content[-1].get("type") == "text"
|
|
1019
|
+
):
|
|
1020
|
+
content[-1]["text"] = content[-1]["text"].rstrip()
|
|
1021
|
+
|
|
1022
|
+
if not content and role == "assistant" and _i < last_non_system_index:
|
|
1023
|
+
# anthropic.BadRequestError: Error code: 400: all messages must have
|
|
1024
|
+
# non-empty content except for the optional final assistant message
|
|
1025
|
+
continue
|
|
1026
|
+
if pending_system:
|
|
1027
|
+
if role == "assistant":
|
|
1028
|
+
formatted_messages.extend(
|
|
1029
|
+
_format_in_place_system_messages(pending_system, model=model)
|
|
1030
|
+
)
|
|
1031
|
+
else:
|
|
1032
|
+
for pending in pending_system:
|
|
1033
|
+
if system is not None:
|
|
1034
|
+
msg = "Received multiple non-consecutive system messages."
|
|
1035
|
+
raise ValueError(msg)
|
|
1036
|
+
system = _format_system_content(pending.content, model=model)
|
|
1037
|
+
_warn_system_message_hoisted(model)
|
|
1038
|
+
pending_system = []
|
|
1039
|
+
formatted_messages.append({"role": role, "content": content})
|
|
1040
|
+
|
|
1041
|
+
formatted_messages.extend(
|
|
1042
|
+
_format_in_place_system_messages(pending_system, model=model)
|
|
1043
|
+
)
|
|
1044
|
+
return system, formatted_messages
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _container_id(container: Any) -> str | None:
|
|
1048
|
+
"""Return the container ID from either accepted `container` shape."""
|
|
1049
|
+
if isinstance(container, str):
|
|
1050
|
+
return container
|
|
1051
|
+
if isinstance(container, dict):
|
|
1052
|
+
return container.get("id")
|
|
1053
|
+
return None
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _collect_code_execution_tool_ids(formatted_messages: list[dict]) -> set[str]:
|
|
1057
|
+
"""Collect `tool_use` IDs that were called by `code_execution`.
|
|
1058
|
+
|
|
1059
|
+
These blocks cannot have `cache_control` applied per Anthropic API
|
|
1060
|
+
requirements.
|
|
1061
|
+
"""
|
|
1062
|
+
code_execution_tool_ids: set[str] = set()
|
|
1063
|
+
|
|
1064
|
+
for message in formatted_messages:
|
|
1065
|
+
if message.get("role") != "assistant":
|
|
1066
|
+
continue
|
|
1067
|
+
content = message.get("content", [])
|
|
1068
|
+
if not isinstance(content, list):
|
|
1069
|
+
continue
|
|
1070
|
+
for block in content:
|
|
1071
|
+
if not isinstance(block, dict):
|
|
1072
|
+
continue
|
|
1073
|
+
if block.get("type") != "tool_use":
|
|
1074
|
+
continue
|
|
1075
|
+
caller = block.get("caller")
|
|
1076
|
+
if isinstance(caller, dict):
|
|
1077
|
+
caller_type = caller.get("type", "")
|
|
1078
|
+
if caller_type.startswith("code_execution"):
|
|
1079
|
+
tool_id = block.get("id")
|
|
1080
|
+
if tool_id:
|
|
1081
|
+
code_execution_tool_ids.add(tool_id)
|
|
1082
|
+
|
|
1083
|
+
return code_execution_tool_ids
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _is_code_execution_related_block(
|
|
1087
|
+
block: dict,
|
|
1088
|
+
code_execution_tool_ids: set[str],
|
|
1089
|
+
) -> bool:
|
|
1090
|
+
"""Return whether a content block is related to `code_execution`.
|
|
1091
|
+
|
|
1092
|
+
Returns `True` for blocks that should NOT have `cache_control` applied.
|
|
1093
|
+
"""
|
|
1094
|
+
if not isinstance(block, dict):
|
|
1095
|
+
return False
|
|
1096
|
+
|
|
1097
|
+
block_type = block.get("type")
|
|
1098
|
+
|
|
1099
|
+
if block_type == "tool_use":
|
|
1100
|
+
caller = block.get("caller")
|
|
1101
|
+
if isinstance(caller, dict):
|
|
1102
|
+
caller_type = caller.get("type", "")
|
|
1103
|
+
if caller_type.startswith("code_execution"):
|
|
1104
|
+
return True
|
|
1105
|
+
|
|
1106
|
+
if block_type == "tool_result":
|
|
1107
|
+
tool_use_id = block.get("tool_use_id")
|
|
1108
|
+
if tool_use_id and tool_use_id in code_execution_tool_ids:
|
|
1109
|
+
return True
|
|
1110
|
+
|
|
1111
|
+
return False
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _reasoning_effort_levels(profile: object) -> tuple[str, ...]:
|
|
1115
|
+
"""Return the reasoning-effort levels declared in a model's profile, if any.
|
|
1116
|
+
|
|
1117
|
+
Defensive against a missing/malformed profile: an absent `profile`, a
|
|
1118
|
+
non-mapping value, or a missing/non-list `reasoning_effort_levels` value is
|
|
1119
|
+
treated as "no levels declared" rather than raising.
|
|
1120
|
+
"""
|
|
1121
|
+
if not isinstance(profile, Mapping):
|
|
1122
|
+
return ()
|
|
1123
|
+
levels = profile.get("reasoning_effort_levels")
|
|
1124
|
+
if not isinstance(levels, (list, tuple)):
|
|
1125
|
+
return ()
|
|
1126
|
+
return tuple(levels)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _supports_mid_conversation_system_messages(model: object) -> bool:
|
|
1130
|
+
"""Return whether the model supports mid-conversation system messages."""
|
|
1131
|
+
if not isinstance(model, str):
|
|
1132
|
+
return False
|
|
1133
|
+
return model.startswith(
|
|
1134
|
+
(
|
|
1135
|
+
"claude-fable-5",
|
|
1136
|
+
"claude-mythos-5",
|
|
1137
|
+
"claude-opus-4-8",
|
|
1138
|
+
"claude-opus-5",
|
|
1139
|
+
)
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _supports_forced_tool_choice(model: str) -> bool:
|
|
1144
|
+
"""Return whether the model accepts `tool_choice` types `any` and `tool`."""
|
|
1145
|
+
return not model.startswith(("claude-fable-5-1", "claude-opus-5-5"))
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _is_direct_anthropic_llm_type(llm_type: object) -> bool:
|
|
1149
|
+
"""Return whether an `_llm_type` reaches Claude via the direct Anthropic API.
|
|
1150
|
+
|
|
1151
|
+
Only the direct API accepts the top-level `cache_control` request param.
|
|
1152
|
+
Subclasses that route through other transports (Bedrock, future backends)
|
|
1153
|
+
override `_llm_type` and must expand `cache_control` kwargs into
|
|
1154
|
+
block-level breakpoints instead.
|
|
1155
|
+
|
|
1156
|
+
Non-string `_llm_type` values return `False` rather than raising, so a
|
|
1157
|
+
misbehaving subclass falls through to the safer non-direct branch.
|
|
1158
|
+
"""
|
|
1159
|
+
return llm_type == "anthropic-chat"
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _apply_cache_control_to_last_eligible_block(
|
|
1163
|
+
formatted_messages: list[dict],
|
|
1164
|
+
cache_control: Any,
|
|
1165
|
+
code_execution_tool_ids: set[str],
|
|
1166
|
+
) -> bool:
|
|
1167
|
+
"""Place `cache_control` on the last block eligible for a breakpoint.
|
|
1168
|
+
|
|
1169
|
+
Walks messages newest-to-oldest and, within each, blocks newest-to-oldest,
|
|
1170
|
+
skipping `code_execution`-related blocks (Anthropic rejects breakpoints
|
|
1171
|
+
there). String message content is promoted to a single text block so the
|
|
1172
|
+
breakpoint can be attached.
|
|
1173
|
+
|
|
1174
|
+
Returns:
|
|
1175
|
+
`True` if a breakpoint was applied, `False` if every candidate was
|
|
1176
|
+
`code_execution`-related (caller should warn and drop the kwarg).
|
|
1177
|
+
"""
|
|
1178
|
+
for formatted_message in reversed(formatted_messages):
|
|
1179
|
+
content = formatted_message.get("content")
|
|
1180
|
+
if isinstance(content, list) and content:
|
|
1181
|
+
for block in reversed(content):
|
|
1182
|
+
if not isinstance(block, dict):
|
|
1183
|
+
continue
|
|
1184
|
+
if _is_code_execution_related_block(block, code_execution_tool_ids):
|
|
1185
|
+
continue
|
|
1186
|
+
block["cache_control"] = cache_control
|
|
1187
|
+
return True
|
|
1188
|
+
elif isinstance(content, str):
|
|
1189
|
+
formatted_message["content"] = [
|
|
1190
|
+
{
|
|
1191
|
+
"type": "text",
|
|
1192
|
+
"text": content,
|
|
1193
|
+
"cache_control": cache_control,
|
|
1194
|
+
}
|
|
1195
|
+
]
|
|
1196
|
+
return True
|
|
1197
|
+
return False
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
class AnthropicContextOverflowError(anthropic.BadRequestError, ContextOverflowError):
|
|
1201
|
+
"""BadRequestError raised when input exceeds Anthropic's context limit."""
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
class AnthropicAuthenticationError(
|
|
1205
|
+
anthropic.AuthenticationError, ModelAuthenticationError
|
|
1206
|
+
):
|
|
1207
|
+
"""Anthropic authentication error classified as a Coco model error."""
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
class AnthropicPermissionDeniedError(
|
|
1211
|
+
anthropic.PermissionDeniedError, ModelPermissionDeniedError
|
|
1212
|
+
):
|
|
1213
|
+
"""Anthropic permission error classified as a Coco model error."""
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
class AnthropicInvalidRequestError(anthropic.BadRequestError, ModelInvalidRequestError):
|
|
1217
|
+
"""Anthropic bad-request error classified as a Coco model error."""
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
class AnthropicModelNotFoundError(anthropic.NotFoundError, ModelNotFoundError):
|
|
1221
|
+
"""Anthropic not-found error classified as a Coco model error."""
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
class AnthropicRateLimitError(anthropic.RateLimitError, ModelRateLimitError):
|
|
1225
|
+
"""Anthropic rate-limit error classified as a Coco model error."""
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
class AnthropicAPIError(anthropic.InternalServerError, ModelAPIError):
|
|
1229
|
+
"""Anthropic server error classified as a Coco model error."""
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
class AnthropicOverloadedError(anthropic.OverloadedError, ModelAPIError):
|
|
1233
|
+
"""Anthropic overloaded error (HTTP 529) classified as a Coco model error."""
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
class AnthropicConnectionError(anthropic.APIConnectionError, ModelConnectionError):
|
|
1237
|
+
"""Anthropic connection error classified as a Coco model error."""
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
class AnthropicTimeoutError(anthropic.APITimeoutError, ModelTimeoutError):
|
|
1241
|
+
"""Anthropic timeout error classified as a Coco model error."""
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _raise_if_authentication_error(e: TypeError) -> None:
|
|
1245
|
+
"""Re-raise anthropic SDK's missing-credentials `TypeError` with guidance."""
|
|
1246
|
+
if "Could not resolve authentication method" in str(e):
|
|
1247
|
+
msg = (
|
|
1248
|
+
"Anthropic authentication failed: no API key or authorization "
|
|
1249
|
+
"credentials were provided. Set the ANTHROPIC_API_KEY environment "
|
|
1250
|
+
"variable, pass api_key=... to ChatAnthropic, or provide "
|
|
1251
|
+
'credentials via default_headers={"Authorization": ...}. If you '
|
|
1252
|
+
"are routing through the LangSmith gateway, set LANGSMITH_GATEWAY "
|
|
1253
|
+
"and LANGSMITH_GATEWAY_API_KEY."
|
|
1254
|
+
)
|
|
1255
|
+
raise TypeError(msg) from e
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _handle_anthropic_bad_request(e: anthropic.BadRequestError) -> None:
|
|
1259
|
+
"""Handle Anthropic BadRequestError."""
|
|
1260
|
+
if "prompt is too long" in e.message:
|
|
1261
|
+
raise AnthropicContextOverflowError(
|
|
1262
|
+
message=e.message, response=e.response, body=e.body
|
|
1263
|
+
) from e
|
|
1264
|
+
if ("messages: at least one message is required") in e.message:
|
|
1265
|
+
message = "Received only system message(s). "
|
|
1266
|
+
warnings.warn(message, stacklevel=2)
|
|
1267
|
+
raise AnthropicInvalidRequestError(
|
|
1268
|
+
message=e.message, response=e.response, body=e.body
|
|
1269
|
+
) from e
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _handle_anthropic_api_error(e: anthropic.APIError) -> None:
|
|
1273
|
+
"""Re-raise an Anthropic SDK error as its Coco-classified equivalent."""
|
|
1274
|
+
if isinstance(e, anthropic.AuthenticationError):
|
|
1275
|
+
raise AnthropicAuthenticationError(
|
|
1276
|
+
message=e.message, response=e.response, body=e.body
|
|
1277
|
+
) from e
|
|
1278
|
+
if isinstance(e, anthropic.PermissionDeniedError):
|
|
1279
|
+
raise AnthropicPermissionDeniedError(
|
|
1280
|
+
message=e.message, response=e.response, body=e.body
|
|
1281
|
+
) from e
|
|
1282
|
+
if isinstance(e, anthropic.NotFoundError):
|
|
1283
|
+
raise AnthropicModelNotFoundError(
|
|
1284
|
+
message=e.message, response=e.response, body=e.body
|
|
1285
|
+
) from e
|
|
1286
|
+
if isinstance(e, anthropic.RateLimitError):
|
|
1287
|
+
raise AnthropicRateLimitError(
|
|
1288
|
+
message=e.message, response=e.response, body=e.body
|
|
1289
|
+
) from e
|
|
1290
|
+
if isinstance(e, anthropic.OverloadedError):
|
|
1291
|
+
raise AnthropicOverloadedError(
|
|
1292
|
+
message=e.message, response=e.response, body=e.body
|
|
1293
|
+
) from e
|
|
1294
|
+
if isinstance(e, anthropic.InternalServerError):
|
|
1295
|
+
raise AnthropicAPIError(
|
|
1296
|
+
message=e.message, response=e.response, body=e.body
|
|
1297
|
+
) from e
|
|
1298
|
+
# `APITimeoutError` subclasses `APIConnectionError`, so check it first.
|
|
1299
|
+
if isinstance(e, anthropic.APITimeoutError):
|
|
1300
|
+
raise AnthropicTimeoutError(e.request) from e
|
|
1301
|
+
if isinstance(e, anthropic.APIConnectionError):
|
|
1302
|
+
raise AnthropicConnectionError(message=e.message, request=e.request) from e
|
|
1303
|
+
raise
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
class ChatAnthropic(BaseChatModel):
|
|
1307
|
+
"""Anthropic (Claude) chat models.
|
|
1308
|
+
|
|
1309
|
+
See the [Coco docs for `ChatAnthropic`](https://docs.langchain.com/oss/python/integrations/chat/anthropic)
|
|
1310
|
+
for tutorials, feature walkthroughs, and examples.
|
|
1311
|
+
|
|
1312
|
+
See the [Claude Platform docs](https://platform.claude.com/docs/en/about-claude/models/overview)
|
|
1313
|
+
for a list of the latest models, their capabilities, and pricing.
|
|
1314
|
+
|
|
1315
|
+
Example:
|
|
1316
|
+
```python
|
|
1317
|
+
# pip install -U trustcoco-anthropic
|
|
1318
|
+
# export ANTHROPIC_API_KEY="your-api-key"
|
|
1319
|
+
|
|
1320
|
+
from coco_anthropic import ChatAnthropic
|
|
1321
|
+
|
|
1322
|
+
model = ChatAnthropic(
|
|
1323
|
+
model="claude-sonnet-4-5-20250929",
|
|
1324
|
+
# temperature=,
|
|
1325
|
+
# max_tokens=,
|
|
1326
|
+
# timeout=,
|
|
1327
|
+
# max_retries=,
|
|
1328
|
+
# base_url="...",
|
|
1329
|
+
# Refer to API reference for full list of parameters
|
|
1330
|
+
)
|
|
1331
|
+
```
|
|
1332
|
+
|
|
1333
|
+
Example: Add a tool mid-conversation
|
|
1334
|
+
```python
|
|
1335
|
+
from coco_core.messages import HumanMessage, SystemMessage
|
|
1336
|
+
from coco_anthropic import ChatAnthropic
|
|
1337
|
+
|
|
1338
|
+
model = ChatAnthropic(model="claude-opus-5-5")
|
|
1339
|
+
model.invoke(
|
|
1340
|
+
[
|
|
1341
|
+
HumanMessage("What time is it?"),
|
|
1342
|
+
SystemMessage(
|
|
1343
|
+
[
|
|
1344
|
+
{
|
|
1345
|
+
"type": "tool_addition",
|
|
1346
|
+
"tool": {
|
|
1347
|
+
"type": "tool_definition",
|
|
1348
|
+
"definition": {
|
|
1349
|
+
"name": "get_time",
|
|
1350
|
+
"description": "Get the current time.",
|
|
1351
|
+
"input_schema": {
|
|
1352
|
+
"type": "object",
|
|
1353
|
+
"properties": {},
|
|
1354
|
+
},
|
|
1355
|
+
},
|
|
1356
|
+
},
|
|
1357
|
+
}
|
|
1358
|
+
]
|
|
1359
|
+
),
|
|
1360
|
+
]
|
|
1361
|
+
)
|
|
1362
|
+
```
|
|
1363
|
+
|
|
1364
|
+
Note:
|
|
1365
|
+
Any param which is not explicitly supported will be passed directly to
|
|
1366
|
+
[`Anthropic.messages.create(...)`](https://platform.claude.com/docs/en/api/python/messages/create)
|
|
1367
|
+
each time to the model is invoked.
|
|
1368
|
+
"""
|
|
1369
|
+
|
|
1370
|
+
model_config = ConfigDict(
|
|
1371
|
+
populate_by_name=True,
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
model: str = Field(alias="model_name")
|
|
1375
|
+
"""Model name to use."""
|
|
1376
|
+
|
|
1377
|
+
max_tokens: int | None = Field(default=None, alias="max_tokens_to_sample")
|
|
1378
|
+
"""Denotes the number of tokens to predict per generation.
|
|
1379
|
+
|
|
1380
|
+
If not specified, this is set dynamically using the model's `max_output_tokens`
|
|
1381
|
+
from its model profile.
|
|
1382
|
+
|
|
1383
|
+
See docs on [model profiles](https://docs.langchain.com/oss/python/langchain/models#model-profiles)
|
|
1384
|
+
for more information.
|
|
1385
|
+
"""
|
|
1386
|
+
|
|
1387
|
+
temperature: float | None = None
|
|
1388
|
+
"""A non-negative float that tunes the degree of randomness in generation."""
|
|
1389
|
+
|
|
1390
|
+
top_k: int | None = None
|
|
1391
|
+
"""Number of most likely tokens to consider at each step."""
|
|
1392
|
+
|
|
1393
|
+
top_p: float | None = None
|
|
1394
|
+
"""Total probability mass of tokens to consider at each step."""
|
|
1395
|
+
|
|
1396
|
+
default_request_timeout: float | None = Field(None, alias="timeout")
|
|
1397
|
+
"""Timeout for requests to Claude API."""
|
|
1398
|
+
|
|
1399
|
+
# sdk default = 2: https://github.com/anthropics/anthropic-sdk-python?tab=readme-ov-file#retries
|
|
1400
|
+
max_retries: int = 2
|
|
1401
|
+
"""Number of retries allowed for requests sent to the Claude API."""
|
|
1402
|
+
|
|
1403
|
+
stop_sequences: list[str] | None = Field(None, alias="stop")
|
|
1404
|
+
"""Default stop sequences."""
|
|
1405
|
+
|
|
1406
|
+
anthropic_api_url: str | None = Field(default=None, alias="base_url")
|
|
1407
|
+
"""Base URL for API requests. Only specify if using a proxy or service emulator.
|
|
1408
|
+
|
|
1409
|
+
If a value isn't passed in, will attempt to read the value first from
|
|
1410
|
+
`ANTHROPIC_API_URL` and if that is not set, `ANTHROPIC_BASE_URL`.
|
|
1411
|
+
|
|
1412
|
+
If `LANGSMITH_GATEWAY` is set, it is used as a fallback after those env vars.
|
|
1413
|
+
"""
|
|
1414
|
+
|
|
1415
|
+
anthropic_api_key: SecretStr = Field(default=SecretStr(""), alias="api_key")
|
|
1416
|
+
"""Automatically read from env var `ANTHROPIC_API_KEY` if not provided.
|
|
1417
|
+
|
|
1418
|
+
If `LANGSMITH_GATEWAY` is enabled and the base URL points at the gateway,
|
|
1419
|
+
`LANGSMITH_GATEWAY_API_KEY` is used instead.
|
|
1420
|
+
"""
|
|
1421
|
+
|
|
1422
|
+
anthropic_proxy: str | None = Field(
|
|
1423
|
+
default_factory=from_env("ANTHROPIC_PROXY", default=None)
|
|
1424
|
+
)
|
|
1425
|
+
"""Proxy to use for the Anthropic clients, will be used for every API call.
|
|
1426
|
+
|
|
1427
|
+
If not provided, will attempt to read from the `ANTHROPIC_PROXY` environment
|
|
1428
|
+
variable.
|
|
1429
|
+
"""
|
|
1430
|
+
|
|
1431
|
+
default_headers: Mapping[str, str] | None = None
|
|
1432
|
+
"""Headers to pass to the Anthropic clients, will be used for every API call."""
|
|
1433
|
+
|
|
1434
|
+
betas: list[str] | None = None
|
|
1435
|
+
"""List of beta features to enable. If specified, invocations will be routed
|
|
1436
|
+
through `client.beta.messages.create`.
|
|
1437
|
+
|
|
1438
|
+
Example: `#!python betas=["token-efficient-tools-2025-02-19"]`
|
|
1439
|
+
"""
|
|
1440
|
+
# Can also be passed in w/ model_kwargs, but having it as a param makes better devx
|
|
1441
|
+
#
|
|
1442
|
+
# Precedence order:
|
|
1443
|
+
# 1. Call-time kwargs (e.g., llm.invoke(..., betas=[...]))
|
|
1444
|
+
# 2. model_kwargs (e.g., ChatAnthropic(model_kwargs={"betas": [...]}))
|
|
1445
|
+
# 3. Direct parameter (e.g., ChatAnthropic(betas=[...]))
|
|
1446
|
+
|
|
1447
|
+
model_kwargs: dict[str, Any] = Field(default_factory=dict)
|
|
1448
|
+
|
|
1449
|
+
streaming: bool = False
|
|
1450
|
+
"""Whether to use streaming or not."""
|
|
1451
|
+
|
|
1452
|
+
stream_usage: bool = True
|
|
1453
|
+
"""Whether to include usage metadata in streaming output.
|
|
1454
|
+
|
|
1455
|
+
If `True`, additional message chunks will be generated during the stream including
|
|
1456
|
+
usage metadata.
|
|
1457
|
+
"""
|
|
1458
|
+
|
|
1459
|
+
thinking: dict[str, Any] | None = Field(default=None)
|
|
1460
|
+
"""Parameters for Claude reasoning.
|
|
1461
|
+
|
|
1462
|
+
Examples:
|
|
1463
|
+
|
|
1464
|
+
- `#!python {"type": "enabled", "budget_tokens": 10_000}` (pre-4.7 models)
|
|
1465
|
+
- `#!python {"type": "adaptive"}` (Opus 4.6+, Opus 5, Opus 5.5, Sonnet 5)
|
|
1466
|
+
- `#!python {"type": "adaptive", "display": "summarized"}` (Opus 4.7+,
|
|
1467
|
+
Opus 5, Opus 5.5, Sonnet 5)
|
|
1468
|
+
- `#!python {"type": "disabled"}` (Opus 5 and Sonnet 5, where adaptive
|
|
1469
|
+
thinking is on by default)
|
|
1470
|
+
|
|
1471
|
+
!!! note "Claude Opus 4.7+, Opus 5, Opus 5.5, and Sonnet 5"
|
|
1472
|
+
|
|
1473
|
+
`budget_tokens` is removed on these models — use `{"type": "adaptive"}`
|
|
1474
|
+
with `output_config.effort` to control reasoning effort. The default
|
|
1475
|
+
`display` is `"omitted"`; set it to `"summarized"` to receive
|
|
1476
|
+
summarized reasoning in the response. On Opus 5, disabled thinking is
|
|
1477
|
+
supported only at `"high"` effort or below. On Opus 5.5, thinking
|
|
1478
|
+
can't be disabled; omit `thinking` and use `output_config.effort`.
|
|
1479
|
+
"""
|
|
1480
|
+
|
|
1481
|
+
output_config: dict[str, Any] | None = None
|
|
1482
|
+
"""Configuration options for the model's output.
|
|
1483
|
+
|
|
1484
|
+
Supports the following keys:
|
|
1485
|
+
|
|
1486
|
+
- `effort`: Controls how many tokens Claude uses when responding.
|
|
1487
|
+
One of `"max"`, `"xhigh"`, `"high"`, `"medium"`, or `"low"`.
|
|
1488
|
+
- `format`: Structured output format configuration (typically set via
|
|
1489
|
+
`with_structured_output`).
|
|
1490
|
+
- `task_budget`: Advisory token budget for an agentic loop (beta).
|
|
1491
|
+
E.g., `#!python {"type": "tokens", "total": 128_000}`.
|
|
1492
|
+
|
|
1493
|
+
Example:
|
|
1494
|
+
|
|
1495
|
+
.. code-block:: python
|
|
1496
|
+
|
|
1497
|
+
ChatAnthropic(
|
|
1498
|
+
model="claude-opus-4-7",
|
|
1499
|
+
output_config={
|
|
1500
|
+
"effort": "xhigh",
|
|
1501
|
+
"task_budget": {"type": "tokens", "total": 128_000},
|
|
1502
|
+
},
|
|
1503
|
+
)
|
|
1504
|
+
|
|
1505
|
+
See Anthropic docs on
|
|
1506
|
+
[extended output](https://platform.claude.com/docs/en/api/go/beta/messages/create).
|
|
1507
|
+
"""
|
|
1508
|
+
|
|
1509
|
+
reasoning_effort: Literal["max", "xhigh", "high", "medium", "low"] | None = Field(
|
|
1510
|
+
default=None,
|
|
1511
|
+
alias="effort",
|
|
1512
|
+
)
|
|
1513
|
+
"""Reasoning effort.
|
|
1514
|
+
|
|
1515
|
+
Configures `output_config.effort`. If `thinking` isn't set explicitly,
|
|
1516
|
+
defaults it to `{"type": "adaptive", "display": "summarized"}`. Can also
|
|
1517
|
+
be passed at call time (for example,
|
|
1518
|
+
`model.invoke(..., reasoning_effort="high")`).
|
|
1519
|
+
|
|
1520
|
+
!!! note "`effort` alias"
|
|
1521
|
+
|
|
1522
|
+
`effort` is also accepted as an alias for this field, at both
|
|
1523
|
+
construction and call time. If both `effort` and `reasoning_effort` are
|
|
1524
|
+
set, `effort` wins (Pydantic's alias-resolution precedence).
|
|
1525
|
+
|
|
1526
|
+
!!! note
|
|
1527
|
+
|
|
1528
|
+
On most models, setting `reasoning_effort` to `'high'` produces exactly
|
|
1529
|
+
the same behavior as omitting the parameter altogether. On Opus 5.5 the
|
|
1530
|
+
default is `'medium'`.
|
|
1531
|
+
|
|
1532
|
+
Example: `reasoning_effort="medium"`
|
|
1533
|
+
"""
|
|
1534
|
+
|
|
1535
|
+
mcp_servers: list[dict[str, Any]] | None = None
|
|
1536
|
+
"""List of MCP servers to use for the request.
|
|
1537
|
+
|
|
1538
|
+
Example: `#!python mcp_servers=[{"type": "url", "url": "https://mcp.example.com/mcp",
|
|
1539
|
+
"name": "example-mcp"}]`
|
|
1540
|
+
"""
|
|
1541
|
+
|
|
1542
|
+
context_management: dict[str, Any] | None = None
|
|
1543
|
+
"""Configuration for
|
|
1544
|
+
[context management](https://platform.claude.com/docs/en/build-with-claude/context-editing).
|
|
1545
|
+
"""
|
|
1546
|
+
|
|
1547
|
+
container: dict[str, Any] | str | None = None
|
|
1548
|
+
"""Code execution container for the request.
|
|
1549
|
+
|
|
1550
|
+
Either a container ID from a previous response, or a dict of container
|
|
1551
|
+
parameters — notably
|
|
1552
|
+
[skills](https://platform.claude.com/docs/en/build-with-claude/skills-guide)
|
|
1553
|
+
to load into the container. Skills require a
|
|
1554
|
+
[code execution](https://docs.langchain.com/oss/python/integrations/chat/anthropic#code-execution)
|
|
1555
|
+
tool to be bound.
|
|
1556
|
+
|
|
1557
|
+
```python
|
|
1558
|
+
model = ChatAnthropic(
|
|
1559
|
+
model="claude-opus-5",
|
|
1560
|
+
container={
|
|
1561
|
+
"skills": [{"type": "anthropic", "skill_id": "pptx", "version": "latest"}]
|
|
1562
|
+
},
|
|
1563
|
+
).bind_tools([{"type": "code_execution_20260521", "name": "code_execution"}])
|
|
1564
|
+
```
|
|
1565
|
+
|
|
1566
|
+
Can also be passed at call time, which overrides the value set here.
|
|
1567
|
+
"""
|
|
1568
|
+
|
|
1569
|
+
reuse_last_container: bool | None = None
|
|
1570
|
+
"""Automatically reuse container from most recent response (code execution).
|
|
1571
|
+
|
|
1572
|
+
When using the built-in
|
|
1573
|
+
[code execution tool](https://docs.langchain.com/oss/python/integrations/chat/anthropic#code-execution),
|
|
1574
|
+
model responses will include container metadata. Set `reuse_last_container=True`
|
|
1575
|
+
to automatically reuse the container from the most recent response for subsequent
|
|
1576
|
+
invocations.
|
|
1577
|
+
"""
|
|
1578
|
+
|
|
1579
|
+
inference_geo: str | None = None
|
|
1580
|
+
"""Controls where model inference runs. See Anthropic's
|
|
1581
|
+
[data residency](https://platform.claude.com/docs/en/build-with-claude/data-residency)
|
|
1582
|
+
docs for more information.
|
|
1583
|
+
"""
|
|
1584
|
+
|
|
1585
|
+
user_profile_id: str | None = None
|
|
1586
|
+
"""User profile ID to attribute the request to.
|
|
1587
|
+
|
|
1588
|
+
Use when acting on behalf of a party other than your organization. Setting this
|
|
1589
|
+
automatically enables the required `user-profiles` beta, routing the request
|
|
1590
|
+
through `client.beta.messages.create`.
|
|
1591
|
+
|
|
1592
|
+
Can also be passed at call time, which overrides the value set here (for example,
|
|
1593
|
+
`model.invoke(..., user_profile_id="uprof_...")`).
|
|
1594
|
+
"""
|
|
1595
|
+
|
|
1596
|
+
@property
|
|
1597
|
+
def effort(self) -> Literal["max", "xhigh", "high", "medium", "low"] | None:
|
|
1598
|
+
"""Alias for `reasoning_effort`."""
|
|
1599
|
+
return self.reasoning_effort
|
|
1600
|
+
|
|
1601
|
+
@property
|
|
1602
|
+
def _llm_type(self) -> str:
|
|
1603
|
+
"""Return type of chat model."""
|
|
1604
|
+
return "anthropic-chat"
|
|
1605
|
+
|
|
1606
|
+
@property
|
|
1607
|
+
def _uses_gateway(self) -> bool:
|
|
1608
|
+
"""Whether requests are routed through the LangSmith gateway."""
|
|
1609
|
+
return self.anthropic_api_key.get_secret_value().startswith("lsv2_")
|
|
1610
|
+
|
|
1611
|
+
@property
|
|
1612
|
+
def lc_secrets(self) -> dict[str, str]:
|
|
1613
|
+
"""Return a mapping of secret keys to environment variables."""
|
|
1614
|
+
return {
|
|
1615
|
+
"anthropic_api_key": "ANTHROPIC_API_KEY",
|
|
1616
|
+
"mcp_servers": "ANTHROPIC_MCP_SERVERS",
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
@classmethod
|
|
1620
|
+
def is_lc_serializable(cls) -> bool:
|
|
1621
|
+
"""Whether the class is serializable in coco."""
|
|
1622
|
+
return True
|
|
1623
|
+
|
|
1624
|
+
@classmethod
|
|
1625
|
+
def get_lc_namespace(cls) -> list[str]:
|
|
1626
|
+
"""Get the namespace of the Coco object.
|
|
1627
|
+
|
|
1628
|
+
Returns:
|
|
1629
|
+
`["coco", "chat_models", "anthropic"]`
|
|
1630
|
+
"""
|
|
1631
|
+
return ["coco", "chat_models", "anthropic"]
|
|
1632
|
+
|
|
1633
|
+
@property
|
|
1634
|
+
def _identifying_params(self) -> dict[str, Any]:
|
|
1635
|
+
"""Get the identifying parameters."""
|
|
1636
|
+
return {
|
|
1637
|
+
"model": self.model,
|
|
1638
|
+
"max_tokens": self.max_tokens,
|
|
1639
|
+
"temperature": self.temperature,
|
|
1640
|
+
"top_k": self.top_k,
|
|
1641
|
+
"top_p": self.top_p,
|
|
1642
|
+
"model_kwargs": self.model_kwargs,
|
|
1643
|
+
"streaming": self.streaming,
|
|
1644
|
+
"max_retries": self.max_retries,
|
|
1645
|
+
"default_request_timeout": self.default_request_timeout,
|
|
1646
|
+
"thinking": self.thinking,
|
|
1647
|
+
"output_config": self.output_config,
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
def _get_ls_params(
|
|
1651
|
+
self,
|
|
1652
|
+
stop: list[str] | None = None,
|
|
1653
|
+
**kwargs: Any,
|
|
1654
|
+
) -> LangSmithParams:
|
|
1655
|
+
"""Get standard params for tracing."""
|
|
1656
|
+
params = self._get_invocation_params(stop=stop, **kwargs)
|
|
1657
|
+
ls_params = LangSmithParams(
|
|
1658
|
+
ls_provider="anthropic",
|
|
1659
|
+
ls_model_name=params.get("model", self.model),
|
|
1660
|
+
ls_model_type="chat",
|
|
1661
|
+
ls_temperature=params.get("temperature", self.temperature),
|
|
1662
|
+
)
|
|
1663
|
+
if ls_max_tokens := params.get("max_tokens", self.max_tokens):
|
|
1664
|
+
ls_params["ls_max_tokens"] = ls_max_tokens
|
|
1665
|
+
if ls_stop := stop or params.get("stop", None):
|
|
1666
|
+
ls_params["ls_stop"] = ls_stop
|
|
1667
|
+
return ls_params
|
|
1668
|
+
|
|
1669
|
+
@model_validator(mode="before")
|
|
1670
|
+
@classmethod
|
|
1671
|
+
def set_default_max_tokens(cls, values: dict[str, Any]) -> Any:
|
|
1672
|
+
"""Set default `max_tokens` from model profile with fallback."""
|
|
1673
|
+
if values.get("max_tokens") is None:
|
|
1674
|
+
model = values.get("model") or values.get("model_name")
|
|
1675
|
+
profile = _get_default_model_profile(model) if model else {}
|
|
1676
|
+
values["max_tokens"] = profile.get(
|
|
1677
|
+
"max_output_tokens", _FALLBACK_MAX_OUTPUT_TOKENS
|
|
1678
|
+
)
|
|
1679
|
+
return values
|
|
1680
|
+
|
|
1681
|
+
@model_validator(mode="before")
|
|
1682
|
+
@classmethod
|
|
1683
|
+
def build_extra(cls, values: dict) -> Any:
|
|
1684
|
+
"""Build model kwargs."""
|
|
1685
|
+
all_required_field_names = get_pydantic_field_names(cls)
|
|
1686
|
+
return _build_model_kwargs(values, all_required_field_names)
|
|
1687
|
+
|
|
1688
|
+
@model_validator(mode="after")
|
|
1689
|
+
def _set_anthropic_version(self) -> Self:
|
|
1690
|
+
"""Set package version in metadata."""
|
|
1691
|
+
self._add_version("trustcoco-anthropic", __version__)
|
|
1692
|
+
return self
|
|
1693
|
+
|
|
1694
|
+
@model_validator(mode="before")
|
|
1695
|
+
@classmethod
|
|
1696
|
+
def _resolve_gateway(cls, values: Any) -> Any:
|
|
1697
|
+
"""Resolve the base URL and API key, applying LangSmith gateway settings.
|
|
1698
|
+
|
|
1699
|
+
An explicit ``base_url``/``api_key`` always wins. Otherwise the base URL
|
|
1700
|
+
falls back to ``ANTHROPIC_API_URL``/``ANTHROPIC_BASE_URL``, then the
|
|
1701
|
+
LangSmith gateway, then the Anthropic default. The gateway key is
|
|
1702
|
+
preferred only when the base URL came from the gateway; for any other
|
|
1703
|
+
endpoint the provider key wins, and the gateway key is a candidate only
|
|
1704
|
+
when the gateway is enabled.
|
|
1705
|
+
"""
|
|
1706
|
+
if isinstance(values, dict):
|
|
1707
|
+
_apply_gateway_config(
|
|
1708
|
+
values,
|
|
1709
|
+
cls,
|
|
1710
|
+
base_url_field="anthropic_api_url",
|
|
1711
|
+
api_key_field="anthropic_api_key",
|
|
1712
|
+
provider_path="anthropic",
|
|
1713
|
+
base_url_env=["ANTHROPIC_API_URL", "ANTHROPIC_BASE_URL"],
|
|
1714
|
+
api_key_env="ANTHROPIC_API_KEY",
|
|
1715
|
+
default_base_url="https://api.anthropic.com",
|
|
1716
|
+
)
|
|
1717
|
+
return values
|
|
1718
|
+
|
|
1719
|
+
def _resolve_model_profile(self) -> ModelProfile | None:
|
|
1720
|
+
profile = _get_default_model_profile(self.model) or None
|
|
1721
|
+
if profile is not None and self.betas and "context-1m-2025-08-07" in self.betas:
|
|
1722
|
+
profile["max_input_tokens"] = 1_000_000
|
|
1723
|
+
return profile
|
|
1724
|
+
|
|
1725
|
+
@cached_property
|
|
1726
|
+
def _client_params(self) -> dict[str, Any]:
|
|
1727
|
+
# Merge User-Agent with user-provided headers (user headers take precedence)
|
|
1728
|
+
default_headers = {"User-Agent": _USER_AGENT}
|
|
1729
|
+
if self.default_headers:
|
|
1730
|
+
default_headers.update(self.default_headers)
|
|
1731
|
+
|
|
1732
|
+
client_params: dict[str, Any] = {
|
|
1733
|
+
"api_key": self.anthropic_api_key.get_secret_value(),
|
|
1734
|
+
"base_url": self.anthropic_api_url,
|
|
1735
|
+
"max_retries": self.max_retries,
|
|
1736
|
+
"default_headers": default_headers,
|
|
1737
|
+
}
|
|
1738
|
+
# value <= 0 indicates the param should be ignored. None is a meaningful value
|
|
1739
|
+
# for Anthropic client and treated differently than not specifying the param at
|
|
1740
|
+
# all.
|
|
1741
|
+
if self.default_request_timeout is None or self.default_request_timeout > 0:
|
|
1742
|
+
client_params["timeout"] = self.default_request_timeout
|
|
1743
|
+
|
|
1744
|
+
return client_params
|
|
1745
|
+
|
|
1746
|
+
@cached_property
|
|
1747
|
+
def _client(self) -> anthropic.Client:
|
|
1748
|
+
client_params = self._client_params
|
|
1749
|
+
http_client_params = {"base_url": client_params["base_url"]}
|
|
1750
|
+
if "timeout" in client_params:
|
|
1751
|
+
http_client_params["timeout"] = client_params["timeout"]
|
|
1752
|
+
if self.anthropic_proxy:
|
|
1753
|
+
http_client_params["anthropic_proxy"] = self.anthropic_proxy
|
|
1754
|
+
http_client = _get_default_httpx_client(**http_client_params)
|
|
1755
|
+
params = {
|
|
1756
|
+
**client_params,
|
|
1757
|
+
"http_client": http_client,
|
|
1758
|
+
}
|
|
1759
|
+
return anthropic.Client(**params)
|
|
1760
|
+
|
|
1761
|
+
@cached_property
|
|
1762
|
+
def _async_client(self) -> anthropic.AsyncClient:
|
|
1763
|
+
client_params = self._client_params
|
|
1764
|
+
http_client_params = {"base_url": client_params["base_url"]}
|
|
1765
|
+
if "timeout" in client_params:
|
|
1766
|
+
http_client_params["timeout"] = client_params["timeout"]
|
|
1767
|
+
if self.anthropic_proxy:
|
|
1768
|
+
http_client_params["anthropic_proxy"] = self.anthropic_proxy
|
|
1769
|
+
http_client = _get_default_async_httpx_client(**http_client_params)
|
|
1770
|
+
params = {
|
|
1771
|
+
**client_params,
|
|
1772
|
+
"http_client": http_client,
|
|
1773
|
+
}
|
|
1774
|
+
return anthropic.AsyncClient(**params)
|
|
1775
|
+
|
|
1776
|
+
def _assert_valid_model_configuration(self, kwargs: Mapping[str, Any]) -> None:
|
|
1777
|
+
"""Validate resolved request configuration against model-specific invariants."""
|
|
1778
|
+
request_config = {**self.model_kwargs, **kwargs}
|
|
1779
|
+
thinking = request_config.get("thinking")
|
|
1780
|
+
if self.thinking is not None:
|
|
1781
|
+
thinking = self.thinking
|
|
1782
|
+
|
|
1783
|
+
output_config = dict(self.output_config or {})
|
|
1784
|
+
if self.reasoning_effort:
|
|
1785
|
+
output_config["effort"] = self.reasoning_effort
|
|
1786
|
+
request_output_config = request_config.get("output_config")
|
|
1787
|
+
if isinstance(request_output_config, dict):
|
|
1788
|
+
output_config.update(request_output_config)
|
|
1789
|
+
effort = request_config.get("effort")
|
|
1790
|
+
if effort is None:
|
|
1791
|
+
effort = request_config.get("reasoning_effort")
|
|
1792
|
+
if effort:
|
|
1793
|
+
output_config["effort"] = effort
|
|
1794
|
+
|
|
1795
|
+
is_fable_model = self.model.startswith("claude-fable-5")
|
|
1796
|
+
if is_fable_model:
|
|
1797
|
+
top_k = request_config.get("top_k", self.top_k)
|
|
1798
|
+
top_p = request_config.get("top_p", self.top_p)
|
|
1799
|
+
temperature = request_config.get("temperature", self.temperature)
|
|
1800
|
+
if top_k is not None:
|
|
1801
|
+
msg = f"`top_k` is not supported for {self.model}."
|
|
1802
|
+
raise ValueError(msg)
|
|
1803
|
+
if top_p is not None and top_p != 1:
|
|
1804
|
+
msg = (
|
|
1805
|
+
f"`top_p` is not supported for {self.model} at non-default values."
|
|
1806
|
+
)
|
|
1807
|
+
raise ValueError(msg)
|
|
1808
|
+
if temperature is not None and temperature != 1:
|
|
1809
|
+
msg = (
|
|
1810
|
+
f"`temperature` is not supported for {self.model} at "
|
|
1811
|
+
"non-default values."
|
|
1812
|
+
)
|
|
1813
|
+
raise ValueError(msg)
|
|
1814
|
+
if isinstance(thinking, Mapping) and thinking.get("type") == "disabled":
|
|
1815
|
+
msg = (
|
|
1816
|
+
'`thinking={"type": "disabled"}` is not supported for '
|
|
1817
|
+
f"{self.model}; omit `thinking` to use adaptive thinking."
|
|
1818
|
+
)
|
|
1819
|
+
raise ValueError(msg)
|
|
1820
|
+
|
|
1821
|
+
if (
|
|
1822
|
+
(self.model.startswith("claude-opus-5") or is_fable_model)
|
|
1823
|
+
and isinstance(thinking, Mapping)
|
|
1824
|
+
and thinking.get("type") == "enabled"
|
|
1825
|
+
):
|
|
1826
|
+
msg = (
|
|
1827
|
+
'`thinking={"type": "enabled", "budget_tokens": ...}` is not '
|
|
1828
|
+
f"supported for {self.model}; use adaptive thinking and "
|
|
1829
|
+
"`output_config.effort` instead."
|
|
1830
|
+
)
|
|
1831
|
+
raise ValueError(msg)
|
|
1832
|
+
|
|
1833
|
+
if (
|
|
1834
|
+
self.model.startswith("claude-opus-5")
|
|
1835
|
+
and isinstance(thinking, Mapping)
|
|
1836
|
+
and thinking.get("type") == "disabled"
|
|
1837
|
+
and output_config.get("effort") in {"xhigh", "max"}
|
|
1838
|
+
):
|
|
1839
|
+
msg = (
|
|
1840
|
+
'`thinking={"type": "disabled"}` is not supported for '
|
|
1841
|
+
f"{self.model} with "
|
|
1842
|
+
f"`output_config.effort={output_config['effort']!r}`; use adaptive "
|
|
1843
|
+
"thinking, omit `thinking`, or set effort to `high` or below."
|
|
1844
|
+
)
|
|
1845
|
+
raise ValueError(msg)
|
|
1846
|
+
|
|
1847
|
+
def _get_request_payload(
|
|
1848
|
+
self,
|
|
1849
|
+
input_: LanguageModelInput,
|
|
1850
|
+
*,
|
|
1851
|
+
stop: list[str] | None = None,
|
|
1852
|
+
**kwargs: Any,
|
|
1853
|
+
) -> dict:
|
|
1854
|
+
"""Get the request payload for the Anthropic API."""
|
|
1855
|
+
self._assert_valid_model_configuration(kwargs)
|
|
1856
|
+
messages = self._convert_input(input_).to_messages()
|
|
1857
|
+
|
|
1858
|
+
for idx, message in enumerate(messages):
|
|
1859
|
+
# Translate v1 content
|
|
1860
|
+
if (
|
|
1861
|
+
isinstance(message, AIMessage)
|
|
1862
|
+
and message.response_metadata.get("output_version") == "v1"
|
|
1863
|
+
):
|
|
1864
|
+
tcs: list[types.ToolCall] = [
|
|
1865
|
+
{
|
|
1866
|
+
"type": "tool_call",
|
|
1867
|
+
"name": tool_call["name"],
|
|
1868
|
+
"args": tool_call["args"],
|
|
1869
|
+
"id": tool_call.get("id"),
|
|
1870
|
+
}
|
|
1871
|
+
for tool_call in message.tool_calls
|
|
1872
|
+
]
|
|
1873
|
+
messages[idx] = message.model_copy(
|
|
1874
|
+
update={
|
|
1875
|
+
"content": _convert_from_v1_to_anthropic(
|
|
1876
|
+
cast(list[types.ContentBlock], message.content),
|
|
1877
|
+
tcs,
|
|
1878
|
+
message.response_metadata.get("model_provider"),
|
|
1879
|
+
)
|
|
1880
|
+
}
|
|
1881
|
+
)
|
|
1882
|
+
|
|
1883
|
+
system, formatted_messages = _format_messages(messages, model=self.model)
|
|
1884
|
+
if isinstance(system, list) and not system:
|
|
1885
|
+
# Every block was narrowed away (or the message was empty to begin
|
|
1886
|
+
# with). An empty block array carries no instructions, so drop the
|
|
1887
|
+
# field rather than sending it. `_format_messages` has already
|
|
1888
|
+
# claimed the slot, so a second hoisted system message still errors.
|
|
1889
|
+
system = None
|
|
1890
|
+
|
|
1891
|
+
# Only the direct Anthropic API accepts top-level `cache_control`.
|
|
1892
|
+
# Subclasses that route through other transports (e.g. Bedrock) expand
|
|
1893
|
+
# `cache_control` kwargs into block-level breakpoints, the only form
|
|
1894
|
+
# those transports accept.
|
|
1895
|
+
if not _is_direct_anthropic_llm_type(getattr(self, "_llm_type", None)):
|
|
1896
|
+
cache_control = kwargs.pop("cache_control", None)
|
|
1897
|
+
# Empty `formatted_messages` has nothing to attach a breakpoint to;
|
|
1898
|
+
# skip silently. The warning below is reserved for the surprising
|
|
1899
|
+
# case where messages exist but every candidate block is ineligible.
|
|
1900
|
+
if cache_control and formatted_messages:
|
|
1901
|
+
code_execution_tool_ids = _collect_code_execution_tool_ids(
|
|
1902
|
+
formatted_messages
|
|
1903
|
+
)
|
|
1904
|
+
applied = _apply_cache_control_to_last_eligible_block(
|
|
1905
|
+
formatted_messages, cache_control, code_execution_tool_ids
|
|
1906
|
+
)
|
|
1907
|
+
if not applied:
|
|
1908
|
+
warnings.warn(
|
|
1909
|
+
"`cache_control` kwarg was dropped: no eligible "
|
|
1910
|
+
"content block found (all candidates are "
|
|
1911
|
+
"`code_execution`-related, which Anthropic forbids "
|
|
1912
|
+
"breakpoints on).",
|
|
1913
|
+
UserWarning,
|
|
1914
|
+
stacklevel=2,
|
|
1915
|
+
)
|
|
1916
|
+
|
|
1917
|
+
payload = {
|
|
1918
|
+
"model": self.model,
|
|
1919
|
+
"max_tokens": self.max_tokens,
|
|
1920
|
+
"messages": formatted_messages,
|
|
1921
|
+
"temperature": self.temperature,
|
|
1922
|
+
"top_k": self.top_k,
|
|
1923
|
+
"top_p": self.top_p,
|
|
1924
|
+
"stop_sequences": stop or self.stop_sequences,
|
|
1925
|
+
"betas": self.betas,
|
|
1926
|
+
"context_management": self.context_management,
|
|
1927
|
+
"mcp_servers": self.mcp_servers,
|
|
1928
|
+
"container": self.container,
|
|
1929
|
+
"user_profile_id": self.user_profile_id,
|
|
1930
|
+
"system": system,
|
|
1931
|
+
**self.model_kwargs,
|
|
1932
|
+
**kwargs,
|
|
1933
|
+
}
|
|
1934
|
+
# Captured before `self.thinking` is applied below, so a call-time
|
|
1935
|
+
# `thinking` kwarg counts as "explicitly set" too.
|
|
1936
|
+
thinking_explicitly_set = "thinking" in payload or self.thinking is not None
|
|
1937
|
+
if self.thinking is not None:
|
|
1938
|
+
payload["thinking"] = self.thinking
|
|
1939
|
+
if self.inference_geo is not None:
|
|
1940
|
+
payload["inference_geo"] = self.inference_geo
|
|
1941
|
+
if self.model.startswith("claude-fable-5"):
|
|
1942
|
+
payload.pop("temperature", None)
|
|
1943
|
+
payload.pop("top_k", None)
|
|
1944
|
+
payload.pop("top_p", None)
|
|
1945
|
+
|
|
1946
|
+
# Handle output_config and effort parameter
|
|
1947
|
+
# Priority: kwarg `effort`/`reasoning_effort` > kwarg `output_config`
|
|
1948
|
+
# > self.reasoning_effort > self.output_config
|
|
1949
|
+
output_config: dict[str, Any] = {}
|
|
1950
|
+
if self.output_config:
|
|
1951
|
+
output_config.update(self.output_config)
|
|
1952
|
+
reasoning_effort_applied = False
|
|
1953
|
+
if self.reasoning_effort:
|
|
1954
|
+
output_config["effort"] = self.reasoning_effort
|
|
1955
|
+
reasoning_effort_applied = True
|
|
1956
|
+
payload_oc = payload.get("output_config")
|
|
1957
|
+
if isinstance(payload_oc, dict):
|
|
1958
|
+
output_config.update(payload_oc)
|
|
1959
|
+
|
|
1960
|
+
# Neither `reasoning_effort` nor its `effort` alias are Anthropic API
|
|
1961
|
+
# fields. Pop them so they never leak through as top-level keys.
|
|
1962
|
+
effort_kwarg = payload.pop("effort", None)
|
|
1963
|
+
reasoning_effort_kwarg = payload.pop("reasoning_effort", None)
|
|
1964
|
+
# `effort` wins if both are set at call time, matching the
|
|
1965
|
+
# construction-time alias-resolution precedence (`Field(alias="effort")`).
|
|
1966
|
+
reasoning_effort_override = (
|
|
1967
|
+
effort_kwarg if effort_kwarg is not None else reasoning_effort_kwarg
|
|
1968
|
+
)
|
|
1969
|
+
if reasoning_effort_override:
|
|
1970
|
+
output_config["effort"] = reasoning_effort_override
|
|
1971
|
+
reasoning_effort_applied = True
|
|
1972
|
+
|
|
1973
|
+
if output_config:
|
|
1974
|
+
payload["output_config"] = output_config
|
|
1975
|
+
|
|
1976
|
+
# Default adaptive thinking when `reasoning_effort` is set, unless the
|
|
1977
|
+
# caller explicitly provided `thinking`. Gated on `xhigh` support: only
|
|
1978
|
+
# Opus 4.7+/Sonnet 5 accept the adaptive+summarized `thinking` shape —
|
|
1979
|
+
# sending it to an older model (e.g. Opus 4.5, 4.6) is rejected by the
|
|
1980
|
+
# API with "adaptive thinking is not supported on this model".
|
|
1981
|
+
if (
|
|
1982
|
+
reasoning_effort_applied
|
|
1983
|
+
and not thinking_explicitly_set
|
|
1984
|
+
and "xhigh" in _reasoning_effort_levels(self.profile)
|
|
1985
|
+
):
|
|
1986
|
+
payload["thinking"] = {"type": "adaptive", "display": "summarized"}
|
|
1987
|
+
|
|
1988
|
+
if "response_format" in payload:
|
|
1989
|
+
# response_format present when using agents.create_agent's ProviderStrategy
|
|
1990
|
+
# ---
|
|
1991
|
+
# ProviderStrategy converts to OpenAI-style format, which passes kwargs to
|
|
1992
|
+
# ChatAnthropic, ending up in our payload
|
|
1993
|
+
response_format = payload.pop("response_format")
|
|
1994
|
+
if (
|
|
1995
|
+
isinstance(response_format, dict)
|
|
1996
|
+
and response_format.get("type") == "json_schema"
|
|
1997
|
+
and "schema" in response_format.get("json_schema", {})
|
|
1998
|
+
):
|
|
1999
|
+
response_format = cast(dict, response_format["json_schema"]["schema"])
|
|
2000
|
+
# Convert OpenAI-style response_format to Anthropic's output_config.format
|
|
2001
|
+
output_config = payload.setdefault("output_config", {})
|
|
2002
|
+
output_config["format"] = _convert_to_anthropic_output_config_format(
|
|
2003
|
+
response_format
|
|
2004
|
+
)
|
|
2005
|
+
|
|
2006
|
+
# Handle deprecated output_format parameter for backward compatibility
|
|
2007
|
+
if "output_format" in payload:
|
|
2008
|
+
warnings.warn(
|
|
2009
|
+
"The 'output_format' parameter is deprecated and will be removed in "
|
|
2010
|
+
"trustcoco-anthropic 2.0.0. Use 'output_config={\"format\": ...}' "
|
|
2011
|
+
"instead.",
|
|
2012
|
+
DeprecationWarning,
|
|
2013
|
+
stacklevel=2,
|
|
2014
|
+
)
|
|
2015
|
+
output_config = payload.setdefault("output_config", {})
|
|
2016
|
+
output_config["format"] = payload.pop("output_format")
|
|
2017
|
+
|
|
2018
|
+
container = payload.get("container")
|
|
2019
|
+
|
|
2020
|
+
if self.reuse_last_container and not _container_id(container):
|
|
2021
|
+
# Reuse the container from the most recent response (code execution)
|
|
2022
|
+
for message in reversed(messages):
|
|
2023
|
+
if (
|
|
2024
|
+
isinstance(message, AIMessage)
|
|
2025
|
+
and isinstance(
|
|
2026
|
+
last_container := message.response_metadata.get("container"),
|
|
2027
|
+
dict,
|
|
2028
|
+
)
|
|
2029
|
+
and (container_id := last_container.get("id"))
|
|
2030
|
+
):
|
|
2031
|
+
payload["container"] = (
|
|
2032
|
+
{**container, "id": container_id}
|
|
2033
|
+
if isinstance(container, dict)
|
|
2034
|
+
else container_id
|
|
2035
|
+
)
|
|
2036
|
+
break
|
|
2037
|
+
|
|
2038
|
+
if (
|
|
2039
|
+
isinstance(container, dict)
|
|
2040
|
+
and container.get("skills")
|
|
2041
|
+
and not any(
|
|
2042
|
+
isinstance(tool, dict)
|
|
2043
|
+
and str(tool.get("type", "")).startswith("code_execution")
|
|
2044
|
+
for tool in (payload.get("tools") or [])
|
|
2045
|
+
)
|
|
2046
|
+
):
|
|
2047
|
+
warnings.warn(
|
|
2048
|
+
"Skills require a code execution tool to be bound, e.g. "
|
|
2049
|
+
'`bind_tools([{"type": "code_execution_20260521", '
|
|
2050
|
+
'"name": "code_execution"}])`.',
|
|
2051
|
+
UserWarning,
|
|
2052
|
+
stacklevel=2,
|
|
2053
|
+
)
|
|
2054
|
+
|
|
2055
|
+
# Note: Beta headers are no longer required for structured outputs
|
|
2056
|
+
# (output_config.format or strict tool use) as they are now generally available
|
|
2057
|
+
if "tools" in payload and isinstance(payload["tools"], list):
|
|
2058
|
+
# Auto-append required betas for specific tool types and input_examples
|
|
2059
|
+
has_input_examples = False
|
|
2060
|
+
for tool in payload["tools"]:
|
|
2061
|
+
if isinstance(tool, dict):
|
|
2062
|
+
tool_type = tool.get("type")
|
|
2063
|
+
if tool_type and tool_type in _TOOL_TYPE_TO_BETA:
|
|
2064
|
+
required_beta = _TOOL_TYPE_TO_BETA[tool_type]
|
|
2065
|
+
if payload["betas"]:
|
|
2066
|
+
if required_beta not in payload["betas"]:
|
|
2067
|
+
payload["betas"] = [
|
|
2068
|
+
*payload["betas"],
|
|
2069
|
+
required_beta,
|
|
2070
|
+
]
|
|
2071
|
+
else:
|
|
2072
|
+
payload["betas"] = [required_beta]
|
|
2073
|
+
# Check for input_examples
|
|
2074
|
+
if tool.get("input_examples"):
|
|
2075
|
+
has_input_examples = True
|
|
2076
|
+
|
|
2077
|
+
# Auto-append header for input_examples
|
|
2078
|
+
if has_input_examples:
|
|
2079
|
+
required_beta = "advanced-tool-use-2025-11-20"
|
|
2080
|
+
if payload["betas"]:
|
|
2081
|
+
if required_beta not in payload["betas"]:
|
|
2082
|
+
payload["betas"] = [*payload["betas"], required_beta]
|
|
2083
|
+
else:
|
|
2084
|
+
payload["betas"] = [required_beta]
|
|
2085
|
+
|
|
2086
|
+
# Auto-append required beta for mcp_servers
|
|
2087
|
+
if payload.get("mcp_servers"):
|
|
2088
|
+
required_beta = "mcp-client-2025-11-20"
|
|
2089
|
+
if payload["betas"]:
|
|
2090
|
+
# Append to existing betas if not already present
|
|
2091
|
+
if required_beta not in payload["betas"]:
|
|
2092
|
+
payload["betas"] = [*payload["betas"], required_beta]
|
|
2093
|
+
else:
|
|
2094
|
+
payload["betas"] = [required_beta]
|
|
2095
|
+
|
|
2096
|
+
# Auto-append required beta for task_budget
|
|
2097
|
+
resolved_oc = payload.get("output_config")
|
|
2098
|
+
if isinstance(resolved_oc, dict) and resolved_oc.get("task_budget"):
|
|
2099
|
+
required_beta = "task-budgets-2026-03-13"
|
|
2100
|
+
if payload.get("betas"):
|
|
2101
|
+
if required_beta not in payload["betas"]:
|
|
2102
|
+
payload["betas"] = [*payload["betas"], required_beta]
|
|
2103
|
+
else:
|
|
2104
|
+
payload["betas"] = [required_beta]
|
|
2105
|
+
|
|
2106
|
+
# Auto-append required beta for the `updates` thinking display mode
|
|
2107
|
+
thinking = payload.get("thinking")
|
|
2108
|
+
if isinstance(thinking, dict) and thinking.get("display") == "updates":
|
|
2109
|
+
required_beta = "thinking-display-updates-2026-08-18"
|
|
2110
|
+
if payload.get("betas"):
|
|
2111
|
+
if required_beta not in payload["betas"]:
|
|
2112
|
+
payload["betas"] = [*payload["betas"], required_beta]
|
|
2113
|
+
else:
|
|
2114
|
+
payload["betas"] = [required_beta]
|
|
2115
|
+
|
|
2116
|
+
system_contents = [
|
|
2117
|
+
message.get("content")
|
|
2118
|
+
for message in (payload.get("messages") or [])
|
|
2119
|
+
if message.get("role") == "system"
|
|
2120
|
+
]
|
|
2121
|
+
has_tool_change = any(_has_tool_change_block(c) for c in system_contents)
|
|
2122
|
+
has_inline_definition = any(
|
|
2123
|
+
_has_inline_tool_definition(c) for c in system_contents
|
|
2124
|
+
)
|
|
2125
|
+
explicit_betas = payload.get("betas") or []
|
|
2126
|
+
tool_change_beta = (
|
|
2127
|
+
_INLINE_TOOLS_BETA
|
|
2128
|
+
if has_inline_definition
|
|
2129
|
+
else (
|
|
2130
|
+
_MID_CONVERSATION_TOOL_CHANGES_BETA
|
|
2131
|
+
if has_tool_change and _INLINE_TOOLS_BETA not in explicit_betas
|
|
2132
|
+
else None
|
|
2133
|
+
)
|
|
2134
|
+
)
|
|
2135
|
+
if tool_change_beta and tool_change_beta not in explicit_betas:
|
|
2136
|
+
payload["betas"] = [*explicit_betas, tool_change_beta]
|
|
2137
|
+
|
|
2138
|
+
# Auto-append required beta for user_profile_id
|
|
2139
|
+
if payload.get("user_profile_id"):
|
|
2140
|
+
required_beta = "user-profiles-2026-03-24"
|
|
2141
|
+
if payload.get("betas"):
|
|
2142
|
+
if required_beta not in payload["betas"]:
|
|
2143
|
+
payload["betas"] = [*payload["betas"], required_beta]
|
|
2144
|
+
else:
|
|
2145
|
+
payload["betas"] = [required_beta]
|
|
2146
|
+
|
|
2147
|
+
return _route_unsupported_sampling_params(
|
|
2148
|
+
{k: v for k, v in payload.items() if v is not None}
|
|
2149
|
+
)
|
|
2150
|
+
|
|
2151
|
+
def _create(self, payload: dict) -> Any:
|
|
2152
|
+
try:
|
|
2153
|
+
if "betas" in payload:
|
|
2154
|
+
return self._client.beta.messages.with_raw_response.create(**payload)
|
|
2155
|
+
return self._client.messages.with_raw_response.create(**payload)
|
|
2156
|
+
except TypeError as e:
|
|
2157
|
+
_raise_if_authentication_error(e)
|
|
2158
|
+
raise
|
|
2159
|
+
|
|
2160
|
+
async def _acreate(self, payload: dict) -> Any:
|
|
2161
|
+
try:
|
|
2162
|
+
if "betas" in payload:
|
|
2163
|
+
return await self._async_client.beta.messages.with_raw_response.create(
|
|
2164
|
+
**payload
|
|
2165
|
+
)
|
|
2166
|
+
return await self._async_client.messages.with_raw_response.create(**payload)
|
|
2167
|
+
except TypeError as e:
|
|
2168
|
+
_raise_if_authentication_error(e)
|
|
2169
|
+
raise
|
|
2170
|
+
|
|
2171
|
+
def _stream(
|
|
2172
|
+
self,
|
|
2173
|
+
messages: list[BaseMessage],
|
|
2174
|
+
stop: list[str] | None = None,
|
|
2175
|
+
run_manager: CallbackManagerForLLMRun | None = None,
|
|
2176
|
+
*,
|
|
2177
|
+
stream_usage: bool | None = None,
|
|
2178
|
+
**kwargs: Any,
|
|
2179
|
+
) -> Iterator[ChatGenerationChunk]:
|
|
2180
|
+
if stream_usage is None:
|
|
2181
|
+
stream_usage = self.stream_usage
|
|
2182
|
+
kwargs["stream"] = True
|
|
2183
|
+
payload = self._get_request_payload(messages, stop=stop, **kwargs)
|
|
2184
|
+
try:
|
|
2185
|
+
raw_response = self._create(payload)
|
|
2186
|
+
base_generation_info: dict[str, Any] = {}
|
|
2187
|
+
if self._uses_gateway:
|
|
2188
|
+
_add_gateway_metadata(base_generation_info, raw_response)
|
|
2189
|
+
stream = raw_response.parse()
|
|
2190
|
+
coerce_content_to_string = (
|
|
2191
|
+
not _tools_in_params(payload)
|
|
2192
|
+
and not _documents_in_params(payload)
|
|
2193
|
+
and not _thinking_in_params(payload)
|
|
2194
|
+
and not _compact_in_params(payload)
|
|
2195
|
+
)
|
|
2196
|
+
block_start_event = None
|
|
2197
|
+
is_first_chunk = True
|
|
2198
|
+
for event in stream:
|
|
2199
|
+
msg, block_start_event = self._make_message_chunk_from_anthropic_event(
|
|
2200
|
+
event,
|
|
2201
|
+
stream_usage=stream_usage,
|
|
2202
|
+
coerce_content_to_string=coerce_content_to_string,
|
|
2203
|
+
block_start_event=block_start_event,
|
|
2204
|
+
)
|
|
2205
|
+
if msg is not None:
|
|
2206
|
+
chunk = ChatGenerationChunk(
|
|
2207
|
+
message=msg,
|
|
2208
|
+
generation_info=base_generation_info
|
|
2209
|
+
if is_first_chunk
|
|
2210
|
+
else None,
|
|
2211
|
+
)
|
|
2212
|
+
is_first_chunk = False
|
|
2213
|
+
if run_manager and isinstance(msg.content, str):
|
|
2214
|
+
run_manager.on_llm_new_token(msg.content, chunk=chunk)
|
|
2215
|
+
yield chunk
|
|
2216
|
+
except anthropic.BadRequestError as e:
|
|
2217
|
+
_handle_anthropic_bad_request(e)
|
|
2218
|
+
except anthropic.APIError as e:
|
|
2219
|
+
_handle_anthropic_api_error(e)
|
|
2220
|
+
|
|
2221
|
+
async def _astream(
|
|
2222
|
+
self,
|
|
2223
|
+
messages: list[BaseMessage],
|
|
2224
|
+
stop: list[str] | None = None,
|
|
2225
|
+
run_manager: AsyncCallbackManagerForLLMRun | None = None,
|
|
2226
|
+
*,
|
|
2227
|
+
stream_usage: bool | None = None,
|
|
2228
|
+
**kwargs: Any,
|
|
2229
|
+
) -> AsyncIterator[ChatGenerationChunk]:
|
|
2230
|
+
if stream_usage is None:
|
|
2231
|
+
stream_usage = self.stream_usage
|
|
2232
|
+
kwargs["stream"] = True
|
|
2233
|
+
payload = self._get_request_payload(messages, stop=stop, **kwargs)
|
|
2234
|
+
try:
|
|
2235
|
+
raw_response = await self._acreate(payload)
|
|
2236
|
+
base_generation_info: dict[str, Any] = {}
|
|
2237
|
+
if self._uses_gateway:
|
|
2238
|
+
_add_gateway_metadata(base_generation_info, raw_response)
|
|
2239
|
+
stream = await _aparse(raw_response)
|
|
2240
|
+
coerce_content_to_string = (
|
|
2241
|
+
not _tools_in_params(payload)
|
|
2242
|
+
and not _documents_in_params(payload)
|
|
2243
|
+
and not _thinking_in_params(payload)
|
|
2244
|
+
and not _compact_in_params(payload)
|
|
2245
|
+
)
|
|
2246
|
+
block_start_event = None
|
|
2247
|
+
is_first_chunk = True
|
|
2248
|
+
async for event in stream:
|
|
2249
|
+
msg, block_start_event = self._make_message_chunk_from_anthropic_event(
|
|
2250
|
+
event,
|
|
2251
|
+
stream_usage=stream_usage,
|
|
2252
|
+
coerce_content_to_string=coerce_content_to_string,
|
|
2253
|
+
block_start_event=block_start_event,
|
|
2254
|
+
)
|
|
2255
|
+
if msg is not None:
|
|
2256
|
+
chunk = ChatGenerationChunk(
|
|
2257
|
+
message=msg,
|
|
2258
|
+
generation_info=base_generation_info
|
|
2259
|
+
if is_first_chunk
|
|
2260
|
+
else None,
|
|
2261
|
+
)
|
|
2262
|
+
is_first_chunk = False
|
|
2263
|
+
if run_manager and isinstance(msg.content, str):
|
|
2264
|
+
await run_manager.on_llm_new_token(msg.content, chunk=chunk)
|
|
2265
|
+
yield chunk
|
|
2266
|
+
except anthropic.BadRequestError as e:
|
|
2267
|
+
_handle_anthropic_bad_request(e)
|
|
2268
|
+
except anthropic.APIError as e:
|
|
2269
|
+
_handle_anthropic_api_error(e)
|
|
2270
|
+
|
|
2271
|
+
def _make_message_chunk_from_anthropic_event(
|
|
2272
|
+
self,
|
|
2273
|
+
event: anthropic.types.RawMessageStreamEvent,
|
|
2274
|
+
*,
|
|
2275
|
+
stream_usage: bool = True,
|
|
2276
|
+
coerce_content_to_string: bool,
|
|
2277
|
+
block_start_event: anthropic.types.RawMessageStreamEvent | None = None,
|
|
2278
|
+
) -> tuple[AIMessageChunk | None, anthropic.types.RawMessageStreamEvent | None]:
|
|
2279
|
+
"""Convert Anthropic streaming event to `AIMessageChunk`.
|
|
2280
|
+
|
|
2281
|
+
Args:
|
|
2282
|
+
event: Raw streaming event from Anthropic SDK
|
|
2283
|
+
stream_usage: Whether to include usage metadata in the output chunks.
|
|
2284
|
+
coerce_content_to_string: Whether to convert structured content to plain
|
|
2285
|
+
text strings.
|
|
2286
|
+
|
|
2287
|
+
When `True`, only text content is preserved; when `False`, structured
|
|
2288
|
+
content like tool calls and citations are maintained.
|
|
2289
|
+
block_start_event: Previous content block start event, used for tracking
|
|
2290
|
+
tool use blocks and maintaining context across related events.
|
|
2291
|
+
|
|
2292
|
+
Returns:
|
|
2293
|
+
Tuple with
|
|
2294
|
+
- `AIMessageChunk`: Converted message chunk with appropriate content and
|
|
2295
|
+
metadata, or `None` if the event doesn't produce a chunk
|
|
2296
|
+
- `RawMessageStreamEvent`: Updated `block_start_event` for tracking
|
|
2297
|
+
content blocks across sequential events, or `None` if not applicable
|
|
2298
|
+
|
|
2299
|
+
Note:
|
|
2300
|
+
Not all Anthropic events result in message chunks. Events like internal
|
|
2301
|
+
state changes return `None` for the message chunk while potentially
|
|
2302
|
+
updating the `block_start_event` for context tracking.
|
|
2303
|
+
"""
|
|
2304
|
+
message_chunk: AIMessageChunk | None = None
|
|
2305
|
+
# Reference: Anthropic SDK streaming implementation
|
|
2306
|
+
# https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/lib/streaming/_messages.py # noqa: E501
|
|
2307
|
+
if event.type == "message_start" and stream_usage:
|
|
2308
|
+
# Capture model name, but don't include usage_metadata yet
|
|
2309
|
+
# as it will be properly reported in message_delta with complete info
|
|
2310
|
+
if hasattr(event.message, "model"):
|
|
2311
|
+
response_metadata: dict[str, Any] = {"model_name": event.message.model}
|
|
2312
|
+
else:
|
|
2313
|
+
response_metadata = {}
|
|
2314
|
+
|
|
2315
|
+
message_chunk = AIMessageChunk(
|
|
2316
|
+
content="" if coerce_content_to_string else [],
|
|
2317
|
+
response_metadata=response_metadata,
|
|
2318
|
+
)
|
|
2319
|
+
|
|
2320
|
+
elif (
|
|
2321
|
+
event.type == "content_block_start"
|
|
2322
|
+
and event.content_block is not None
|
|
2323
|
+
and (
|
|
2324
|
+
"tool_result" in event.content_block.type
|
|
2325
|
+
or "tool_use" in event.content_block.type
|
|
2326
|
+
or "document" in event.content_block.type
|
|
2327
|
+
or "redacted_thinking" in event.content_block.type
|
|
2328
|
+
)
|
|
2329
|
+
):
|
|
2330
|
+
if coerce_content_to_string:
|
|
2331
|
+
warnings.warn("Received unexpected tool content block.", stacklevel=2)
|
|
2332
|
+
|
|
2333
|
+
content_block = event.content_block.model_dump()
|
|
2334
|
+
if "caller" in content_block and content_block["caller"] is None:
|
|
2335
|
+
content_block.pop("caller")
|
|
2336
|
+
content_block["index"] = event.index
|
|
2337
|
+
if event.content_block.type == "tool_use":
|
|
2338
|
+
if (
|
|
2339
|
+
parsed_args := getattr(event.content_block, "input", None)
|
|
2340
|
+
) and isinstance(parsed_args, dict):
|
|
2341
|
+
# In some cases parsed args are represented in start event, with no
|
|
2342
|
+
# following input_json_delta events
|
|
2343
|
+
args = json.dumps(parsed_args)
|
|
2344
|
+
else:
|
|
2345
|
+
args = ""
|
|
2346
|
+
tool_call_chunk = create_tool_call_chunk(
|
|
2347
|
+
index=event.index,
|
|
2348
|
+
id=event.content_block.id,
|
|
2349
|
+
name=event.content_block.name,
|
|
2350
|
+
args=args,
|
|
2351
|
+
)
|
|
2352
|
+
tool_call_chunks = [tool_call_chunk]
|
|
2353
|
+
else:
|
|
2354
|
+
tool_call_chunks = []
|
|
2355
|
+
message_chunk = AIMessageChunk(
|
|
2356
|
+
content=[content_block],
|
|
2357
|
+
tool_call_chunks=tool_call_chunks,
|
|
2358
|
+
)
|
|
2359
|
+
block_start_event = event
|
|
2360
|
+
|
|
2361
|
+
elif (
|
|
2362
|
+
event.type == "content_block_start"
|
|
2363
|
+
and event.content_block is not None
|
|
2364
|
+
and event.content_block.type in ("text", "thinking")
|
|
2365
|
+
):
|
|
2366
|
+
# Anthropic can place the opening content of a text or thinking block
|
|
2367
|
+
# directly on the `content_block_start` event instead of in a
|
|
2368
|
+
# following delta. This is common for the assistant turn that follows
|
|
2369
|
+
# a tool result. Emit that initial content here so it is not dropped
|
|
2370
|
+
# from the aggregated message. The deltas that follow are emitted as
|
|
2371
|
+
# separate chunks sharing this block's `index`; chunk addition
|
|
2372
|
+
# (`AIMessageChunk.__add__`) later coalesces them into one block.
|
|
2373
|
+
block_start_event = event
|
|
2374
|
+
if event.content_block.type == "text":
|
|
2375
|
+
text = getattr(event.content_block, "text", "") or ""
|
|
2376
|
+
if text:
|
|
2377
|
+
if coerce_content_to_string:
|
|
2378
|
+
message_chunk = AIMessageChunk(content=text)
|
|
2379
|
+
else:
|
|
2380
|
+
content_block = event.content_block.model_dump()
|
|
2381
|
+
content_block["index"] = event.index
|
|
2382
|
+
if content_block.get("citations") is None:
|
|
2383
|
+
content_block.pop("citations", None)
|
|
2384
|
+
message_chunk = AIMessageChunk(content=[content_block])
|
|
2385
|
+
else: # thinking
|
|
2386
|
+
thinking = getattr(event.content_block, "thinking", "") or ""
|
|
2387
|
+
signature = getattr(event.content_block, "signature", "") or ""
|
|
2388
|
+
if thinking or signature:
|
|
2389
|
+
content_block = event.content_block.model_dump()
|
|
2390
|
+
content_block["index"] = event.index
|
|
2391
|
+
content_block["type"] = "thinking"
|
|
2392
|
+
message_chunk = AIMessageChunk(content=[content_block])
|
|
2393
|
+
|
|
2394
|
+
# Process incremental content updates
|
|
2395
|
+
elif event.type == "content_block_delta":
|
|
2396
|
+
# Text and citation deltas (incremental text content)
|
|
2397
|
+
if event.delta.type in ("text_delta", "citations_delta"):
|
|
2398
|
+
if coerce_content_to_string and hasattr(event.delta, "text"):
|
|
2399
|
+
text = getattr(event.delta, "text", "")
|
|
2400
|
+
message_chunk = AIMessageChunk(content=text)
|
|
2401
|
+
else:
|
|
2402
|
+
content_block = event.delta.model_dump()
|
|
2403
|
+
content_block["index"] = event.index
|
|
2404
|
+
|
|
2405
|
+
# All citation deltas are part of a text block
|
|
2406
|
+
content_block["type"] = "text"
|
|
2407
|
+
if "citation" in content_block:
|
|
2408
|
+
# Assign citations to a list if present
|
|
2409
|
+
content_block["citations"] = [content_block.pop("citation")]
|
|
2410
|
+
message_chunk = AIMessageChunk(content=[content_block])
|
|
2411
|
+
|
|
2412
|
+
# Reasoning
|
|
2413
|
+
elif event.delta.type in {"thinking_delta", "signature_delta"}:
|
|
2414
|
+
content_block = event.delta.model_dump()
|
|
2415
|
+
content_block["index"] = event.index
|
|
2416
|
+
content_block["type"] = "thinking"
|
|
2417
|
+
if event.delta.type == "signature_delta":
|
|
2418
|
+
content_block.setdefault("thinking", "")
|
|
2419
|
+
message_chunk = AIMessageChunk(content=[content_block])
|
|
2420
|
+
|
|
2421
|
+
# Tool input JSON (streaming tool arguments)
|
|
2422
|
+
elif event.delta.type == "input_json_delta":
|
|
2423
|
+
content_block = event.delta.model_dump()
|
|
2424
|
+
content_block["index"] = event.index
|
|
2425
|
+
start_event_block = (
|
|
2426
|
+
getattr(block_start_event, "content_block", None)
|
|
2427
|
+
if block_start_event
|
|
2428
|
+
else None
|
|
2429
|
+
)
|
|
2430
|
+
if (
|
|
2431
|
+
start_event_block is not None
|
|
2432
|
+
and getattr(start_event_block, "type", None) == "tool_use"
|
|
2433
|
+
):
|
|
2434
|
+
tool_call_chunk = create_tool_call_chunk(
|
|
2435
|
+
index=event.index,
|
|
2436
|
+
id=None,
|
|
2437
|
+
name=None,
|
|
2438
|
+
args=event.delta.partial_json,
|
|
2439
|
+
)
|
|
2440
|
+
tool_call_chunks = [tool_call_chunk]
|
|
2441
|
+
else:
|
|
2442
|
+
tool_call_chunks = []
|
|
2443
|
+
message_chunk = AIMessageChunk(
|
|
2444
|
+
content=[content_block],
|
|
2445
|
+
tool_call_chunks=tool_call_chunks,
|
|
2446
|
+
)
|
|
2447
|
+
|
|
2448
|
+
# Compaction block
|
|
2449
|
+
elif event.delta.type == "compaction_delta":
|
|
2450
|
+
content_block = event.delta.model_dump()
|
|
2451
|
+
content_block["index"] = event.index
|
|
2452
|
+
content_block["type"] = "compaction"
|
|
2453
|
+
if (
|
|
2454
|
+
"encrypted_content" in content_block
|
|
2455
|
+
and content_block["encrypted_content"] is None
|
|
2456
|
+
):
|
|
2457
|
+
content_block.pop("encrypted_content")
|
|
2458
|
+
message_chunk = AIMessageChunk(content=[content_block])
|
|
2459
|
+
|
|
2460
|
+
# Process final usage metadata and completion info
|
|
2461
|
+
elif event.type == "message_delta" and stream_usage:
|
|
2462
|
+
usage_metadata = _create_usage_metadata(event.usage)
|
|
2463
|
+
response_metadata = {
|
|
2464
|
+
"stop_reason": event.delta.stop_reason,
|
|
2465
|
+
"stop_sequence": event.delta.stop_sequence,
|
|
2466
|
+
}
|
|
2467
|
+
if context_management := getattr(event, "context_management", None):
|
|
2468
|
+
response_metadata["context_management"] = (
|
|
2469
|
+
context_management.model_dump()
|
|
2470
|
+
)
|
|
2471
|
+
message_delta = getattr(event, "delta", None)
|
|
2472
|
+
if message_delta and (
|
|
2473
|
+
container := getattr(message_delta, "container", None)
|
|
2474
|
+
):
|
|
2475
|
+
response_metadata["container"] = container.model_dump(mode="json")
|
|
2476
|
+
message_chunk = AIMessageChunk(
|
|
2477
|
+
content="" if coerce_content_to_string else [],
|
|
2478
|
+
usage_metadata=usage_metadata,
|
|
2479
|
+
response_metadata=response_metadata,
|
|
2480
|
+
)
|
|
2481
|
+
if message_chunk.response_metadata.get("stop_reason"):
|
|
2482
|
+
# Mark final Anthropic stream chunk
|
|
2483
|
+
message_chunk.chunk_position = "last"
|
|
2484
|
+
# Unhandled event types (e.g., `content_block_stop`, `ping` events)
|
|
2485
|
+
# https://platform.claude.com/docs/en/build-with-claude/streaming#other-events
|
|
2486
|
+
else:
|
|
2487
|
+
pass
|
|
2488
|
+
|
|
2489
|
+
if message_chunk:
|
|
2490
|
+
message_chunk.response_metadata["model_provider"] = "anthropic"
|
|
2491
|
+
return message_chunk, block_start_event
|
|
2492
|
+
|
|
2493
|
+
def _format_output(
|
|
2494
|
+
self,
|
|
2495
|
+
data: Any,
|
|
2496
|
+
*,
|
|
2497
|
+
generation_info: dict[str, Any] | None = None,
|
|
2498
|
+
**kwargs: Any,
|
|
2499
|
+
) -> ChatResult:
|
|
2500
|
+
"""Format the output from the Anthropic API to LC."""
|
|
2501
|
+
data_dict = data.model_dump()
|
|
2502
|
+
content = data_dict["content"]
|
|
2503
|
+
|
|
2504
|
+
# Remove citations if they are None - introduced in anthropic sdk 0.45
|
|
2505
|
+
for block in content:
|
|
2506
|
+
if isinstance(block, dict):
|
|
2507
|
+
if "citations" in block and block["citations"] is None:
|
|
2508
|
+
block.pop("citations")
|
|
2509
|
+
if "caller" in block and block["caller"] is None:
|
|
2510
|
+
block.pop("caller")
|
|
2511
|
+
if "encrypted_content" in block and block["encrypted_content"] is None:
|
|
2512
|
+
block.pop("encrypted_content")
|
|
2513
|
+
if (
|
|
2514
|
+
block.get("type") == "thinking"
|
|
2515
|
+
and "text" in block
|
|
2516
|
+
and block["text"] is None
|
|
2517
|
+
):
|
|
2518
|
+
block.pop("text")
|
|
2519
|
+
|
|
2520
|
+
llm_output = {
|
|
2521
|
+
k: v for k, v in data_dict.items() if k not in ("content", "role", "type")
|
|
2522
|
+
}
|
|
2523
|
+
if (
|
|
2524
|
+
(container := llm_output.get("container"))
|
|
2525
|
+
and isinstance(container, dict)
|
|
2526
|
+
and (expires_at := container.get("expires_at"))
|
|
2527
|
+
and isinstance(expires_at, datetime.datetime)
|
|
2528
|
+
):
|
|
2529
|
+
# TODO: dump all `data` with `mode="json"`
|
|
2530
|
+
llm_output["container"]["expires_at"] = expires_at.isoformat()
|
|
2531
|
+
response_metadata = {"model_provider": "anthropic"}
|
|
2532
|
+
if "model" in llm_output and "model_name" not in llm_output:
|
|
2533
|
+
llm_output["model_name"] = llm_output["model"]
|
|
2534
|
+
if (
|
|
2535
|
+
len(content) == 1
|
|
2536
|
+
and content[0]["type"] == "text"
|
|
2537
|
+
and not content[0].get("citations")
|
|
2538
|
+
):
|
|
2539
|
+
msg = AIMessage(
|
|
2540
|
+
content=content[0]["text"], response_metadata=response_metadata
|
|
2541
|
+
)
|
|
2542
|
+
elif any(block["type"] == "tool_use" for block in content):
|
|
2543
|
+
tool_calls = extract_tool_calls(content)
|
|
2544
|
+
msg = AIMessage(
|
|
2545
|
+
content=content,
|
|
2546
|
+
tool_calls=tool_calls,
|
|
2547
|
+
response_metadata=response_metadata,
|
|
2548
|
+
)
|
|
2549
|
+
else:
|
|
2550
|
+
msg = AIMessage(content=content, response_metadata=response_metadata)
|
|
2551
|
+
msg.usage_metadata = _create_usage_metadata(data.usage)
|
|
2552
|
+
return ChatResult(
|
|
2553
|
+
generations=[
|
|
2554
|
+
ChatGeneration(message=msg, generation_info=generation_info or None)
|
|
2555
|
+
],
|
|
2556
|
+
llm_output=llm_output,
|
|
2557
|
+
)
|
|
2558
|
+
|
|
2559
|
+
def _generate(
|
|
2560
|
+
self,
|
|
2561
|
+
messages: list[BaseMessage],
|
|
2562
|
+
stop: list[str] | None = None,
|
|
2563
|
+
run_manager: CallbackManagerForLLMRun | None = None,
|
|
2564
|
+
**kwargs: Any,
|
|
2565
|
+
) -> ChatResult:
|
|
2566
|
+
payload = self._get_request_payload(messages, stop=stop, **kwargs)
|
|
2567
|
+
try:
|
|
2568
|
+
raw_response = self._create(payload)
|
|
2569
|
+
except anthropic.BadRequestError as e:
|
|
2570
|
+
_handle_anthropic_bad_request(e)
|
|
2571
|
+
except anthropic.APIError as e:
|
|
2572
|
+
_handle_anthropic_api_error(e)
|
|
2573
|
+
generation_info: dict[str, Any] = {}
|
|
2574
|
+
if self._uses_gateway:
|
|
2575
|
+
_add_gateway_metadata(generation_info, raw_response)
|
|
2576
|
+
return self._format_output(
|
|
2577
|
+
raw_response.parse(), generation_info=generation_info, **kwargs
|
|
2578
|
+
)
|
|
2579
|
+
|
|
2580
|
+
async def _agenerate(
|
|
2581
|
+
self,
|
|
2582
|
+
messages: list[BaseMessage],
|
|
2583
|
+
stop: list[str] | None = None,
|
|
2584
|
+
run_manager: AsyncCallbackManagerForLLMRun | None = None,
|
|
2585
|
+
**kwargs: Any,
|
|
2586
|
+
) -> ChatResult:
|
|
2587
|
+
payload = self._get_request_payload(messages, stop=stop, **kwargs)
|
|
2588
|
+
try:
|
|
2589
|
+
raw_response = await self._acreate(payload)
|
|
2590
|
+
except anthropic.BadRequestError as e:
|
|
2591
|
+
_handle_anthropic_bad_request(e)
|
|
2592
|
+
except anthropic.APIError as e:
|
|
2593
|
+
_handle_anthropic_api_error(e)
|
|
2594
|
+
generation_info: dict[str, Any] = {}
|
|
2595
|
+
if self._uses_gateway:
|
|
2596
|
+
_add_gateway_metadata(generation_info, raw_response)
|
|
2597
|
+
return self._format_output(
|
|
2598
|
+
await _aparse(raw_response), generation_info=generation_info, **kwargs
|
|
2599
|
+
)
|
|
2600
|
+
|
|
2601
|
+
def _get_llm_for_structured_output_when_thinking_is_enabled(
|
|
2602
|
+
self,
|
|
2603
|
+
schema: dict | type,
|
|
2604
|
+
formatted_tool: AnthropicTool,
|
|
2605
|
+
) -> Runnable[LanguageModelInput, BaseMessage]:
|
|
2606
|
+
thinking_admonition = (
|
|
2607
|
+
"You are attempting to use structured output via forced tool calling, "
|
|
2608
|
+
"which is not supported when `thinking` is enabled or on "
|
|
2609
|
+
f"{self.model}. This method will raise an OutputParserException if tool "
|
|
2610
|
+
"calls are not generated. Consider `method='json_schema'`, disabling "
|
|
2611
|
+
"`thinking` where supported, or adjusting your prompt to ensure the "
|
|
2612
|
+
"tool is called."
|
|
2613
|
+
)
|
|
2614
|
+
warnings.warn(thinking_admonition, stacklevel=2)
|
|
2615
|
+
llm = self.bind_tools(
|
|
2616
|
+
[schema],
|
|
2617
|
+
# We don't specify tool_choice here since the API will reject attempts to
|
|
2618
|
+
# force tool calls when thinking=true
|
|
2619
|
+
ls_structured_output_format={
|
|
2620
|
+
"kwargs": {"method": "function_calling"},
|
|
2621
|
+
"schema": formatted_tool,
|
|
2622
|
+
},
|
|
2623
|
+
)
|
|
2624
|
+
|
|
2625
|
+
def _raise_if_no_tool_calls(message: AIMessage) -> AIMessage:
|
|
2626
|
+
if not message.tool_calls:
|
|
2627
|
+
raise OutputParserException(thinking_admonition)
|
|
2628
|
+
return message
|
|
2629
|
+
|
|
2630
|
+
return llm | _raise_if_no_tool_calls
|
|
2631
|
+
|
|
2632
|
+
def bind_tools(
|
|
2633
|
+
self,
|
|
2634
|
+
tools: Sequence[Mapping[str, Any] | type | Callable | BaseTool],
|
|
2635
|
+
*,
|
|
2636
|
+
tool_choice: dict[str, str] | str | None = None,
|
|
2637
|
+
parallel_tool_calls: bool | None = None,
|
|
2638
|
+
strict: bool | None = None,
|
|
2639
|
+
**kwargs: Any,
|
|
2640
|
+
) -> Runnable[LanguageModelInput, AIMessage]:
|
|
2641
|
+
r"""Bind tool-like objects to `ChatAnthropic`.
|
|
2642
|
+
|
|
2643
|
+
Args:
|
|
2644
|
+
tools: A list of tool definitions to bind to this chat model.
|
|
2645
|
+
|
|
2646
|
+
Supports Anthropic format tool schemas and any tool definition handled
|
|
2647
|
+
by [`convert_to_openai_tool`][coco_core.utils.function_calling.convert_to_openai_tool].
|
|
2648
|
+
tool_choice: Which tool to require the model to call. Options are:
|
|
2649
|
+
|
|
2650
|
+
- Name of the tool as a string or as dict `{"type": "tool", "name": "<<tool_name>>"}`: calls corresponding tool
|
|
2651
|
+
- `'auto'`, `{"type: "auto"}`, or `None`: automatically selects a tool (including no tool)
|
|
2652
|
+
- `'any'` or `{"type: "any"}`: force at least one tool to be called
|
|
2653
|
+
parallel_tool_calls: Set to `False` to disable parallel tool use.
|
|
2654
|
+
|
|
2655
|
+
Defaults to `None` (no specification, which allows parallel tool use).
|
|
2656
|
+
|
|
2657
|
+
!!! version-added "Added in `trustcoco-anthropic` 0.3.2"
|
|
2658
|
+
strict: If `True`, Claude's schema adherence is applied to tool calls.
|
|
2659
|
+
|
|
2660
|
+
See the [docs](https://docs.langchain.com/oss/python/integrations/chat/anthropic#strict-tool-use) for more info.
|
|
2661
|
+
kwargs: Any additional parameters are passed directly to `bind`.
|
|
2662
|
+
|
|
2663
|
+
Raises:
|
|
2664
|
+
ValueError: If every tool in `tools` was dropped for using top-level
|
|
2665
|
+
schema composition, leaving the model with no callable tool.
|
|
2666
|
+
ValueError: If `tool_choice` forces tool use (a specific tool, or
|
|
2667
|
+
`'any'`) while any tool was dropped, since the forced tool may be
|
|
2668
|
+
unreachable. Does not apply when `thinking` is enabled, as the
|
|
2669
|
+
forced choice is discarded before the request is sent.
|
|
2670
|
+
ValueError: If `tool_choice` is neither a `dict`, a `str`, nor `None`.
|
|
2671
|
+
|
|
2672
|
+
Example:
|
|
2673
|
+
```python
|
|
2674
|
+
from coco_anthropic import ChatAnthropic
|
|
2675
|
+
from pydantic import BaseModel, Field
|
|
2676
|
+
|
|
2677
|
+
|
|
2678
|
+
class GetWeather(BaseModel):
|
|
2679
|
+
'''Get the current weather in a given location'''
|
|
2680
|
+
|
|
2681
|
+
location: str = Field(..., description="The city and state, e.g. San Francisco, CA")
|
|
2682
|
+
|
|
2683
|
+
|
|
2684
|
+
class GetPrice(BaseModel):
|
|
2685
|
+
'''Get the price of a specific product.'''
|
|
2686
|
+
|
|
2687
|
+
product: str = Field(..., description="The product to look up.")
|
|
2688
|
+
|
|
2689
|
+
|
|
2690
|
+
model = ChatAnthropic(model="claude-sonnet-4-5-20250929", temperature=0)
|
|
2691
|
+
model_with_tools = model.bind_tools([GetWeather, GetPrice])
|
|
2692
|
+
model_with_tools.invoke(
|
|
2693
|
+
"What is the weather like in San Francisco",
|
|
2694
|
+
)
|
|
2695
|
+
# -> AIMessage(
|
|
2696
|
+
# content=[
|
|
2697
|
+
# {'text': '<thinking>\nBased on the user\'s question, the relevant function to call is GetWeather, which requires the "location" parameter.\n\nThe user has directly specified the location as "San Francisco". Since San Francisco is a well known city, I can reasonably infer they mean San Francisco, CA without needing the state specified.\n\nAll the required parameters are provided, so I can proceed with the API call.\n</thinking>', 'type': 'text'},
|
|
2698
|
+
# {'text': None, 'type': 'tool_use', 'id': 'toolu_01SCgExKzQ7eqSkMHfygvYuu', 'name': 'GetWeather', 'input': {'location': 'San Francisco, CA'}}
|
|
2699
|
+
# ],
|
|
2700
|
+
# response_metadata={'id': 'msg_01GM3zQtoFv8jGQMW7abLnhi', 'model': 'claude-sonnet-4-5-20250929', 'stop_reason': 'tool_use', 'stop_sequence': None, 'usage': {'input_tokens': 487, 'output_tokens': 145}},
|
|
2701
|
+
# id='run-87b1331e-9251-4a68-acef-f0a018b639cc-0'
|
|
2702
|
+
# )
|
|
2703
|
+
```
|
|
2704
|
+
""" # noqa: E501
|
|
2705
|
+
# Allows built-in tools either by their:
|
|
2706
|
+
# - Raw `dict` format
|
|
2707
|
+
# - Extracting extras["provider_tool_definition"] if provided on a BaseTool
|
|
2708
|
+
formatted_tools: list[Mapping[str, Any]] = [
|
|
2709
|
+
tool
|
|
2710
|
+
if _is_builtin_tool(tool)
|
|
2711
|
+
else convert_to_anthropic_tool(tool, strict=strict)
|
|
2712
|
+
for tool in tools
|
|
2713
|
+
]
|
|
2714
|
+
formatted_tools, dropped_tool_names = _drop_unsupported_root_composition_tools(
|
|
2715
|
+
formatted_tools
|
|
2716
|
+
)
|
|
2717
|
+
|
|
2718
|
+
# Dropping salvages a request when usable tools remain. If every tool was
|
|
2719
|
+
# dropped there is nothing to salvage: the caller asked for a model with
|
|
2720
|
+
# tools and would get one that cannot call any, so fail loudly instead of
|
|
2721
|
+
# letting it surface later as a tool call that never happens.
|
|
2722
|
+
if tools and not formatted_tools:
|
|
2723
|
+
msg = (
|
|
2724
|
+
f"All {len(tools)} bound tool(s) use a top-level "
|
|
2725
|
+
f"{'/'.join(_TOP_LEVEL_SCHEMA_COMPOSITION_KEYS)} in their "
|
|
2726
|
+
f"input_schema, which the Anthropic API rejects: "
|
|
2727
|
+
f"{sorted(dropped_tool_names)}. No tool is left for the model to "
|
|
2728
|
+
"call. If you control the schema, move the combinator under "
|
|
2729
|
+
"`properties`; for structured output, `with_structured_output(..., "
|
|
2730
|
+
"method='json_schema')` accepts these schemas directly. Otherwise "
|
|
2731
|
+
"these tools cannot be used with Anthropic -- bind a subset that "
|
|
2732
|
+
"excludes them, or raise it with the tool's author."
|
|
2733
|
+
)
|
|
2734
|
+
raise ValueError(msg)
|
|
2735
|
+
|
|
2736
|
+
# Reconcile tool_choice with the filtered list: forcing a tool that was
|
|
2737
|
+
# dropped, or forcing tool use at all when the set the caller depends on
|
|
2738
|
+
# has silently shrunk, still produces a 400 or a stuck agent loop.
|
|
2739
|
+
if tool_choice and dropped_tool_names:
|
|
2740
|
+
choice_type: str | None = None
|
|
2741
|
+
choice_name: str | None = None
|
|
2742
|
+
if isinstance(tool_choice, dict):
|
|
2743
|
+
choice_type = tool_choice.get("type")
|
|
2744
|
+
choice_name = tool_choice.get("name")
|
|
2745
|
+
elif isinstance(tool_choice, str):
|
|
2746
|
+
if tool_choice in ("any", "auto"):
|
|
2747
|
+
choice_type = tool_choice
|
|
2748
|
+
else:
|
|
2749
|
+
choice_type, choice_name = "tool", tool_choice
|
|
2750
|
+
# Thinking discards forced choices before the request is sent, so
|
|
2751
|
+
# they need not refer to a tool that remains after filtering.
|
|
2752
|
+
thinking_discards_forced_choice = (
|
|
2753
|
+
self.thinking is not None
|
|
2754
|
+
and self.thinking.get("type") in ("enabled", "adaptive")
|
|
2755
|
+
and choice_type in ("any", "tool")
|
|
2756
|
+
)
|
|
2757
|
+
if not thinking_discards_forced_choice:
|
|
2758
|
+
if choice_type == "tool" and choice_name in dropped_tool_names:
|
|
2759
|
+
msg = (
|
|
2760
|
+
f"tool_choice forces {choice_name!r}, but that tool was "
|
|
2761
|
+
"dropped because its input_schema uses a top-level "
|
|
2762
|
+
f"{'/'.join(_TOP_LEVEL_SCHEMA_COMPOSITION_KEYS)}, which "
|
|
2763
|
+
"the Anthropic API rejects. Stop forcing it, or -- if you "
|
|
2764
|
+
"control the schema -- move the combinator under "
|
|
2765
|
+
"`properties`. For structured output, "
|
|
2766
|
+
"`with_structured_output(..., method='json_schema')` "
|
|
2767
|
+
"accepts these schemas directly."
|
|
2768
|
+
)
|
|
2769
|
+
raise ValueError(msg)
|
|
2770
|
+
if choice_type == "any":
|
|
2771
|
+
# Forcing tool use means the caller depends on a specific
|
|
2772
|
+
# reachable tool set, so losing any member of it is fatal --
|
|
2773
|
+
# not just losing all of them.
|
|
2774
|
+
msg = (
|
|
2775
|
+
"tool_choice='any' forces the model to call a tool, but "
|
|
2776
|
+
f"{sorted(dropped_tool_names)} were dropped because their "
|
|
2777
|
+
"input_schema uses a top-level "
|
|
2778
|
+
f"{'/'.join(_TOP_LEVEL_SCHEMA_COMPOSITION_KEYS)}, which "
|
|
2779
|
+
"the Anthropic API rejects, so the model can no longer "
|
|
2780
|
+
"call them. Use tool_choice='auto' to proceed with the "
|
|
2781
|
+
"remaining tools, or -- if you control the schema -- move "
|
|
2782
|
+
"the combinator under `properties`."
|
|
2783
|
+
)
|
|
2784
|
+
raise ValueError(msg)
|
|
2785
|
+
|
|
2786
|
+
if not tool_choice:
|
|
2787
|
+
pass
|
|
2788
|
+
elif isinstance(tool_choice, dict):
|
|
2789
|
+
kwargs["tool_choice"] = tool_choice.copy()
|
|
2790
|
+
elif isinstance(tool_choice, str) and tool_choice in ("any", "auto"):
|
|
2791
|
+
kwargs["tool_choice"] = {"type": tool_choice}
|
|
2792
|
+
elif isinstance(tool_choice, str):
|
|
2793
|
+
kwargs["tool_choice"] = {"type": "tool", "name": tool_choice}
|
|
2794
|
+
else:
|
|
2795
|
+
msg = (
|
|
2796
|
+
f"Unrecognized 'tool_choice' type {tool_choice=}. Expected dict, "
|
|
2797
|
+
f"str, or None."
|
|
2798
|
+
)
|
|
2799
|
+
raise ValueError(
|
|
2800
|
+
msg,
|
|
2801
|
+
)
|
|
2802
|
+
|
|
2803
|
+
# Anthropic API rejects forced tool use when thinking is enabled:
|
|
2804
|
+
# "Thinking may not be enabled when tool_choice forces tool use."
|
|
2805
|
+
# Drop forced tool_choice and warn, matching the behavior in
|
|
2806
|
+
# _get_llm_for_structured_output_when_thinking_is_enabled.
|
|
2807
|
+
if (
|
|
2808
|
+
self.thinking is not None
|
|
2809
|
+
and self.thinking.get("type") in ("enabled", "adaptive")
|
|
2810
|
+
and "tool_choice" in kwargs
|
|
2811
|
+
and kwargs["tool_choice"].get("type") in ("any", "tool")
|
|
2812
|
+
):
|
|
2813
|
+
warnings.warn(
|
|
2814
|
+
"tool_choice is forced but thinking is enabled. The Anthropic "
|
|
2815
|
+
"API does not support forced tool use with thinking. "
|
|
2816
|
+
"Dropping tool_choice to avoid an API error. Tool calls are "
|
|
2817
|
+
"not guaranteed. Consider disabling thinking or adjusting "
|
|
2818
|
+
"your prompt to ensure the tool is called.",
|
|
2819
|
+
stacklevel=2,
|
|
2820
|
+
)
|
|
2821
|
+
del kwargs["tool_choice"]
|
|
2822
|
+
|
|
2823
|
+
if parallel_tool_calls is not None:
|
|
2824
|
+
disable_parallel_tool_use = not parallel_tool_calls
|
|
2825
|
+
if "tool_choice" in kwargs:
|
|
2826
|
+
kwargs["tool_choice"]["disable_parallel_tool_use"] = (
|
|
2827
|
+
disable_parallel_tool_use
|
|
2828
|
+
)
|
|
2829
|
+
else:
|
|
2830
|
+
kwargs["tool_choice"] = {
|
|
2831
|
+
"type": "auto",
|
|
2832
|
+
"disable_parallel_tool_use": disable_parallel_tool_use,
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
return self.bind(tools=formatted_tools, **kwargs)
|
|
2836
|
+
|
|
2837
|
+
def with_structured_output(
|
|
2838
|
+
self,
|
|
2839
|
+
schema: dict | type,
|
|
2840
|
+
*,
|
|
2841
|
+
include_raw: bool = False,
|
|
2842
|
+
method: Literal["function_calling", "json_schema"] = "function_calling",
|
|
2843
|
+
**kwargs: Any,
|
|
2844
|
+
) -> Runnable[LanguageModelInput, dict | BaseModel]:
|
|
2845
|
+
"""Model wrapper that returns outputs formatted to match the given schema.
|
|
2846
|
+
|
|
2847
|
+
See the [Coco docs](https://docs.langchain.com/oss/python/integrations/chat/anthropic#structured-output)
|
|
2848
|
+
for more details and examples.
|
|
2849
|
+
|
|
2850
|
+
Args:
|
|
2851
|
+
schema: The output schema. Can be passed in as:
|
|
2852
|
+
|
|
2853
|
+
- An Anthropic tool schema,
|
|
2854
|
+
- An OpenAI function/tool schema,
|
|
2855
|
+
- A JSON Schema,
|
|
2856
|
+
- A `TypedDict` class,
|
|
2857
|
+
- Or a Pydantic class.
|
|
2858
|
+
|
|
2859
|
+
If `schema` is a Pydantic class then the model output will be a
|
|
2860
|
+
Pydantic instance of that class, and the model-generated fields will be
|
|
2861
|
+
validated by the Pydantic class. Otherwise the model output will be a
|
|
2862
|
+
dict and will not be validated.
|
|
2863
|
+
|
|
2864
|
+
See `coco_core.utils.function_calling.convert_to_openai_tool` for
|
|
2865
|
+
more on how to properly specify types and descriptions of schema fields
|
|
2866
|
+
when specifying a Pydantic or `TypedDict` class.
|
|
2867
|
+
include_raw:
|
|
2868
|
+
If `False` then only the parsed structured output is returned.
|
|
2869
|
+
|
|
2870
|
+
If an error occurs during model output parsing it will be raised.
|
|
2871
|
+
|
|
2872
|
+
If `True` then both the raw model response (a `BaseMessage`) and the
|
|
2873
|
+
parsed model response will be returned.
|
|
2874
|
+
|
|
2875
|
+
If an error occurs during output parsing it will be caught and returned
|
|
2876
|
+
as well.
|
|
2877
|
+
|
|
2878
|
+
The final output is always a `dict` with keys `'raw'`, `'parsed'`, and
|
|
2879
|
+
`'parsing_error'`.
|
|
2880
|
+
method: The structured output method to use. Options are:
|
|
2881
|
+
|
|
2882
|
+
- `'function_calling'` (default): Use forced tool calling to get
|
|
2883
|
+
structured output. When `thinking` is enabled, or on models
|
|
2884
|
+
that don't support forced tool use (Claude Opus 5.5, Claude
|
|
2885
|
+
Fable 5.1), the tool call isn't forced, and a missing tool
|
|
2886
|
+
call raises `OutputParserException`.
|
|
2887
|
+
- `'json_schema'`: Use Claude's dedicated
|
|
2888
|
+
[structured output](https://platform.claude.com/docs/en/build-with-claude/structured-outputs)
|
|
2889
|
+
feature.
|
|
2890
|
+
|
|
2891
|
+
kwargs: Additional keyword arguments are ignored.
|
|
2892
|
+
|
|
2893
|
+
Returns:
|
|
2894
|
+
A `Runnable` that takes same inputs as a
|
|
2895
|
+
`coco_core.language_models.chat.BaseChatModel`.
|
|
2896
|
+
|
|
2897
|
+
If `include_raw` is `False` and `schema` is a Pydantic class, `Runnable`
|
|
2898
|
+
outputs an instance of `schema` (i.e., a Pydantic object). Otherwise, if
|
|
2899
|
+
`include_raw` is `False` then `Runnable` outputs a `dict`.
|
|
2900
|
+
|
|
2901
|
+
If `include_raw` is `True`, then `Runnable` outputs a `dict` with keys:
|
|
2902
|
+
|
|
2903
|
+
- `'raw'`: `BaseMessage`
|
|
2904
|
+
- `'parsed'`: `None` if there was a parsing error, otherwise the type
|
|
2905
|
+
depends on the `schema` as described above.
|
|
2906
|
+
- `'parsing_error'`: `BaseException | None`
|
|
2907
|
+
|
|
2908
|
+
Example:
|
|
2909
|
+
```python hl_lines="13"
|
|
2910
|
+
from coco_anthropic import ChatAnthropic
|
|
2911
|
+
from pydantic import BaseModel, Field
|
|
2912
|
+
|
|
2913
|
+
model = ChatAnthropic(model="claude-sonnet-4-5")
|
|
2914
|
+
|
|
2915
|
+
class Movie(BaseModel):
|
|
2916
|
+
\"\"\"A movie with details.\"\"\"
|
|
2917
|
+
title: str = Field(..., description="The title of the movie")
|
|
2918
|
+
year: int = Field(..., description="The year the movie was released")
|
|
2919
|
+
director: str = Field(..., description="The director of the movie")
|
|
2920
|
+
rating: float = Field(..., description="The movie's rating out of 10")
|
|
2921
|
+
|
|
2922
|
+
model_with_structure = model.with_structured_output(Movie, method="json_schema")
|
|
2923
|
+
response = model_with_structure.invoke("Provide details about the movie Inception")
|
|
2924
|
+
print(response)
|
|
2925
|
+
# -> Movie(title="Inception", year=2010, director="Christopher Nolan", rating=8.8)
|
|
2926
|
+
```
|
|
2927
|
+
""" # noqa: E501
|
|
2928
|
+
if method == "json_mode":
|
|
2929
|
+
warning_message = (
|
|
2930
|
+
"Unrecognized structured output method 'json_mode'. Defaulting to "
|
|
2931
|
+
"'json_schema' method."
|
|
2932
|
+
)
|
|
2933
|
+
warnings.warn(warning_message, stacklevel=2)
|
|
2934
|
+
method = "json_schema"
|
|
2935
|
+
|
|
2936
|
+
# TODO: make `method="json_schema"` the default in a future release.
|
|
2937
|
+
if method == "function_calling":
|
|
2938
|
+
formatted_tool = cast(AnthropicTool, convert_to_anthropic_tool(schema))
|
|
2939
|
+
# The result of convert_to_anthropic_tool for 'method=function_calling' will
|
|
2940
|
+
# always be an AnthropicTool
|
|
2941
|
+
tool_name = formatted_tool["name"]
|
|
2942
|
+
if (
|
|
2943
|
+
self.thinking is not None
|
|
2944
|
+
and self.thinking.get("type") in ("enabled", "adaptive")
|
|
2945
|
+
) or not _supports_forced_tool_choice(self.model):
|
|
2946
|
+
llm = self._get_llm_for_structured_output_when_thinking_is_enabled(
|
|
2947
|
+
schema,
|
|
2948
|
+
formatted_tool,
|
|
2949
|
+
)
|
|
2950
|
+
else:
|
|
2951
|
+
llm = self.bind_tools(
|
|
2952
|
+
[schema],
|
|
2953
|
+
tool_choice=tool_name, # Force tool call
|
|
2954
|
+
ls_structured_output_format={
|
|
2955
|
+
"kwargs": {"method": "function_calling"},
|
|
2956
|
+
"schema": formatted_tool,
|
|
2957
|
+
},
|
|
2958
|
+
)
|
|
2959
|
+
|
|
2960
|
+
if isinstance(schema, type) and is_basemodel_subclass(schema):
|
|
2961
|
+
output_parser: OutputParserLike = PydanticToolsParser(
|
|
2962
|
+
tools=[schema],
|
|
2963
|
+
first_tool_only=True,
|
|
2964
|
+
)
|
|
2965
|
+
else:
|
|
2966
|
+
output_parser = JsonOutputKeyToolsParser(
|
|
2967
|
+
key_name=tool_name,
|
|
2968
|
+
first_tool_only=True,
|
|
2969
|
+
)
|
|
2970
|
+
elif method == "json_schema":
|
|
2971
|
+
llm = self.bind(
|
|
2972
|
+
output_config={
|
|
2973
|
+
"format": _convert_to_anthropic_output_config_format(schema)
|
|
2974
|
+
},
|
|
2975
|
+
ls_structured_output_format={
|
|
2976
|
+
"kwargs": {"method": "json_schema"},
|
|
2977
|
+
"schema": convert_to_openai_tool(schema),
|
|
2978
|
+
},
|
|
2979
|
+
)
|
|
2980
|
+
if isinstance(schema, type) and is_basemodel_subclass(schema):
|
|
2981
|
+
output_parser = PydanticOutputParser(pydantic_object=schema)
|
|
2982
|
+
else:
|
|
2983
|
+
output_parser = JsonOutputParser()
|
|
2984
|
+
else:
|
|
2985
|
+
error_message = (
|
|
2986
|
+
f"Unrecognized structured output method '{method}'. "
|
|
2987
|
+
f"Expected 'function_calling' or 'json_schema'."
|
|
2988
|
+
)
|
|
2989
|
+
raise ValueError(error_message)
|
|
2990
|
+
|
|
2991
|
+
if include_raw:
|
|
2992
|
+
parser_assign = RunnablePassthrough.assign(
|
|
2993
|
+
parsed=itemgetter("raw") | output_parser,
|
|
2994
|
+
parsing_error=lambda _: None,
|
|
2995
|
+
)
|
|
2996
|
+
parser_none = RunnablePassthrough.assign(parsed=lambda _: None)
|
|
2997
|
+
parser_with_fallback = parser_assign.with_fallbacks(
|
|
2998
|
+
[parser_none],
|
|
2999
|
+
exception_key="parsing_error",
|
|
3000
|
+
)
|
|
3001
|
+
return RunnableMap(raw=llm) | parser_with_fallback
|
|
3002
|
+
return llm | output_parser
|
|
3003
|
+
|
|
3004
|
+
def get_num_tokens_from_messages(
|
|
3005
|
+
self,
|
|
3006
|
+
messages: list[BaseMessage],
|
|
3007
|
+
tools: Sequence[dict[str, Any] | type | Callable | BaseTool] | None = None,
|
|
3008
|
+
**kwargs: Any,
|
|
3009
|
+
) -> int:
|
|
3010
|
+
"""Count tokens in a sequence of input messages.
|
|
3011
|
+
|
|
3012
|
+
This uses Anthropic's official [token counting API](https://platform.claude.com/docs/en/build-with-claude/token-counting).
|
|
3013
|
+
|
|
3014
|
+
Args:
|
|
3015
|
+
messages: The message inputs to tokenize.
|
|
3016
|
+
tools: If provided, sequence of `dict`, `BaseModel`, function, or `BaseTool`
|
|
3017
|
+
objects to be converted to tool schemas.
|
|
3018
|
+
kwargs: Additional keyword arguments are passed to the Anthropic
|
|
3019
|
+
`messages.count_tokens` method.
|
|
3020
|
+
|
|
3021
|
+
???+ example "Basic usage"
|
|
3022
|
+
|
|
3023
|
+
```python
|
|
3024
|
+
from coco_anthropic import ChatAnthropic
|
|
3025
|
+
from coco_core.messages import HumanMessage, SystemMessage
|
|
3026
|
+
|
|
3027
|
+
model = ChatAnthropic(model="claude-sonnet-4-5-20250929")
|
|
3028
|
+
|
|
3029
|
+
messages = [
|
|
3030
|
+
SystemMessage(content="You are a scientist"),
|
|
3031
|
+
HumanMessage(content="Hello, Claude"),
|
|
3032
|
+
]
|
|
3033
|
+
model.get_num_tokens_from_messages(messages)
|
|
3034
|
+
```
|
|
3035
|
+
|
|
3036
|
+
```txt
|
|
3037
|
+
14
|
|
3038
|
+
```
|
|
3039
|
+
|
|
3040
|
+
??? example "Pass tool schemas"
|
|
3041
|
+
|
|
3042
|
+
```python
|
|
3043
|
+
from coco_anthropic import ChatAnthropic
|
|
3044
|
+
from coco_core.messages import HumanMessage
|
|
3045
|
+
from coco_core.tools import tool
|
|
3046
|
+
|
|
3047
|
+
model = ChatAnthropic(model="claude-sonnet-4-5-20250929")
|
|
3048
|
+
|
|
3049
|
+
@tool(parse_docstring=True)
|
|
3050
|
+
def get_weather(location: str) -> str:
|
|
3051
|
+
\"\"\"Get the current weather in a given location
|
|
3052
|
+
|
|
3053
|
+
Args:
|
|
3054
|
+
location: The city and state, e.g. San Francisco, CA
|
|
3055
|
+
\"\"\"
|
|
3056
|
+
return "Sunny"
|
|
3057
|
+
|
|
3058
|
+
messages = [
|
|
3059
|
+
HumanMessage(content="What's the weather like in San Francisco?"),
|
|
3060
|
+
]
|
|
3061
|
+
model.get_num_tokens_from_messages(messages, tools=[get_weather])
|
|
3062
|
+
```
|
|
3063
|
+
|
|
3064
|
+
```txt
|
|
3065
|
+
403
|
|
3066
|
+
```
|
|
3067
|
+
""" # noqa: D214
|
|
3068
|
+
formatted_system, formatted_messages = _format_messages(
|
|
3069
|
+
messages, model=self.model
|
|
3070
|
+
)
|
|
3071
|
+
if formatted_system is not None:
|
|
3072
|
+
kwargs["system"] = formatted_system
|
|
3073
|
+
if tools:
|
|
3074
|
+
# Filter the same schemas `bind_tools` drops, so counting tokens and
|
|
3075
|
+
# sending a request agree on which tools the API will accept.
|
|
3076
|
+
kwargs["tools"], _ = _drop_unsupported_root_composition_tools(
|
|
3077
|
+
[convert_to_anthropic_tool(tool) for tool in tools]
|
|
3078
|
+
)
|
|
3079
|
+
if self.context_management is not None:
|
|
3080
|
+
kwargs["context_management"] = self.context_management
|
|
3081
|
+
|
|
3082
|
+
if self.betas is not None:
|
|
3083
|
+
beta_response = self._client.beta.messages.count_tokens(
|
|
3084
|
+
betas=self.betas,
|
|
3085
|
+
model=self.model,
|
|
3086
|
+
messages=formatted_messages, # type: ignore[arg-type]
|
|
3087
|
+
**kwargs,
|
|
3088
|
+
)
|
|
3089
|
+
return beta_response.input_tokens
|
|
3090
|
+
response = self._client.messages.count_tokens(
|
|
3091
|
+
model=self.model,
|
|
3092
|
+
messages=formatted_messages, # type: ignore[arg-type]
|
|
3093
|
+
**kwargs,
|
|
3094
|
+
)
|
|
3095
|
+
return response.input_tokens
|
|
3096
|
+
|
|
3097
|
+
|
|
3098
|
+
_TOP_LEVEL_SCHEMA_COMPOSITION_KEYS = ("oneOf", "anyOf")
|
|
3099
|
+
|
|
3100
|
+
|
|
3101
|
+
def _drop_unsupported_root_composition_tools(
|
|
3102
|
+
tools: Sequence[Mapping[str, Any]],
|
|
3103
|
+
) -> tuple[list[Mapping[str, Any]], set[str]]:
|
|
3104
|
+
"""Drop tools whose root `input_schema` uses `oneOf`/`anyOf`.
|
|
3105
|
+
|
|
3106
|
+
The Anthropic API rejects these at request validation, failing the entire
|
|
3107
|
+
request. A tool is dropped only if its `input_schema` is a mapping carrying
|
|
3108
|
+
a root combinator, so built-in (server-side) tools -- which have no
|
|
3109
|
+
`input_schema` -- and tools whose combinators are nested under `properties`
|
|
3110
|
+
are passed through as the same objects, unmodified.
|
|
3111
|
+
|
|
3112
|
+
A `UserWarning` is emitted per dropped tool.
|
|
3113
|
+
|
|
3114
|
+
Args:
|
|
3115
|
+
tools: Already-formatted tool definitions, as built by `bind_tools`.
|
|
3116
|
+
|
|
3117
|
+
Returns:
|
|
3118
|
+
The retained tools, and the names of the dropped tools. A dropped tool
|
|
3119
|
+
with no string `name` contributes no entry to the name set.
|
|
3120
|
+
"""
|
|
3121
|
+
kept: list[Mapping[str, Any]] = []
|
|
3122
|
+
dropped_tool_names: set[str] = set()
|
|
3123
|
+
for tool in tools:
|
|
3124
|
+
input_schema = tool.get("input_schema")
|
|
3125
|
+
offending_keys = (
|
|
3126
|
+
[k for k in _TOP_LEVEL_SCHEMA_COMPOSITION_KEYS if k in input_schema]
|
|
3127
|
+
if isinstance(input_schema, Mapping)
|
|
3128
|
+
else []
|
|
3129
|
+
)
|
|
3130
|
+
if not offending_keys:
|
|
3131
|
+
kept.append(tool)
|
|
3132
|
+
continue
|
|
3133
|
+
tool_name = tool.get("name")
|
|
3134
|
+
if isinstance(tool_name, str):
|
|
3135
|
+
dropped_tool_names.add(tool_name)
|
|
3136
|
+
described = repr(tool_name)
|
|
3137
|
+
else:
|
|
3138
|
+
described = "with no name"
|
|
3139
|
+
warnings.warn(
|
|
3140
|
+
f"Dropping tool {described}: its input_schema has a "
|
|
3141
|
+
f"top-level {'/'.join(offending_keys)}, which the Anthropic API does "
|
|
3142
|
+
"not support. The tool will not be available to the model.",
|
|
3143
|
+
stacklevel=3,
|
|
3144
|
+
)
|
|
3145
|
+
return kept, dropped_tool_names
|
|
3146
|
+
|
|
3147
|
+
|
|
3148
|
+
def convert_to_anthropic_tool(
|
|
3149
|
+
tool: Mapping[str, Any] | type | Callable | BaseTool,
|
|
3150
|
+
*,
|
|
3151
|
+
strict: bool | None = None,
|
|
3152
|
+
) -> AnthropicTool:
|
|
3153
|
+
"""Convert a tool-like object to an Anthropic tool definition.
|
|
3154
|
+
|
|
3155
|
+
Args:
|
|
3156
|
+
tool: A tool-like object to convert. Can be an Anthropic tool dict,
|
|
3157
|
+
a Pydantic model, a function, or a `BaseTool`.
|
|
3158
|
+
strict: If `True`, enables strict schema adherence for the tool.
|
|
3159
|
+
|
|
3160
|
+
!!! note
|
|
3161
|
+
|
|
3162
|
+
Requires Claude Sonnet 4.5 or Opus 4.1.
|
|
3163
|
+
|
|
3164
|
+
Returns:
|
|
3165
|
+
`AnthropicTool` for custom/user-defined tools
|
|
3166
|
+
"""
|
|
3167
|
+
if (
|
|
3168
|
+
isinstance(tool, BaseTool)
|
|
3169
|
+
and hasattr(tool, "extras")
|
|
3170
|
+
and isinstance(tool.extras, dict)
|
|
3171
|
+
and "provider_tool_definition" in tool.extras
|
|
3172
|
+
):
|
|
3173
|
+
# Pass through built-in tool definitions
|
|
3174
|
+
return tool.extras["provider_tool_definition"] # type: ignore[return-value]
|
|
3175
|
+
|
|
3176
|
+
if isinstance(tool, dict) and all(
|
|
3177
|
+
k in tool for k in ("name", "description", "input_schema")
|
|
3178
|
+
):
|
|
3179
|
+
# Anthropic tool format
|
|
3180
|
+
anthropic_formatted = AnthropicTool(tool) # type: ignore[misc]
|
|
3181
|
+
else:
|
|
3182
|
+
oai_formatted = convert_to_openai_tool(tool, strict=strict)["function"]
|
|
3183
|
+
anthropic_formatted = AnthropicTool(
|
|
3184
|
+
name=oai_formatted["name"],
|
|
3185
|
+
input_schema=oai_formatted["parameters"],
|
|
3186
|
+
)
|
|
3187
|
+
if "description" in oai_formatted:
|
|
3188
|
+
anthropic_formatted["description"] = oai_formatted["description"]
|
|
3189
|
+
if "strict" in oai_formatted and isinstance(strict, bool):
|
|
3190
|
+
anthropic_formatted["strict"] = oai_formatted["strict"]
|
|
3191
|
+
# Select params from tool.extras
|
|
3192
|
+
if (
|
|
3193
|
+
isinstance(tool, BaseTool)
|
|
3194
|
+
and hasattr(tool, "extras")
|
|
3195
|
+
and isinstance(tool.extras, dict)
|
|
3196
|
+
):
|
|
3197
|
+
for key, value in tool.extras.items():
|
|
3198
|
+
if key in _ANTHROPIC_EXTRA_FIELDS:
|
|
3199
|
+
# all are populated top-level
|
|
3200
|
+
anthropic_formatted[key] = value # type: ignore[literal-required]
|
|
3201
|
+
return anthropic_formatted
|
|
3202
|
+
|
|
3203
|
+
|
|
3204
|
+
def _tools_in_params(params: dict) -> bool:
|
|
3205
|
+
return (
|
|
3206
|
+
"tools" in params
|
|
3207
|
+
or ("extra_body" in params and params["extra_body"].get("tools"))
|
|
3208
|
+
or "mcp_servers" in params
|
|
3209
|
+
)
|
|
3210
|
+
|
|
3211
|
+
|
|
3212
|
+
def _thinking_in_params(params: dict) -> bool:
|
|
3213
|
+
return params.get("thinking", {}).get("type") in ("enabled", "adaptive")
|
|
3214
|
+
|
|
3215
|
+
|
|
3216
|
+
def _documents_in_params(params: dict) -> bool:
|
|
3217
|
+
for message in params.get("messages", []):
|
|
3218
|
+
if isinstance(message.get("content"), list):
|
|
3219
|
+
for block in message["content"]:
|
|
3220
|
+
if (
|
|
3221
|
+
isinstance(block, dict)
|
|
3222
|
+
and block.get("type") == "document"
|
|
3223
|
+
and block.get("citations", {}).get("enabled")
|
|
3224
|
+
):
|
|
3225
|
+
return True
|
|
3226
|
+
return False
|
|
3227
|
+
|
|
3228
|
+
|
|
3229
|
+
def _compact_in_params(params: dict) -> bool:
|
|
3230
|
+
edits = params.get("context_management", {}).get("edits") or []
|
|
3231
|
+
|
|
3232
|
+
return any("compact" in (edit.get("type") or "") for edit in edits)
|
|
3233
|
+
|
|
3234
|
+
|
|
3235
|
+
class _AnthropicToolUse(TypedDict):
|
|
3236
|
+
type: Literal["tool_use"]
|
|
3237
|
+
name: str
|
|
3238
|
+
input: dict
|
|
3239
|
+
id: str
|
|
3240
|
+
caller: NotRequired[dict[str, Any]]
|
|
3241
|
+
|
|
3242
|
+
|
|
3243
|
+
def _lc_tool_calls_to_anthropic_tool_use_blocks(
|
|
3244
|
+
tool_calls: list[ToolCall],
|
|
3245
|
+
) -> list[_AnthropicToolUse]:
|
|
3246
|
+
return [
|
|
3247
|
+
_AnthropicToolUse(
|
|
3248
|
+
type="tool_use",
|
|
3249
|
+
name=tool_call["name"],
|
|
3250
|
+
input=tool_call["args"],
|
|
3251
|
+
id=cast("str", _normalize_tool_call_id(tool_call["id"])),
|
|
3252
|
+
)
|
|
3253
|
+
for tool_call in tool_calls
|
|
3254
|
+
]
|
|
3255
|
+
|
|
3256
|
+
|
|
3257
|
+
def _convert_to_anthropic_output_config_format(schema: dict | type) -> dict[str, Any]:
|
|
3258
|
+
"""Convert JSON schema, Pydantic model, or `TypedDict` into `output_config.format`.
|
|
3259
|
+
|
|
3260
|
+
See Claude docs on [structured outputs](https://platform.claude.com/docs/en/build-with-claude/structured-outputs).
|
|
3261
|
+
|
|
3262
|
+
Args:
|
|
3263
|
+
schema: A JSON schema dict, Pydantic model class, or TypedDict.
|
|
3264
|
+
|
|
3265
|
+
Returns:
|
|
3266
|
+
A dict with `type` and `schema` keys suitable for `output_config.format`.
|
|
3267
|
+
"""
|
|
3268
|
+
from anthropic import transform_schema
|
|
3269
|
+
|
|
3270
|
+
is_pydantic_class = isinstance(schema, type) and is_basemodel_subclass(schema)
|
|
3271
|
+
if is_pydantic_class or isinstance(schema, dict):
|
|
3272
|
+
json_schema = transform_schema(schema)
|
|
3273
|
+
else:
|
|
3274
|
+
# TypedDict
|
|
3275
|
+
json_schema = transform_schema(convert_to_json_schema(schema))
|
|
3276
|
+
return {"type": "json_schema", "schema": json_schema}
|
|
3277
|
+
|
|
3278
|
+
|
|
3279
|
+
def _create_usage_metadata(anthropic_usage: BaseModel) -> UsageMetadata:
|
|
3280
|
+
"""Create Coco `UsageMetadata` from Anthropic `Usage` data.
|
|
3281
|
+
|
|
3282
|
+
Note:
|
|
3283
|
+
Anthropic's `input_tokens` excludes cached tokens, so we manually add
|
|
3284
|
+
`cache_read` and `cache_creation` tokens to get the true total.
|
|
3285
|
+
"""
|
|
3286
|
+
input_token_details: dict = {
|
|
3287
|
+
"cache_read": getattr(anthropic_usage, "cache_read_input_tokens", None),
|
|
3288
|
+
"cache_creation": getattr(anthropic_usage, "cache_creation_input_tokens", None),
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
# Add cache TTL information if provided (5-minute and 1-hour ephemeral cache)
|
|
3292
|
+
cache_creation = getattr(anthropic_usage, "cache_creation", None)
|
|
3293
|
+
|
|
3294
|
+
# Currently just copying over the 5m and 1h keys, but if more are added in the
|
|
3295
|
+
# future we'll need to expand this tuple
|
|
3296
|
+
cache_creation_keys = ("ephemeral_5m_input_tokens", "ephemeral_1h_input_tokens")
|
|
3297
|
+
specific_cache_creation_tokens = 0
|
|
3298
|
+
if cache_creation:
|
|
3299
|
+
if isinstance(cache_creation, BaseModel):
|
|
3300
|
+
cache_creation = cache_creation.model_dump()
|
|
3301
|
+
for k in cache_creation_keys:
|
|
3302
|
+
specific_cache_creation_tokens += cache_creation.get(k, 0)
|
|
3303
|
+
input_token_details[k] = cache_creation.get(k)
|
|
3304
|
+
if not isinstance(specific_cache_creation_tokens, int):
|
|
3305
|
+
specific_cache_creation_tokens = 0
|
|
3306
|
+
if specific_cache_creation_tokens > 0:
|
|
3307
|
+
# Remove generic key to avoid double counting cache creation tokens
|
|
3308
|
+
input_token_details["cache_creation"] = 0
|
|
3309
|
+
|
|
3310
|
+
# Calculate total input tokens: Anthropic's `input_tokens` excludes cached tokens,
|
|
3311
|
+
# so we need to add them back to get the true total input token count
|
|
3312
|
+
input_tokens = (
|
|
3313
|
+
(getattr(anthropic_usage, "input_tokens", 0) or 0) # Base input tokens
|
|
3314
|
+
+ (input_token_details["cache_read"] or 0) # Tokens read from cache
|
|
3315
|
+
+ (
|
|
3316
|
+
specific_cache_creation_tokens or input_token_details["cache_creation"] or 0
|
|
3317
|
+
) # Tokens used to create cache
|
|
3318
|
+
)
|
|
3319
|
+
output_tokens = getattr(anthropic_usage, "output_tokens", 0) or 0
|
|
3320
|
+
|
|
3321
|
+
# Reasoning (thinking) tokens are a decomposition of `output_tokens` (not
|
|
3322
|
+
# additive), reported by Anthropic via `output_tokens_details.thinking_tokens`.
|
|
3323
|
+
# Older models omit `output_tokens_details` entirely, so guard with `getattr`.
|
|
3324
|
+
output_token_details: dict = {
|
|
3325
|
+
"reasoning": getattr(
|
|
3326
|
+
getattr(anthropic_usage, "output_tokens_details", None),
|
|
3327
|
+
"thinking_tokens",
|
|
3328
|
+
None,
|
|
3329
|
+
),
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
usage_metadata = UsageMetadata(
|
|
3333
|
+
input_tokens=input_tokens,
|
|
3334
|
+
output_tokens=output_tokens,
|
|
3335
|
+
total_tokens=input_tokens + output_tokens,
|
|
3336
|
+
input_token_details=InputTokenDetails(
|
|
3337
|
+
**{k: v for k, v in input_token_details.items() if v is not None},
|
|
3338
|
+
),
|
|
3339
|
+
)
|
|
3340
|
+
|
|
3341
|
+
filtered_output_token_details = {
|
|
3342
|
+
k: v for k, v in output_token_details.items() if v is not None
|
|
3343
|
+
}
|
|
3344
|
+
if filtered_output_token_details:
|
|
3345
|
+
usage_metadata["output_token_details"] = OutputTokenDetails(
|
|
3346
|
+
**filtered_output_token_details,
|
|
3347
|
+
)
|
|
3348
|
+
|
|
3349
|
+
return usage_metadata
|