diff --git a/docs/integrations/llms/anthropic.md b/docs/integrations/llms/anthropic.md index 6bcbcd0c2..9e7381f84 100644 --- a/docs/integrations/llms/anthropic.md +++ b/docs/integrations/llms/anthropic.md @@ -103,3 +103,21 @@ Shows up like this in Logfire: ![Logfire Anthropic Streaming](../../images/logfire-screenshot-anthropic-stream.png){ width="500" }
Anthropic streaming response
+ +## Amazon Bedrock + +You can also log Anthropic LLM calls to Amazon Bedrock using the `AmazonBedrock` and `AsyncAmazonBedrock` clients. + +```python +import anthropic +import logfire + +client = anthropic.AnthropicBedrock( + aws_region='us-east-1', + aws_access_key='access-key', + aws_secret_key='secret-key', +) + +logfire.configure() +logfire.instrument_anthropic(client) +``` diff --git a/logfire/_internal/integrations/llm_providers/anthropic.py b/logfire/_internal/integrations/llm_providers/anthropic.py index bb01ef614..4e0120892 100644 --- a/logfire/_internal/integrations/llm_providers/anthropic.py +++ b/logfire/_internal/integrations/llm_providers/anthropic.py @@ -21,7 +21,7 @@ def get_endpoint_config(options: FinalRequestOptions) -> EndpointConfig: - """Returns the endpoint config for Anthropic depending on the url.""" + """Returns the endpoint config for Anthropic or Bedrock depending on the url.""" url = options.url json_data = options.json_data if not isinstance(json_data, dict): # pragma: no cover @@ -83,9 +83,16 @@ def on_response(response: ResponseT, span: LogfireSpan) -> ResponseT: return response -def is_async_client(client: type[anthropic.Anthropic] | type[anthropic.AsyncAnthropic]): +def is_async_client( + client: type[anthropic.Anthropic] + | type[anthropic.AsyncAnthropic] + | type[anthropic.AnthropicBedrock] + | type[anthropic.AsyncAnthropicBedrock], +): """Returns whether or not the `client` class is async.""" - if issubclass(client, anthropic.Anthropic): + if issubclass(client, (anthropic.Anthropic, anthropic.AnthropicBedrock)): return False - assert issubclass(client, anthropic.AsyncAnthropic), f'Expected Anthropic or AsyncAnthropic type, got: {client}' + assert issubclass( + client, (anthropic.AsyncAnthropic, anthropic.AsyncAnthropicBedrock) + ), f'Expected Anthropic, AsyncAnthropic, AnthropicBedrock or AsyncAnthropicBedrock type, got: {client}' return True diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 186519b3b..7cc2fde60 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -1072,17 +1072,23 @@ def instrument_openai( def instrument_anthropic( self, - anthropic_client: anthropic.Anthropic - | anthropic.AsyncAnthropic - | type[anthropic.Anthropic] - | type[anthropic.AsyncAnthropic] - | None = None, + anthropic_client: ( + anthropic.Anthropic + | anthropic.AsyncAnthropic + | anthropic.AnthropicBedrock + | anthropic.AsyncAnthropicBedrock + | type[anthropic.Anthropic] + | type[anthropic.AsyncAnthropic] + | type[anthropic.AnthropicBedrock] + | type[anthropic.AsyncAnthropicBedrock] + | None + ) = None, *, suppress_other_instrumentation: bool = True, ) -> ContextManager[None]: """Instrument an Anthropic client so that spans are automatically created for each request. - The following methods are instrumented for both the sync and the async clients: + The following methods are instrumented for both the sync and async clients: - [`client.messages.create`](https://docs.anthropic.com/en/api/messages) - [`client.messages.stream`](https://docs.anthropic.com/en/api/messages-streaming) @@ -1097,6 +1103,7 @@ def instrument_anthropic( import anthropic client = anthropic.Anthropic() + logfire.configure() logfire.instrument_anthropic(client) @@ -1112,13 +1119,10 @@ def instrument_anthropic( Args: anthropic_client: The Anthropic client or class to instrument: - - - `None` (the default) to instrument both the - `anthropic.Anthropic` and `anthropic.AsyncAnthropic` classes. - - The `anthropic.Anthropic` class or a subclass - - The `anthropic.AsyncAnthropic` class or a subclass - - An instance of `anthropic.Anthropic` - - An instance of `anthropic.AsyncAnthropic` + - `None` (the default) to instrument all Anthropic client types + - The `anthropic.Anthropic` or `anthropic.AnthropicBedrock` class or subclass + - The `anthropic.AsyncAnthropic` or `anthropic.AsyncAnthropicBedrock` class or subclass + - An instance of any of the above classes suppress_other_instrumentation: If True, suppress any other OTEL instrumentation that may be otherwise enabled. In reality, this means the HTTPX instrumentation, which could otherwise be called since @@ -1136,7 +1140,13 @@ def instrument_anthropic( self._warn_if_not_initialized_for_instrumentation() return instrument_llm_provider( self, - anthropic_client or (anthropic.Anthropic, anthropic.AsyncAnthropic), + anthropic_client + or ( + anthropic.Anthropic, + anthropic.AsyncAnthropic, + anthropic.AnthropicBedrock, + anthropic.AsyncAnthropicBedrock, + ), suppress_other_instrumentation, 'Anthropic', get_endpoint_config, diff --git a/pyproject.toml b/pyproject.toml index e4cdfaf94..085f1b86f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -160,6 +160,9 @@ dev = [ "requests", "setuptools>=75.3.0", "aiosqlite>=0.20.0", + "boto3 >= 1.28.57", + "botocore >= 1.31.57", + ] docs = [ "mkdocs>=1.5.0", diff --git a/tests/otel_integrations/test_anthropic_bedrock.py b/tests/otel_integrations/test_anthropic_bedrock.py new file mode 100644 index 000000000..9b2be5dde --- /dev/null +++ b/tests/otel_integrations/test_anthropic_bedrock.py @@ -0,0 +1,147 @@ +from typing import Iterator + +import httpx +import pytest +from anthropic import Anthropic, AnthropicBedrock, AsyncAnthropic, AsyncAnthropicBedrock +from anthropic.types import Message, TextBlock, Usage +from dirty_equals import IsJson +from httpx._transports.mock import MockTransport +from inline_snapshot import snapshot + +import logfire +from logfire._internal.integrations.llm_providers.anthropic import is_async_client +from logfire.testing import TestExporter + + +def request_handler(request: httpx.Request) -> httpx.Response: + """Used to mock httpx requests""" + model_id = 'anthropic.claude-3-haiku-20240307-v1:0' + + assert request.method == 'POST' + assert request.url == f'https://bedrock-runtime.us-east-1.amazonaws.com/model/{model_id}/invoke' + + return httpx.Response( + 200, + json=Message( + id='test_id', + content=[ + TextBlock( + text='Nine', + type='text', + ) + ], + model=model_id, + role='assistant', + type='message', + usage=Usage(input_tokens=2, output_tokens=3), # Match the snapshot values + ).model_dump(mode='json'), + ) + + +@pytest.fixture +def mock_client() -> Iterator[AnthropicBedrock]: + """Fixture that provides a mocked Anthropic client with AWS credentials""" + with httpx.Client(transport=MockTransport(request_handler)) as http_client: + client = AnthropicBedrock( + aws_region='us-east-1', + aws_access_key='test-access-key', + aws_secret_key='test-secret-key', + aws_session_token='test-session-token', + http_client=http_client, + ) + with logfire.instrument_anthropic(): + yield client + + +@pytest.mark.filterwarnings('ignore:datetime.datetime.utcnow:DeprecationWarning') +def test_sync_messages(mock_client: AnthropicBedrock, exporter: TestExporter): + """Test basic synchronous message creation""" + model_id = 'anthropic.claude-3-haiku-20240307-v1:0' + response = mock_client.messages.create( + max_tokens=1000, + model=model_id, + system='You are a helpful assistant.', + messages=[{'role': 'user', 'content': 'What is four plus five?'}], + ) + + # Verify response structure + assert isinstance(response.content[0], TextBlock) + assert response.content[0].text == 'Nine' + + # Verify exported spans + assert exporter.exported_spans_as_dict() == snapshot( + [ + { + 'name': 'Message with {request_data[model]!r}', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 2000000000, + 'attributes': { + 'code.filepath': 'test_anthropic_bedrock.py', + 'code.function': 'test_sync_messages', + 'code.lineno': 123, + 'request_data': IsJson( + { + 'max_tokens': 1000, + 'system': 'You are a helpful assistant.', + 'messages': [{'role': 'user', 'content': 'What is four plus five?'}], + 'model': model_id, + } + ), + 'async': False, + 'logfire.msg_template': 'Message with {request_data[model]!r}', + 'logfire.msg': f"Message with '{model_id}'", + 'logfire.span_type': 'span', + 'logfire.tags': ('LLM',), + 'response_data': IsJson( + { + 'message': { + 'content': 'Nine', + 'role': 'assistant', + }, + 'usage': { + 'input_tokens': 2, + 'output_tokens': 3, + 'cache_creation_input_tokens': None, + 'cache_read_input_tokens': None, + }, + } + ), + 'logfire.json_schema': IsJson( + { + 'type': 'object', + 'properties': { + 'request_data': {'type': 'object'}, + 'async': {}, + 'response_data': { + 'type': 'object', + 'properties': { + 'usage': { + 'type': 'object', + 'title': 'Usage', + 'x-python-datatype': 'PydanticModel', + }, + }, + }, + }, + } + ), + }, + } + ] + ) + + +def test_is_async_client() -> None: + # Test sync clients + assert not is_async_client(Anthropic) + assert not is_async_client(AnthropicBedrock) + + # Test async clients + assert is_async_client(AsyncAnthropic) + assert is_async_client(AsyncAnthropicBedrock) + + # Test invalid input + with pytest.raises(AssertionError): + is_async_client(str) # type: ignore diff --git a/uv.lock b/uv.lock index 471414d10..af502d41c 100644 --- a/uv.lock +++ b/uv.lock @@ -413,6 +413,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bb/2a/10164ed1f31196a2f7f3799368a821765c62851ead0e630ab52b8e14b4d0/blinker-1.8.2-py3-none-any.whl", hash = "sha256:1779309f71bf239144b9399d06ae925637cf6634cf6bd131104184531bf67c01", size = 9456 }, ] +[[package]] +name = "boto3" +version = "1.35.85" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/f0/503969c1f24593d97bf11768f522dbaf4595c74e2f9bd85a2fe0ea67289a/boto3-1.35.85.tar.gz", hash = "sha256:6257cad97d92c2b5597aec6e5484b9cfed8c0c785297942ed37cfaf2dd0ec23c", size = 111023 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/44/682024a962ed2e23d35b11309003db1a18537e01dabe399426e14d0c8812/boto3-1.35.85-py3-none-any.whl", hash = "sha256:f22678bdbdc91ca6022a45696284d236e1fbafa84ca3a69d108d4a155cdd823e", size = 139178 }, +] + +[[package]] +name = "botocore" +version = "1.35.85" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/49/27f479d74880dde4c1b56fb19c68f298d82694284c433a9f67c5f769bc28/botocore-1.35.85.tar.gz", hash = "sha256:5e7e8075e85427c9e0e6d15dcb7d13b3c843011b25d43981571fe1bfb3fd6985", size = 13486663 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/9c/cf0970a3d74f20aabb31a6a4967b8fda4b82cc0861fa4c49e99c0db453d6/botocore-1.35.85-py3-none-any.whl", hash = "sha256:04c196905b0eebcb29f7594a9e4588772a5222deed1b381f54cab78d0f30e239", size = 13290197 }, +] + [[package]] name = "celery" version = "5.4.0" @@ -1380,6 +1408,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/b7/a3cde72c644fd1caf9da07fb38cf2c130f43484d8f91011940b7c4f42c8f/jiter-0.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:1c0dfbd1be3cbefc7510102370d86e35d1d53e5a93d48519688b1bf0f761160a", size = 207527 }, ] +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, +] + [[package]] name = "kombu" version = "5.4.2" @@ -1483,6 +1520,8 @@ dev = [ { name = "anyio" }, { name = "asyncpg" }, { name = "attrs" }, + { name = "boto3" }, + { name = "botocore" }, { name = "celery" }, { name = "cloudpickle" }, { name = "coverage", extra = ["toml"] }, @@ -1599,6 +1638,8 @@ dev = [ { name = "anyio", specifier = "<4.4.0" }, { name = "asyncpg" }, { name = "attrs" }, + { name = "boto3", specifier = ">=1.28.57" }, + { name = "botocore", specifier = ">=1.31.57" }, { name = "celery", specifier = ">=5.4.0" }, { name = "cloudpickle", specifier = ">=3.0.0" }, { name = "coverage", extras = ["toml"], specifier = ">=7.5.0" }, @@ -3817,6 +3858,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/8f/e4fa95288b81233356d9a9dcaed057e5b0adc6399aa8fd0f6d784041c9c3/ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936", size = 9078754 }, ] +[[package]] +name = "s3transfer" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/0a/1cdbabf9edd0ea7747efdf6c9ab4e7061b085aa7f9bfc36bb1601563b069/s3transfer-0.10.4.tar.gz", hash = "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7", size = 145287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/05/7957af15543b8c9799209506df4660cba7afc4cf94bfb60513827e96bed6/s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", size = 83175 }, +] + [[package]] name = "setuptools" version = "75.3.0"