Skip to content

Commit 7403848

Browse files
authored
feat(anthropic): add anthropic beta client support (#15441)
## Description Adds auto-instrumentation support for the Anthropic Beta client API (`client.beta.messages.create()` and `client.beta.messages.stream()`). This extends the existing Anthropic integration to also instrument the beta namespace, which was introduced in `anthropic>=0.37.0`. ## Testing - Added 7 new tests covering sync/async create, stream, and stream helper for the beta API. Tests are skipped for `anthropic<0.37.0`. - The non-beta snapshots are used to ensure consistency. ## Risks The beta instrumentation is conditionally applied only when the SDK version supports it (`>=0.37.0`), so older SDK versions are unaffected. ## Additional Notes The beta messages API uses the same request/response structure as the regular messages API and the existing integration code appears to work without any further modification.
1 parent d7e180e commit 7403848

File tree

10 files changed

+512
-8
lines changed

10 files changed

+512
-8
lines changed

ddtrace/contrib/internal/anthropic/_streaming.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -207,26 +207,30 @@ def _on_error_chunk(chunk, message):
207207

208208

209209
def _is_stream(resp: Any) -> bool:
210-
if hasattr(anthropic, "Stream") and isinstance(resp, anthropic.Stream):
211-
return True
210+
for attr in ("Stream", "BetaMessageStream"):
211+
if hasattr(anthropic, attr) and isinstance(resp, getattr(anthropic, attr)):
212+
return True
212213
return False
213214

214215

215216
def _is_async_stream(resp: Any) -> bool:
216-
if hasattr(anthropic, "AsyncStream") and isinstance(resp, anthropic.AsyncStream):
217-
return True
217+
for attr in ("AsyncStream", "BetaAsyncMessageStream"):
218+
if hasattr(anthropic, attr) and isinstance(resp, getattr(anthropic, attr)):
219+
return True
218220
return False
219221

220222

221223
def _is_stream_manager(resp: Any) -> bool:
222-
if hasattr(anthropic, "MessageStreamManager") and isinstance(resp, anthropic.MessageStreamManager):
223-
return True
224+
for attr in ("MessageStreamManager", "BetaMessageStreamManager"):
225+
if hasattr(anthropic, attr) and isinstance(resp, getattr(anthropic, attr)):
226+
return True
224227
return False
225228

226229

227230
def _is_async_stream_manager(resp: Any) -> bool:
228-
if hasattr(anthropic, "AsyncMessageStreamManager") and isinstance(resp, anthropic.AsyncMessageStreamManager):
229-
return True
231+
for attr in ("AsyncMessageStreamManager", "BetaAsyncMessageStreamManager"):
232+
if hasattr(anthropic, attr) and isinstance(resp, getattr(anthropic, attr)):
233+
return True
230234
return False
231235

232236

ddtrace/contrib/internal/anthropic/patch.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ddtrace.contrib.internal.trace_utils import with_traced_module
1212
from ddtrace.contrib.internal.trace_utils import wrap
1313
from ddtrace.internal.logger import get_logger
14+
from ddtrace.internal.utils.version import parse_version
1415
from ddtrace.llmobs._integrations import AnthropicIntegration
1516

1617

@@ -22,6 +23,9 @@ def get_version():
2223
return getattr(anthropic, "__version__", "")
2324

2425

26+
ANTHROPIC_VERSION = parse_version(get_version())
27+
28+
2529
def _supported_versions() -> Dict[str, str]:
2630
return {"anthropic": ">=0.28.0"}
2731

@@ -111,6 +115,18 @@ def patch():
111115
# AsyncMessages.stream is a sync function
112116
wrap("anthropic", "resources.messages.AsyncMessages.stream", traced_chat_model_generate(anthropic))
113117

118+
if ANTHROPIC_VERSION >= (0, 37):
119+
wrap("anthropic", "resources.beta.messages.messages.Messages.create", traced_chat_model_generate(anthropic))
120+
wrap("anthropic", "resources.beta.messages.messages.Messages.stream", traced_chat_model_generate(anthropic))
121+
wrap(
122+
"anthropic",
123+
"resources.beta.messages.messages.AsyncMessages.create",
124+
traced_async_chat_model_generate(anthropic),
125+
)
126+
wrap(
127+
"anthropic", "resources.beta.messages.messages.AsyncMessages.stream", traced_chat_model_generate(anthropic)
128+
)
129+
114130

115131
def unpatch():
116132
if not getattr(anthropic, "_datadog_patch", False):
@@ -123,4 +139,10 @@ def unpatch():
123139
unwrap(anthropic.resources.messages.AsyncMessages, "create")
124140
unwrap(anthropic.resources.messages.AsyncMessages, "stream")
125141

142+
if ANTHROPIC_VERSION >= (0, 37):
143+
unwrap(anthropic.resources.beta.messages.messages.Messages, "create")
144+
unwrap(anthropic.resources.beta.messages.messages.Messages, "stream")
145+
unwrap(anthropic.resources.beta.messages.messages.AsyncMessages, "create")
146+
unwrap(anthropic.resources.beta.messages.messages.AsyncMessages, "stream")
147+
126148
delattr(anthropic, "_datadog_integration")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
features:
3+
- |
4+
anthropic: Adds support for the Anthropic Beta client API (``client.beta.messages.create()`` and ``client.beta.messages.stream()``).
5+
This feature requires Anthropic client version 0.37.0 or higher.

tests/contrib/anthropic/test_anthropic.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,3 +598,109 @@ async def test_anthropic_llm_async_tools_stream_full_use(anthropic, request_vcr,
598598
)
599599
async for _ in stream:
600600
pass
601+
602+
603+
BETA_SKIP_REASON = "Anthropic Beta API not available until 0.37.0, skipping."
604+
605+
606+
@pytest.mark.skipif(ANTHROPIC_VERSION < (0, 37), reason=BETA_SKIP_REASON)
607+
@pytest.mark.snapshot(token="tests.contrib.anthropic.test_anthropic.test_anthropic_llm", ignores=["resource"])
608+
def test_anthropic_beta_llm_sync_create(anthropic, request_vcr):
609+
llm = anthropic.Anthropic()
610+
with request_vcr.use_cassette("anthropic_completion.yaml"):
611+
llm.beta.messages.create(
612+
model="claude-3-opus-20240229",
613+
max_tokens=15,
614+
messages=[
615+
{"role": "user", "content": "Can you explain what Descartes meant by 'I think, therefore I am'?"}
616+
],
617+
)
618+
619+
620+
@pytest.mark.skipif(ANTHROPIC_VERSION < (0, 37), reason=BETA_SKIP_REASON)
621+
@pytest.mark.snapshot(token="tests.contrib.anthropic.test_anthropic.test_anthropic_llm_stream", ignores=["resource"])
622+
def test_anthropic_beta_llm_sync_stream(anthropic, request_vcr):
623+
llm = anthropic.Anthropic()
624+
with request_vcr.use_cassette("anthropic_completion_stream.yaml"):
625+
stream = llm.beta.messages.create(
626+
model="claude-3-opus-20240229",
627+
max_tokens=15,
628+
messages=[
629+
{"role": "user", "content": "Can you explain what Descartes meant by 'I think, therefore I am'?"}
630+
],
631+
stream=True,
632+
)
633+
for _ in stream:
634+
pass
635+
636+
637+
@pytest.mark.skipif(ANTHROPIC_VERSION < (0, 37), reason=BETA_SKIP_REASON)
638+
@pytest.mark.snapshot(
639+
token="tests.contrib.anthropic.test_anthropic.test_anthropic_llm_stream_helper", ignores=["resource"]
640+
)
641+
def test_anthropic_beta_llm_sync_stream_helper(anthropic, request_vcr):
642+
llm = anthropic.Anthropic()
643+
with request_vcr.use_cassette("anthropic_completion_stream_helper.yaml"):
644+
with llm.beta.messages.stream(
645+
max_tokens=15,
646+
messages=[
647+
{"role": "user", "content": "Can you explain what Descartes meant by 'I think, therefore I am'?"}
648+
],
649+
model="claude-3-opus-20240229",
650+
) as stream:
651+
for _ in stream.text_stream:
652+
pass
653+
654+
655+
@pytest.mark.skipif(ANTHROPIC_VERSION < (0, 37), reason=BETA_SKIP_REASON)
656+
@pytest.mark.asyncio
657+
async def test_anthropic_beta_llm_async_create(anthropic, request_vcr, snapshot_context):
658+
with snapshot_context(token="tests.contrib.anthropic.test_anthropic.test_anthropic_llm", ignores=["resource"]):
659+
llm = anthropic.AsyncAnthropic()
660+
with request_vcr.use_cassette("anthropic_completion.yaml"):
661+
await llm.beta.messages.create(
662+
model="claude-3-opus-20240229",
663+
max_tokens=15,
664+
messages=[
665+
{"role": "user", "content": "Can you explain what Descartes meant by 'I think, therefore I am'?"}
666+
],
667+
)
668+
669+
670+
@pytest.mark.skipif(ANTHROPIC_VERSION < (0, 37), reason=BETA_SKIP_REASON)
671+
@pytest.mark.asyncio
672+
async def test_anthropic_beta_llm_async_stream(anthropic, request_vcr, snapshot_context):
673+
with snapshot_context(
674+
token="tests.contrib.anthropic.test_anthropic.test_anthropic_llm_stream", ignores=["resource"]
675+
):
676+
llm = anthropic.AsyncAnthropic()
677+
with request_vcr.use_cassette("anthropic_completion_stream.yaml"):
678+
stream = await llm.beta.messages.create(
679+
model="claude-3-opus-20240229",
680+
max_tokens=15,
681+
messages=[
682+
{"role": "user", "content": "Can you explain what Descartes meant by 'I think, therefore I am'?"}
683+
],
684+
stream=True,
685+
)
686+
async for _ in stream:
687+
pass
688+
689+
690+
@pytest.mark.skipif(ANTHROPIC_VERSION < (0, 37), reason=BETA_SKIP_REASON)
691+
@pytest.mark.asyncio
692+
async def test_anthropic_beta_llm_async_stream_helper(anthropic, request_vcr, snapshot_context):
693+
with snapshot_context(
694+
token="tests.contrib.anthropic.test_anthropic.test_anthropic_llm_stream_helper", ignores=["resource"]
695+
):
696+
llm = anthropic.AsyncAnthropic()
697+
with request_vcr.use_cassette("anthropic_completion_stream_helper.yaml"):
698+
async with llm.beta.messages.stream(
699+
max_tokens=15,
700+
messages=[
701+
{"role": "user", "content": "Can you explain what Descartes meant by 'I think, therefore I am'?"}
702+
],
703+
model="claude-3-opus-20240229",
704+
) as stream:
705+
async for _ in stream.text_stream:
706+
pass

tests/contrib/anthropic/test_anthropic_llmobs.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from ddtrace.llmobs._utils import safe_json
88
from tests.contrib.anthropic.test_anthropic import ANTHROPIC_VERSION
9+
from tests.contrib.anthropic.test_anthropic import BETA_SKIP_REASON
910
from tests.contrib.anthropic.utils import MOCK_MESSAGES_CREATE_REQUEST
1011
from tests.contrib.anthropic.utils import tools
1112
from tests.llmobs._utils import _expected_llmobs_llm_span_event
@@ -1060,3 +1061,31 @@ def test_completion_stream_prompt_caching(
10601061
),
10611062
]
10621063
)
1064+
1065+
@pytest.mark.skipif(ANTHROPIC_VERSION < (0, 37), reason=BETA_SKIP_REASON)
1066+
def test_beta_completion(self, anthropic, ddtrace_global_config, mock_llmobs_writer, mock_tracer, request_vcr):
1067+
"""Ensure llmobs records are emitted for beta completion endpoints."""
1068+
llm = anthropic.Anthropic()
1069+
with request_vcr.use_cassette("anthropic_completion.yaml"):
1070+
response = llm.beta.messages.create(
1071+
model="claude-3-opus-20240229",
1072+
max_tokens=15,
1073+
messages=[{"role": "user", "content": "What does Nietzsche mean by 'God is dead'?"}],
1074+
)
1075+
span = mock_tracer.pop_traces()[0][0]
1076+
mock_llmobs_writer.enqueue.assert_called_with(
1077+
_expected_llmobs_llm_span_event(
1078+
span,
1079+
model_name="claude-3-opus-20240229",
1080+
model_provider="anthropic",
1081+
input_messages=[{"content": "What does Nietzsche mean by 'God is dead'?", "role": "user"}],
1082+
output_messages=[{"content": response.content[0].text, "role": "assistant"}],
1083+
metadata={"max_tokens": 15.0},
1084+
token_metrics={
1085+
"input_tokens": response.usage.input_tokens,
1086+
"output_tokens": response.usage.output_tokens,
1087+
"total_tokens": response.usage.input_tokens + response.usage.output_tokens,
1088+
},
1089+
tags={"ml_app": "<ml-app-name>", "service": "tests.contrib.anthropic"},
1090+
)
1091+
)

