Skip to content
Merged
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
40 changes: 27 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,37 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml
enable-cache: true
cache-dependency-glob: "uv.lock"

- name: Install
run: |
python -m pip install --upgrade pip
pip install -e '.[dev]'
- name: Install project (locked)
run: uv sync --all-extras

- name: Lint (ruff check)
run: uv run ruff check src tests

- name: Lint (ruff)
run: ruff check src tests
- name: Format (ruff format --check)
run: uv run ruff format --check src tests

- name: Type-check (mypy)
run: mypy src
run: uv run mypy src

- name: Unit tests
run: pytest tests/unit -q
- name: Unit tests (with coverage)
run: |
uv run pytest tests/unit -q \
--cov=lago_agent_sdk \
--cov-report=term-missing \
--cov-report=xml \
--cov-fail-under=80

- name: Upload coverage artifact
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
uses: actions/upload-artifact@v4
with:
name: coverage-xml
path: coverage.xml
if-no-files-found: warn
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dist/
.mypy_cache/
.ruff_cache/
.coverage
coverage.xml
htmlcov/

# Editor / OS
Expand Down
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.11
45 changes: 38 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,64 @@

## Development setup

Recommended: [uv](https://docs.astral.sh/uv/) (10× faster installs, lockfile-driven reproducible envs):

```bash
git clone https://github.com/getlago/lago-agent-sdk-python
cd lago-agent-sdk-python
python3.11 -m venv venv
source venv/bin/activate
uv sync --all-extras # creates .venv, installs from uv.lock
```

Plain pip works too:

```bash
python3.11 -m venv venv && source venv/bin/activate
pip install -e '.[dev]'
```

Common workflows are wired through the Makefile:

```bash
make sync # install/sync deps from uv.lock
make test # unit tests
make lint # ruff check + ruff format --check + mypy
make format # auto-fix lint and format
make check # lint + test (what CI runs)
```

## Run tests

```bash
# Unit tests (fast, no network)
pytest tests/unit
make test

# Unit tests with coverage report
uv run pytest tests/unit --cov=lago_agent_sdk --cov-report=term-missing

# Integration tests (require credentials — see env vars in each test)
AWS_BEARER_TOKEN_BEDROCK="..." \
MISTRAL_API_KEY="..." \
LAGO_API_URL="..." LAGO_API_KEY="..." LAGO_EXTERNAL_SUBSCRIPTION_ID="..." \
pytest tests/integration
uv run pytest tests/integration -q
```

## Linting and type checks

```bash
ruff check src tests
ruff format --check src tests
mypy src
make lint # all three at once
# or directly:
uv run ruff check src tests
uv run ruff format --check src tests
uv run mypy src
```

CI gates on all of the above plus an 80% coverage floor. Raising the floor is encouraged as coverage improves.

## Updating dependencies

```bash
uv lock --upgrade # refresh the lockfile (commit the diff)
uv lock --upgrade-package X # bump a single package
```

## Where things live
Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Comment thread
anassg-lago marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
The MIT License (MIT)

Copyright (c) 2025–2026 Lago

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
21 changes: 21 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.PHONY: sync test smoke lint format check clean

sync:
uv sync --all-extras

test:
uv run pytest tests/unit -q

smoke:
uv run python tests/smoke.py

lint:
uv run ruff check src tests
uv run ruff format --check src tests
uv run mypy src

format:
uv run ruff format src tests
uv run ruff check --fix src tests

check: lint test
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,7 @@ pytest tests/integration
## Security

Found a vulnerability? See [SECURITY.md](SECURITY.md).

## License

[MIT LICENSE](LICENSE).
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ mistral = ["mistralai>=2.0"]
dev = [
"pytest>=7",
"pytest-asyncio>=0.23",
"pytest-cov>=5",
"boto3>=1.34",
"mistralai>=2.0",
"hypothesis>=6",
Expand Down
1 change: 1 addition & 0 deletions src/lago_agent_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Lago Agent SDK — Python."""

from .canonical import CanonicalUsage
from .config import DEFAULT_METRIC_CODES, LagoConfig
from .exceptions import (
Expand Down
1 change: 1 addition & 0 deletions src/lago_agent_sdk/adapters/bedrock_converse.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- full-cache : adds cacheReadInputTokens + cacheWriteInputTokens
(Claude Sonnet 4.5/4.6, Haiku 4.5, Opus 4.5/4.6).
"""

from __future__ import annotations

from typing import Any
Expand Down
5 changes: 4 additions & 1 deletion src/lago_agent_sdk/adapters/bedrock_invoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
4.6.7 mistral_legacy — Mistral 7B / Mixtral 8x7B / Mistral Large 24.02
(no usage; emit WARN, return _no_usage extras)
"""

from __future__ import annotations

import logging
Expand Down Expand Up @@ -154,7 +155,9 @@ def _extract_anthropic(resp: dict[str, Any], model_id: str) -> CanonicalUsage:
# Tool calls — count `type == "tool_use"` content blocks
content = resp.get("content") if isinstance(resp, dict) else None
tool_calls = (
sum(1 for b in content if isinstance(b, dict) and b.get("type") == "tool_use") if isinstance(content, list) else 0
sum(1 for b in content if isinstance(b, dict) and b.get("type") == "tool_use")
if isinstance(content, list)
else 0
)

for k, v in usage.items():
Expand Down
1 change: 1 addition & 0 deletions src/lago_agent_sdk/adapters/mistral_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
(NOT `usage.prompt_cache_hit_tokens` — that field does not exist)
- Reasoning, cache_write, image_input, audio_input not exposed by Mistral.
"""

from __future__ import annotations

from typing import Any
Expand Down
1 change: 1 addition & 0 deletions src/lago_agent_sdk/canonical.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""CanonicalUsage — normalized usage shape emitted to Lago."""

from __future__ import annotations

from dataclasses import asdict, dataclass, field
Expand Down
24 changes: 23 additions & 1 deletion src/lago_agent_sdk/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""LagoConfig — runtime configuration for the SDK."""

from __future__ import annotations

from collections.abc import Callable
Expand All @@ -18,9 +19,18 @@
}


def _mask_api_key(api_key: str) -> str:
"""Render an api key safe for logs/repr: keeps a 4-char tail for debuggability."""
if not api_key:
return "<unset>"
if len(api_key) <= 8:
return "***"
return f"***{api_key[-4:]}"


@dataclass
class LagoConfig:
api_key: str = ""
api_key: str = field(default="", repr=False)
api_url: str = "https://api.getlago.com/api/v1"
default_subscription_id: str | None = None
metric_codes: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_METRIC_CODES))
Expand All @@ -30,3 +40,15 @@ class LagoConfig:
request_timeout_seconds: float = 10.0
max_retry_seconds: float = 60.0
on_error: Callable[[Exception, str], None] | None = None

