Skip to content

Add native Anthropic, OpenAI, and Gemini SDK support#3

Merged
anassg-lago merged 9 commits into
mainfrom
feature/anthropic-openai-gemini-native
Jun 3, 2026
Merged

Add native Anthropic, OpenAI, and Gemini SDK support#3
anassg-lago merged 9 commits into
mainfrom
feature/anthropic-openai-gemini-native

Conversation

@anassg-lago

Copy link
Copy Markdown
Collaborator

Summary

Adds three new native LLM provider integrations to the SDK, bringing total coverage to five (Bedrock + Mistral + Anthropic + OpenAI + Gemini). Plus a canonical-schema extension for audio-output tokens and a flaky-test fix.

Commits in this PR

Commit Summary
c23e692 Fix flaky test_repeated_overflow_keeps_window_sliding (race condition with max_batch_size == max_buffer_size)
8da7b82 Native Anthropic SDK support (messages.create sync + stream, messages.stream context manager)
0f79c5a Native OpenAI SDK support (Chat Completions + Responses API, sync + stream + async)
6c487ab Native Gemini SDK support (google-genai, sync + stream + async via client.aio.models)

Provider coverage matrix

Provider Sync Stream Async Tool calls Reasoning Cache Multimodal
Anthropic native ✗ (folded) ✓ (5m + 1h)
OpenAI native ✓ (subset of output) ✓ (auto) ✓ (audio)
Gemini native ✓ (additive to output) ✓ (CachedContent API) ✓ (audio + image, per modality)

Key design decisions

  • CanonicalUsage extended with audio_outputllm_audio_output_tokens metric code. Populated by GPT-4o-audio output and Gemini TTS responses.
  • Detector now returns gemini (was google) for google-genai clients — keeps naming consistent with bedrock/anthropic/openai/mistral.
  • OpenAI streaming auto-injects stream_options.include_usage=True when missing. Without it, OpenAI emits no usage on streamed responses — silent under-billing.
  • OpenAI adapter auto-detects Chat Completions vs Responses API by usage-field shape (prompt_tokens vs input_tokens).
  • Reasoning-tokens semantic difference documented: OpenAI's reasoning_tokens is a SUBSET of completion_tokens; Gemini's thoughtsTokenCount is ADDITIVE to candidatesTokenCount. Customers configuring per-metric billing must account for this. Documented in adapter docstrings + README.

Tests

  • 283 → 304 unit tests (+19 Anthropic +18 OpenAI adapter +9 OpenAI wrapper +15 Gemini adapter +6 Gemini wrapper)
  • Live integration tests (skipped without API keys): 3 Anthropic + 5 OpenAI + 4 Gemini — all pass against the real APIs with a mock Lago HTTP server
  • 24 captured response fixtures from real APIs (9 Anthropic + 10 OpenAI + 5 Gemini)
  • Coverage maintained ≥ 80%

Quality gate

  • ruff check src tests — clean
  • mypy --strict src — 0 issues in 21 source files
  • pytest tests/unit — 304/304 pass
  • Live integration tests verified manually against real APIs

Known gaps (intentional, documented)

  • OpenAI Predicted Outputs tokens (accepted_/rejected_prediction_tokens) — not surfaced; would risk double-counting against completion_tokens
  • Gemini Vertex AI mode — same adapter works (same response shape), not specifically tested
  • Multimodal image input on OpenAI — Phase 3

Test plan

  • Review the canonical-schema change (single new field: audio_output)
  • Verify the detector change doesn't break existing kind == "google" consumers (none today)
  • Run unit suite locally (pytest tests/unit -q)
  • Optionally run live integration tests with API keys exported

- src/lago_agent_sdk/adapters/anthropic_native.py — extract_anthropic_native
- src/lago_agent_sdk/wrappers/anthropic.py — wraps messages.create (sync + async,
  streaming and non-streaming) and messages.stream context manager
- Wired into sdk.wrap() dispatch and adapters/__init__.py exports
- anthropic = ["anthropic>=0.30"] optional-dep group
- 19 new unit tests + 3 live integration tests; 256 unit tests pass
- Coverage 80.71% — gate maintained
- 9 captured response fixtures from real Anthropic API
- README + CHANGELOG updated
The test set max_batch_size == max_buffer_size == 100, which caused the
push that brings the buffer to 100 to trigger a wake on the background
worker. The worker would take a batch (emptying the buffer) and then race
with the remaining 150 pushes to call slow_sender. On CI's slower runners
the worker sometimes squeezed in additional batches before slow_sender
finally blocked, leaving the buffer with fewer items than the expected
sliding window.