tests/contrib/anthropic/test_anthropic_patch.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from ddtrace.contrib.internal.anthropic.patch import ANTHROPIC_VERSION
12
from ddtrace.contrib.internal.anthropic.patch import get_version
23
from ddtrace.contrib.internal.anthropic.patch import patch
34
from ddtrace.contrib.internal.anthropic.patch import unpatch
@@ -14,11 +15,20 @@ class TestAnthropicPatch(PatchTestCase.Base):
1415
def assert_module_patched(self, anthropic):
1516
self.assert_wrapped(anthropic.resources.messages.Messages.create)
1617
self.assert_wrapped(anthropic.resources.messages.AsyncMessages.create)
18+
if ANTHROPIC_VERSION >= (0, 37):
19+
self.assert_wrapped(anthropic.resources.beta.messages.messages.Messages.create)
20+
self.assert_wrapped(anthropic.resources.beta.messages.messages.AsyncMessages.create)
1721

1822
def assert_not_module_patched(self, anthropic):
1923
self.assert_not_wrapped(anthropic.resources.messages.Messages.create)
2024
self.assert_not_wrapped(anthropic.resources.messages.AsyncMessages.create)
25+
if ANTHROPIC_VERSION >= (0, 37):
26+
self.assert_not_wrapped(anthropic.resources.beta.messages.messages.Messages.create)
27+
self.assert_not_wrapped(anthropic.resources.beta.messages.messages.AsyncMessages.create)
2128