def __repr__(self) -> str:
return (
f"LagoConfig(api_key={_mask_api_key(self.api_key)!r}, "
f"api_url={self.api_url!r}, "
f"default_subscription_id={self.default_subscription_id!r}, "
f"flush_interval_seconds={self.flush_interval_seconds}, "
f"max_batch_size={self.max_batch_size}, "
f"max_buffer_size={self.max_buffer_size}, "
f"request_timeout_seconds={self.request_timeout_seconds}, "
f"max_retry_seconds={self.max_retry_seconds})"
)
1 change: 1 addition & 0 deletions src/lago_agent_sdk/detector.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Detect which client kind was passed to wrap()."""

from __future__ import annotations

from typing import Any
Expand Down
1 change: 1 addition & 0 deletions src/lago_agent_sdk/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Error types for the Lago Agent SDK."""

from __future__ import annotations


Expand Down
10 changes: 10 additions & 0 deletions src/lago_agent_sdk/lago_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Thin HTTP client to Lago."""

from __future__ import annotations

import json
Expand All @@ -15,6 +16,15 @@ def __init__(self, api_key: str, api_url: str, timeout: float = 10.0) -> None:
self.api_url = api_url.rstrip("/")
self.timeout = timeout

def __repr__(self) -> str:
if not self.api_key:
masked = "<unset>"
elif len(self.api_key) <= 8:
masked = "***"
else:
masked = f"***{self.api_key[-4:]}"
return f"LagoClient(api_key={masked!r}, api_url={self.api_url!r}, timeout={self.timeout})"

def send_batch(self, events: list[dict[str, Any]]) -> None:
if not events:
return
Expand Down
5 changes: 4 additions & 1 deletion src/lago_agent_sdk/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
failure, re-prepends the batch and applies exponential backoff
(1s, 2s, 4s, 8s, capped at 60s). Resets on next success.
"""