Setting max_batch_size > max_buffer_size guarantees push() never sets the
wake event (buffer can never reach max_batch_size). Combined with a long
flush_interval the worker only runs once shutdown() releases the pause in
the finally block — fully deterministic. Verified with 5 consecutive runs.
Adapter handles both API shapes with auto-detection:

  Chat Completions (client.chat.completions.create):
    usage.prompt_tokens                                -> input
    usage.completion_tokens                            -> output
    usage.prompt_tokens_details.cached_tokens          -> cache_read
    usage.prompt_tokens_details.audio_tokens           -> audio_input
    usage.completion_tokens_details.reasoning_tokens   -> reasoning   (o-series)
    usage.completion_tokens_details.audio_tokens       -> audio_output
    count of choices[0].message.tool_calls             -> tool_calls

  Responses API (client.responses.create):
    usage.input_tokens                                 -> input
    usage.output_tokens                                -> output
    usage.input_tokens_details.cached_tokens           -> cache_read
    usage.output_tokens_details.reasoning_tokens       -> reasoning
    count of output[].type == "function_call"          -> tool_calls

Wrapper covers both methods, sync + streaming, on both OpenAI and AsyncOpenAI.
For Chat Completions streaming, auto-injects stream_options.include_usage=true
when missing so the final chunk carries usage data (without that flag, OpenAI
emits no usage on streamed responses).

CanonicalUsage extended with audio_output (mapped to llm_audio_output_tokens)
to capture GPT-4o-audio output usage.

OpenAI is the first provider to actually populate llm_reasoning_tokens
(o-series surfaces reasoning tokens separately; Anthropic/Bedrock fold them
into output_tokens).

Predicted Outputs tokens (accepted/rejected_prediction_tokens) are
intentionally not surfaced -- documented in the adapter docstring as a
v1 gap.

27 new unit tests (18 adapter + 9 wrapper). 5 live integration tests gated
on OPENAI_API_KEY. 10 captured response fixtures from the real OpenAI API.

Total: 283 unit tests passing, ruff + mypy strict clean.
Adapter maps usage_metadata fields to CanonicalUsage:

  prompt_token_count                                   -> input
  candidates_token_count                               -> output
  cached_content_token_count                           -> cache_read
  thoughts_token_count                                 -> reasoning
  prompt_tokens_details[modality=AUDIO].token_count    -> audio_input
  prompt_tokens_details[modality=IMAGE].token_count    -> image_input
  candidates_tokens_details[modality=AUDIO].token_count -> audio_output
  count of candidates[0].content.parts[].function_call -> tool_calls

Wrapper covers client.models.generate_content + generate_content_stream
(sync) and the async variants under client.aio.models. Idempotent via
_lago_instrumented sentinel.

Detector now returns 'gemini' (was 'google') for google-genai clients --
matches the naming convention used by other providers (bedrock, anthropic,
openai, mistral).

Semantic note vs OpenAI:
  Gemini's `thoughts_token_count` is ADDITIVE to `candidates_token_count`
  (verified by math across all 5 fixtures: input + output + reasoning = total).
  OpenAI's `reasoning_tokens` is a SUBSET of `completion_tokens`.
  Documented in adapter docstring + README for customers configuring
  per-metric billing.

Gemini 2.5 emits reasoning tokens by default (no explicit thinking_config
needed) -- second provider populating llm_reasoning_tokens.

21 new unit tests (15 adapter + 6 wrapper). 4 live integration tests
gated on GEMINI_API_KEY. 5 captured response fixtures (plain, tool use,
streaming, thinking, multi-turn).

Total: 304 unit tests passing, ruff + mypy strict clean.
CI runs `ruff format --check` which was failing because earlier dev only
ran `ruff check` (linter) locally, not the formatter. Auto-formatting
restores whitespace consistency in:

- src/lago_agent_sdk/adapters/gemini_native.py
- src/lago_agent_sdk/wrappers/openai.py
- tests/unit/adapters/fixtures/capture_openai.py
- tests/unit/adapters/test_gemini_native.py
- tests/unit/test_wrapper_gemini.py

No functional changes.
@vladmarascu
vladmarascu self-requested a review June 1, 2026 12:09
Comment thread src/lago_agent_sdk/wrappers/openai.py
Comment thread src/lago_agent_sdk/wrappers/anthropic.py
@vladmarascu

Copy link
Copy Markdown
Contributor

