Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this project will be documented here. Format follows [Kee

## [Unreleased]

### Added
- Native `anthropic` SDK support. Wraps `Anthropic.messages.create` (including `stream=True`) and `Anthropic.messages.stream(...)` context manager. Same coverage on `AsyncAnthropic` (sync + async variants).
- `extract_anthropic_native` adapter with the full Anthropic field map: `input_tokens`, `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens`, `cache_creation.ephemeral_5m_input_tokens`, `cache_creation.ephemeral_1h_input_tokens`, `content[].type == "tool_use"`.
- `anthropic` optional dependency group: `pip install 'lago-agent-sdk[anthropic]'`.
- 19 new unit tests (adapter + wrapper) and 3 live integration tests (gated on `ANTHROPIC_API_KEY`). Total: 256 unit tests, ≥80% coverage maintained.
- 9 captured response fixtures from the real Anthropic API (plain, tool use, 5m + 1h prompt caching, extended thinking, streaming, multi-turn).


## [0.1.0] — initial release

### Added
Expand Down
42 changes: 31 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pip install lago-agent-sdk

For Bedrock support: `pip install 'lago-agent-sdk[bedrock]'` (adds `boto3`).
For Mistral support: `pip install 'lago-agent-sdk[mistral]'` (adds `mistralai`).
For Anthropic native support: `pip install 'lago-agent-sdk[anthropic]'` (adds `anthropic`).

## Quickstart — Bedrock

Expand All @@ -52,6 +53,25 @@ sdk.flush()

The wrapped client behaves identically to the original — same arguments, same return shape, same exceptions. The SDK adds an in-memory queue that batches events to Lago in the background.

## Quickstart — Anthropic

```python
from anthropic import Anthropic
from lago_agent_sdk import LagoSDK

sdk = LagoSDK(api_key="...", default_subscription_id="sub_acme")
client = sdk.wrap(Anthropic(api_key="..."))

resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=200,
messages=[{"role": "user", "content": "Hello"}],
)
sdk.flush()
```

Works with `Anthropic` and `AsyncAnthropic`. Both `messages.create(..., stream=True)` and the `messages.stream(...)` context manager are instrumented — usage is captured from the final `message_delta` event in either case.

## Quickstart — Mistral

```python
Expand Down Expand Up @@ -92,26 +112,26 @@ Backed by `contextvars` for safe propagation across `asyncio` tasks.
|---|---|---|
| AWS Bedrock | `Converse` (sync + stream) | ✓ |
| AWS Bedrock | `InvokeModel` (sync + stream), 7 model families | ✓ |
| Anthropic | native SDK (`messages.create` + `messages.stream`, sync + async) | ✓ |
| Mistral | native SDK (`chat.complete` + `chat.stream`) | ✓ |
| OpenAI | native SDK | Phase 2 |
| Anthropic | native SDK | Phase 2 |
| Google Gemini | native SDK | Phase 2 |
| LiteLLM | callback bridge | Phase 4 |

## Token dimensions captured

`CanonicalUsage` carries 10 numeric fields. Which ones populate depends on the provider:

| Field | Lago metric code | Bedrock | Mistral native |
|---|---|---|---|
| input | `llm_input_tokens` | ✓ | ✓ |
| output | `llm_output_tokens` | ✓ | ✓ |
| cache_read | `llm_cached_input_tokens` | ✓ (Anthropic) | ✓ (when cache hits) |
| cache_write | `llm_cache_creation_tokens` | ✓ (Anthropic) | ✗ |
| cache_write_5m / 1h | `llm_cache_write_5m/1h_tokens` | ✓ (Anthropic InvokeModel) | ✗ |
| reasoning | `llm_reasoning_tokens` | ✗ (folded into output) | ✗ (folded into output) |
| tool_calls | `llm_tool_calls` | ✓ | ✓ |
| image_input / audio_input | `llm_image/audio_input_tokens` | ✗ | ✗ |
| Field | Lago metric code | Bedrock | Anthropic native | Mistral native |
|---|---|---|---|---|
| input | `llm_input_tokens` | ✓ | ✓ | ✓ |
| output | `llm_output_tokens` | ✓ | ✓ | ✓ |
| cache_read | `llm_cached_input_tokens` | ✓ (Anthropic) | ✓ | ✓ (when cache hits) |
| cache_write | `llm_cache_creation_tokens` | ✓ (Anthropic) | ✓ | ✗ |
| cache_write_5m / 1h | `llm_cache_write_5m/1h_tokens` | ✓ (Anthropic InvokeModel) | ✓ | ✗ |
| reasoning | `llm_reasoning_tokens` | ✗ (folded into output) | ✗ (folded into output, even with extended thinking) | ✗ (folded into output) |
| tool_calls | `llm_tool_calls` | ✓ | ✓ | ✓ |
| image_input / audio_input | `llm_image/audio_input_tokens` | ✗ | ✗ | ✗ |

Reasoning, image, and audio fields will populate when Phase 2 native OpenAI ships.

Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ dev = [
"mypy>=1.10",
"types-requests>=2.31",
]
anthropic = [
"anthropic>=0.30",
]

[project.urls]
Homepage = "https://www.getlago.com"
Expand Down Expand Up @@ -81,3 +84,8 @@ files = ["src/lago_agent_sdk"]
[[tool.mypy.overrides]]
module = ["boto3.*", "botocore.*", "mistralai.*"]
ignore_missing_imports = true

[dependency-groups]
dev = [
"anthropic>=0.30",
]
2 changes: 2 additions & 0 deletions src/lago_agent_sdk/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from .anthropic_native import extract_anthropic_native
from .bedrock_converse import extract_bedrock_converse
from .bedrock_invoke import extract_bedrock_invoke, pick_invoke_adapter
from .mistral_native import extract_mistral_native

__all__ = [
"extract_anthropic_native",
"extract_bedrock_converse",
"extract_bedrock_invoke",
"pick_invoke_adapter",
Expand Down
91 changes: 91 additions & 0 deletions src/lago_agent_sdk/adapters/anthropic_native.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Anthropic native adapter — verified against real fixtures.

Field mapping:
usage.input_tokens → input
usage.output_tokens → output
usage.cache_read_input_tokens → cache_read
usage.cache_creation_input_tokens → cache_write
usage.cache_creation.ephemeral_5m_input_tokens → cache_write_5m
usage.cache_creation.ephemeral_1h_input_tokens → cache_write_1h
count of content[].type == "tool_use" → tool_calls

Not exposed by Anthropic (folded into output_tokens):
reasoning_tokens — even with extended thinking enabled

Unknown usage fields (service_tier, inference_geo, server_tool_use, …) land in extras.
"""

from __future__ import annotations

from typing import Any, cast

from ..canonical import CanonicalUsage

_KNOWN_USAGE_FIELDS = {
"input_tokens",
"output_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
"cache_creation",
}


def _safe_dict(v: Any) -> dict[str, Any]:
return v if isinstance(v, dict) else {}


def _safe_int(v: Any) -> int:
try:
return max(0, int(v or 0))
except (TypeError, ValueError):
return 0


def _to_dict(obj: Any) -> dict[str, Any]:
"""Best-effort pydantic-or-dict to dict (Anthropic SDK returns pydantic Message objects)."""
if isinstance(obj, dict):
return obj
if hasattr(obj, "model_dump"):
try:
return cast(dict[str, Any], obj.model_dump())
except Exception: # noqa: BLE001
pass
return {}


def extract_anthropic_native(response: Any, model_id: str = "") -> CanonicalUsage:
"""Translate an Anthropic native response (Message or dict) → CanonicalUsage.

Accepts the SDK's pydantic Message object, a dict (e.g. captured fixture),
or a synthetic `{"usage": {...}}` blob produced by the streaming wrapper.
"""
resp = _to_dict(response) if not isinstance(response, dict) else response

usage = _safe_dict(resp.get("usage"))
cache_creation = _safe_dict(usage.get("cache_creation"))

content = resp.get("content")
tool_calls = (
sum(1 for b in content if isinstance(b, dict) and b.get("type") == "tool_use")
if isinstance(content, list)
else 0
)

extras: dict[str, Any] = {}
for k, v in usage.items():
if k not in _KNOWN_USAGE_FIELDS:
extras[k] = v

return CanonicalUsage(
input=_safe_int(usage.get("input_tokens")),
output=_safe_int(usage.get("output_tokens")),
cache_read=_safe_int(usage.get("cache_read_input_tokens")),
cache_write=_safe_int(usage.get("cache_creation_input_tokens")),
cache_write_5m=_safe_int(cache_creation.get("ephemeral_5m_input_tokens")),
cache_write_1h=_safe_int(cache_creation.get("ephemeral_1h_input_tokens")),
tool_calls=tool_calls,
model=model_id or (resp.get("model") if isinstance(resp.get("model"), str) else "") or "",
provider="anthropic",
api="native",
extras=extras,
)
8 changes: 6 additions & 2 deletions src/lago_agent_sdk/sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,17 @@ def wrap(
from .wrappers.mistral import wrap_mistral_client

return wrap_mistral_client(self, client, dimensions=dimensions, subscription=subscription)
if kind == "anthropic":
from .wrappers.anthropic import wrap_anthropic_client

return wrap_anthropic_client(self, client, dimensions=dimensions, subscription=subscription)
if kind == "unknown":
raise UnknownClientError(
f"Unknown client passed to wrap(): {type(client).__module__}.{type(client).__name__}. "
"Supported: boto3 bedrock-runtime, mistralai.client.Mistral."
"Supported: boto3 bedrock-runtime, mistralai.client.Mistral, anthropic.Anthropic / AsyncAnthropic."
)
raise UnknownClientError(
f"Client kind '{kind}' is not yet supported. Implemented: 'bedrock', 'mistral'."
f"Client kind '{kind}' is not yet supported. Implemented: 'bedrock', 'mistral', 'anthropic'."
)

# ------------------------------------------------------------------
Expand Down
Loading
Loading