from __future__ import annotations

import atexit
Expand Down Expand Up @@ -135,7 +136,9 @@ def _run(self) -> None:
pass
logger.warning("lago send_batch failed: %s", exc)
self._backoff_seconds = (
1.0 if self._backoff_seconds == 0 else min(self._backoff_seconds * 2, self._max_retry_seconds)
1.0
if self._backoff_seconds == 0
else min(self._backoff_seconds * 2, self._max_retry_seconds)
)
break
# drain on exit
Expand Down
7 changes: 6 additions & 1 deletion src/lago_agent_sdk/sdk.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""LagoSDK — primary entrypoint."""

from __future__ import annotations

import contextvars
Expand Down Expand Up @@ -70,13 +71,17 @@ def _resolve_subscription(self, override: str | None) -> str | None:
# ------------------------------------------------------------------
# Wrap()
# ------------------------------------------------------------------
def wrap(self, client: Any, dimensions: dict[str, Any] | None = None, subscription: str | None = None) -> Any:
def wrap(
self, client: Any, dimensions: dict[str, Any] | None = None, subscription: str | None = None
) -> Any:
kind = detect_client_kind(client)
if kind == "bedrock":
from .wrappers.boto3_bedrock import wrap_boto3_bedrock_client

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

return wrap_mistral_client(self, client, dimensions=dimensions, subscription=subscription)
if kind == "unknown":
raise UnknownClientError(
Expand Down
6 changes: 4 additions & 2 deletions src/lago_agent_sdk/wrappers/boto3_bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
once, parse JSON, extract usage, and **re-wrap** it as a fresh `StreamingBody`
so customer code reading `response['body'].read()` works unchanged.
"""

from __future__ import annotations

import io
Expand All @@ -31,6 +32,7 @@ def _restream_body(body_bytes: bytes) -> Any:
"""Build a botocore StreamingBody equivalent so customer .read() still works."""
try:
from botocore.response import StreamingBody

return StreamingBody(io.BytesIO(body_bytes), len(body_bytes))
except Exception: # noqa: BLE001
return io.BytesIO(body_bytes)
Expand Down Expand Up @@ -159,8 +161,8 @@ def _wrap_invoke_stream() -> Iterator[Any]:
# or OpenAI-compat shape on the final delta), and Bedrock's invocation
# metrics on the very last `message_stop` chunk. We accumulate both
# without overwriting and pick the richer one at the end.
usage_payload: dict[str, Any] = {} # Anthropic / OpenAI-shape
bedrock_metrics: dict[str, Any] = {} # amazon-bedrock-invocationMetrics
usage_payload: dict[str, Any] = {} # Anthropic / OpenAI-shape
bedrock_metrics: dict[str, Any] = {} # amazon-bedrock-invocationMetrics
try:
for event in original_body:
if isinstance(event, dict) and "chunk" in event:
Expand Down
1 change: 1 addition & 0 deletions src/lago_agent_sdk/wrappers/mistral.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Wraps `Mistral.chat.complete` and `.stream` in place — instrumentation never
breaks the customer's call. Streaming captures usage from the final chunk.
"""

from __future__ import annotations

import logging
Expand Down
Loading
Loading