Good PR @anassg-lago 🙌 Left a few comms with some important bits and also these 2 more generals nits here:

  1. pyproject.toml: you now have two dev groups — [project.optional-dependencies].dev (mypy, types) and [dependency-groups].dev (the 3 SDKs). Works, but it's confusing to split test deps across both mechanisms; consider consolidating.

  2. All async wrapper paths are untested
    The Gemini fake explicitly omits .aio ("tests cover the sync path only"), and the Anthropic/OpenAI fakes and live tests are sync-only. That leaves _create_async, _wrap_async_stream, _make_async_generate, _make_async_stream, and the async stream-manager (aenter/aexit) with zero coverage — and that's exactly where bugs dev tooling: uv + Makefile + tighter CI, secret-redacted repr #1 and Add native Anthropic SDK support #2 live. These are nontrivial (async generators with finally-emit). Add async unit tests with fakes mirroring the sync ones.

Three bugs in Python async paths and the matching coverage gaps:

OpenAI wrapper
  - Responses API + stream=True was injecting stream_options.include_usage,
    a parameter the Responses API does not accept. It would raise TypeError
    or HTTP 400 on the customer's call. Factory now takes is_responses_api
    and only injects on the chat-completions path.
  - Streaming usage extraction missed Responses API events: their terminal
    response.completed event nests usage under event.response.usage rather
    than at the top level. New _extract_stream_usage helper handles both
    shapes.

Anthropic wrapper
  - _LagoStreamManager.__aexit__ called the sync _emit_final, which in turn
    called get_final_message() without await. On AsyncMessageStream the
    method is a coroutine; the un-awaited coroutine fell through to {}
    and nothing got billed. Added _emit_final_async; __aexit__ awaits it.

Mistral wrapper
  - chat.stream_async in mistralai v2 is async def, returning a coroutine
    that must be awaited before iterating. The wrapper was iterating the
    coroutine directly, raising TypeError on first iteration. One-line
    `await` added.

Plus new test coverage for the async paths that previously had none:
  - 5 async tests on OpenAI wrapper (chat + responses, with stream variants)
  - 3 async tests on Anthropic wrapper (messages.create + messages.stream
    context manager)
  - 3 async tests on Mistral wrapper (complete_async + stream_async)
  - 2 async tests on Gemini wrapper (client.aio.models surface)

Coverage climbs from 80.16% to 89.28%. Total Python unit tests 304 → 318.
Three new live tests, skipped unless their matching API key is set, that
exercise the real provider API end-to-end against a mock Lago server:

  - test_live_openai_responses_create_with_stream_emits_to_lago
    Hits the Responses API with stream=True. Before the fix the call
    crashed because stream_options was injected; after the fix the call
    succeeds and usage is extracted from event.response.usage on the
    terminal response.completed event.

  - test_live_async_anthropic_messages_stream_context_manager_emits
    Uses AsyncAnthropic + `async with client.messages.stream(...)`.
    Before the fix __aexit__ ran the sync _emit_final, which called
    get_final_message() without await; the un-awaited coroutine fell
    through as {} and no event was emitted.

  - test_live_mistral_chat_stream_async_emits_to_lago
    Uses `stream = await client.chat.stream_async(...)`. Before the fix
    the wrapper's _stream_async was sync def, so customers got an async
    generator back directly instead of the awaitable shape the real
    mistralai SDK provides. Fix: make _stream_async async def, preserving
    the SDK's API contract.

Also corrected the unit test for stream_async to use the natural
`await client.chat.stream_async(...)` pattern.

All three live tests verified passing against the real OpenAI, Anthropic
and Mistral APIs.
Publish:
  .github/workflows/publish.yml triggered on `v*.*.*` tag push. Four-stage
  pipeline:
    1. test     — full CI gate (ruff, mypy, pytest, cov ≥ 80%) on 3.10/3.11/3.12
    2. build    — verify tag matches pyproject version, build sdist + wheel
    3. publish  — upload to PyPI via OIDC trusted publishing (no token in repo)
    4. release  — open a GitHub Release with auto-generated notes + artifacts

  Auth via OIDC means no long-lived PYPI_TOKEN secret to leak. One-time
  PyPI-side setup documented in RELEASING.md.

  Customer-facing release flow becomes one command:
    git tag v0.X.Y && git push --tags

Dev-group cleanup:
  Consolidated [project.optional-dependencies].dev and
  [dependency-groups].dev into a single dev extra. Removed duplicates
  (boto3/mistralai were in both bedrock/mistral extras AND the dev extra;
  anthropic/openai/google-genai were in their own extras AND the
  dependency-groups dev). One source of truth now:
    `pip install -e '.[dev]'` gives you everything to develop and test.

  User-facing per-provider extras (bedrock, mistral, anthropic, openai,
  gemini) are unchanged.