2229
def assert_not_module_double_patched(self, anthropic):
2330
self.assert_not_double_wrapped(anthropic.resources.messages.Messages.create)
2431
self.assert_not_double_wrapped(anthropic.resources.messages.AsyncMessages.create)
32+
if ANTHROPIC_VERSION >= (0, 37):
33+
self.assert_not_double_wrapped(anthropic.resources.beta.messages.messages.Messages.create)
34+
self.assert_not_double_wrapped(anthropic.resources.beta.messages.messages.AsyncMessages.create)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
interactions:
2+
- request:
3+
body: "{\n \"model\": \"claude-3-7-sonnet-20250219\",\n \"messages\": [\n {\n
4+
\ \"role\": \"user\",\n \"content\": \"Hello, world!\"\n }\n ],\n
5+
\ \"max_tokens\": 100,\n \"temperature\": 0.5\n}"
6+
headers:
7+
? !!python/object/apply:multidict._multidict.istr
8+
- Accept
9+
: - application/json
10+
? !!python/object/apply:multidict._multidict.istr
11+
- Accept-Encoding
12+
: - gzip,deflate
13+
? !!python/object/apply:multidict._multidict.istr
14+
- Connection
15+
: - keep-alive
16+
Content-Length:
17+
- '174'
18+
? !!python/object/apply:multidict._multidict.istr
19+
- Content-Type
20+
: - application/json
21+
? !!python/object/apply:multidict._multidict.istr
22+
- User-Agent
23+
: - Anthropic/JS 0.33.0
24+
? !!python/object/apply:multidict._multidict.istr
25+
- anthropic-version
26+
: - '2023-06-01'
27+
? !!python/object/apply:multidict._multidict.istr
28+
- x-stainless-arch
29+
: - arm64
30+
? !!python/object/apply:multidict._multidict.istr
31+
- x-stainless-lang
32+
: - js
33+
? !!python/object/apply:multidict._multidict.istr
34+
- x-stainless-os
35+
: - MacOS
36+
? !!python/object/apply:multidict._multidict.istr
37+
- x-stainless-package-version
38+
: - 0.33.0
39+
? !!python/object/apply:multidict._multidict.istr
40+
- x-stainless-retry-count
41+
: - '0'
42+
? !!python/object/apply:multidict._multidict.istr
43+
- x-stainless-runtime
44+
: - node
45+
? !!python/object/apply:multidict._multidict.istr
46+
- x-stainless-runtime-version
47+
: - v22.17.0
48+
method: POST
49+
uri: https://api.anthropic.com/v1/messages?beta=true
50+
response:
51+
body:
52+
string: '{"type":"error","error":{"type":"authentication_error","message":"invalid
53+
x-api-key"},"request_id":"req_011CVa2kdYoBWtrYYX7mqJjX"}'
54+
headers:
55+
CF-RAY:
56+
- 9a58bfc54c1300bb-CDG
57+
Connection:
58+
- keep-alive
59+
Content-Length:
60+
- '130'
61+
Content-Type:
62+
- application/json
63+
Date:
64+
- Fri, 28 Nov 2025 09:13:24 GMT
65+
Server:
66+
- cloudflare
67+
X-Robots-Tag:
68+
- none
69+
cf-cache-status:
70+
- DYNAMIC
71+
request-id:
72+
- req_011CVa2kdYoBWtrYYX7mqJjX
73+
strict-transport-security:
74+
- max-age=31536000; includeSubDomains; preload
75+
x-envoy-upstream-service-time:
76+
- '17'
77+
x-should-retry:
78+
- 'false'
79+
status:
80+
code: 401
81+
message: Unauthorized
82+
version: 1

0 commit comments

Comments
 (0)