Comment thread README.md
| audio_output | `llm_audio_output_tokens` | ✗ | ✗ | ✗ | ✓ (GPT-4o-audio) | ✓ (multimodal AUDIO) |
| image_input | `llm_image_input_tokens` | ✗ | ✗ | ✗ | ✗ (Phase 3) | ✓ (multimodal IMAGE) |

**Semantic note on `reasoning`:**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cache_read, audio_input, and image_input are all subsets of input for both OpenAI (prompt_tokens_details.*) and Gemini (prompt_tokens_details[modality=…]) , same trap as reasoning. A customer metering llm_input_tokens + llm_audio_input_tokens separately and summing them will double-count. Can we add a note here that these are subsets of input, not additive? Thxxx

return "mistral"
if "google" in module and ("genai" in module or "generativeai" in module):
return "google"
return "gemini"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also matches the legacy google-generativeai SDK, but wrap_gemini_client only handles the new google-genai .models/.aio surface , a legacy client routes here and silently wraps nothing. Either narrow this to genai only, or handle/reject generativeai explicitly.

for event in src:
payload = event.model_dump() if hasattr(event, "model_dump") else event
if isinstance(payload, dict):
usage = payload.get("usage")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only top-level usage is read, which is present on message_delta. The authoritative input/cache counts arrive on message_start (nested under message.usage) and are ignored --> billing relies on message_delta echoing input_tokens. If the SDK ever drops that, input silently bills 0. Consider merging message_start (input/cache) with message_delta (final output).

@vladmarascu

Copy link
Copy Markdown
Contributor

Having a look at the release pipeline and have some suggestions for security:

  1. You correctly scope id-token: write (publish) and contents: write (release) per-job, but with no top-level block the test and build jobs inherit the repo's default GITHUB_TOKEN permissions (often read/write). Add least-privilege at the top and let the two jobs opt up:

permissions:
contents: read

  1. actions/checkout@v4, astral-sh/setup-uv@v4, actions/upload|download-artifact@v4, and pypa/gh-action-pypi-publish@release/v1 (a branch) are all movable. In a workflow that mints an OIDC token with PyPI publish rights, a compromised tag/branch could exfiltrate that token or swap the artifact. Pin to full commit SHAs with a version comment:
  • uses: astral-sh/setup-uv@ # v4.x
  1. The pypi environment has no protection rules
    RELEASING.md says "No secrets needed inside it" that is true, but the environment is still your deploy gate. Since trusted publishing is bound to environment: pypi and tags aren't branch-scoped, anyone who can push a v*.. tag can publish to PyPI. Add deployment protection to the pypi environment (required reviewers, and/or restrict deployments to protected tags). That's the control that makes "bound to the pypi environment" actually mean something.

I don't have a lot of exp with release workflows so feel free to take this with a grain of salt or ask for a second pair of eyes!

@vladmarascu
vladmarascu self-requested a review June 2, 2026 15:06
Bugs
  - Anthropic messages.create(stream=True) under-billed input tokens. The
    stream wrapper read only the top-level `usage`, which on a basic stream
    appears only on message_delta as {output_tokens: N}. The authoritative
    input/cache counts arrive nested under message.usage on message_start and
    were dropped, so input billed 0. New _merge_stream_usage folds both
    locations (message_start input/cache + message_delta cumulative output)
    across sync and async paths. Fixtures now use the realistic wire shape
    (message_delta carries no input echo), so the stream tests are genuine
    regressions.

  - Legacy google-generativeai SDK silently emitted nothing. The detector
    matched both google-genai and the deprecated google-generativeai, but the
    wrapper only instruments the unified Client.models/.aio surface, so a
    legacy GenerativeModel wrapped nothing. Detector now returns a distinct
    'gemini_legacy' kind and wrap() rejects it with a migrate-to-google-genai
    message. ("genai" is not a substring of "generativeai", so no overlap.)

Docs
  - README: cache_read / audio_input / image_input are subsets of input for
    OpenAI and Gemini, not additive — summing them double-counts.

Publish-workflow hardening
  - Least-privilege default (permissions: contents: read); only publish gets
    id-token: write, only release gets contents: write.
  - All third-party actions pinned to full commit SHAs (version in comment).
  - Added `if: startsWith(github.ref, 'refs/tags/v')` to the publish job as
    defense-in-depth.
  - Added .github/dependabot.yml (github-actions) to keep SHA pins fresh.
  - RELEASING.md documents pypi environment protection (required reviewers +
    protected-tag restriction) as a REQUIRED setup step.

Gate: ruff + format + mypy clean; 319 unit tests pass; coverage 89.27%.

@vladmarascu vladmarascu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm! ty for addressing 🙌

@anassg-lago
anassg-lago merged commit b4ba5fa into main Jun 3, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants