LAM-2172: Fix duplicate LLM spans from litellm's API bridge re-entrancy - #331
Open
laminar-coding-agent[bot] wants to merge 1 commit into
Open
LAM-2172: Fix duplicate LLM spans from litellm's API bridge re-entrancy#331laminar-coding-agent[bot] wants to merge 1 commit into
laminar-coding-agent[bot] wants to merge 1 commit into
Conversation
LiteLLM re-enters its own public API, and both entry points are instrumented. `completion()` for a model whose `model_info["mode"] == "responses"` (or a `responses/`-prefixed model, or gpt-5.4+ with tools and `reasoning_effort`) routes through `completion_extras/litellm_responses_transformation`, which does `from litellm import responses` and calls it. The reverse bridge sends `responses()` for a chat-only provider back through `completion()`. The result was that one user-visible call emitted two LLM spans with identical messages AND identical usage, so a trace's token and cost rollups were exactly doubled. On the async path the duplicate was not even nested: `wrap_completion` returns a coroutine, so by the time the bridge fires our span is no longer the active OTel span and the second span lands as a sibling of the caller's parent — reading as two independent LLM calls rather than an obvious nesting bug. Guard both wrappers on `is_in_litellm_context()`: whichever entry point the user called owns the span, the bridged inner call passes straight through. `wrap_responses` also now enters `in_litellm_context()` around `wrapped()` (`wrap_completion` always did), without which the guard never trips in the responses -> completion direction. Nothing is lost by deduplicating — the surviving span still carries the full `gen_ai.input.messages` / `gen_ai.output.messages`, transformed back into the entry point's own shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
LiteLLM re-enters its own public API, and Laminar instruments both entry points.
litellm.completion()for a model whosemodel_info["mode"] == "responses"(or aresponses/-prefixed model, or gpt-5.4+ with tools +reasoning_effort) routes throughlitellm/completion_extras/litellm_responses_transformation/handler.py, which doesfrom litellm import responsesand calls it.litellm.responses()for a chat-only provider (e.g.gemini/*) back throughcompletion().So one user-visible call emitted two LLM spans carrying identical messages and identical usage — token and cost rollups on the trace were exactly doubled.
On the async path the duplicate isn't even nested:
wrap_completionreturns a coroutine, so by the time the bridge fires our span is no longer the active OTel span, and the second span lands as a sibling of the caller's parent. That's why it reads as two independent LLM calls rather than an obvious nesting bug.Real trace that surfaced this (
azure/gpt-5.6-sol, APEX trajectory collection):585dee37-d9ec-e106-9b8a-faf3922bda04—litellm.completionandlitellm.responsesas siblings, both 23387 in / 74 out, costed $0.119155 and $0.095028 independently.What changed
src/lmnr/opentelemetry_lib/opentelemetry/instrumentation/litellm/wrappers/__init__.py:wrap_completionandwrap_responsesboth bail out to a barewrapped(*args, **kwargs)whenis_in_litellm_context(). Whichever entry point the user actually called owns the span; the bridged inner call passes straight through.wrap_responsesnow also entersin_litellm_context()aroundwrapped()—wrap_completionalways did. Without this the guard never trips in the responses → completion direction (and the raw provider instrumentors underneath don't know the call is already traced).Deduplicating loses nothing: the surviving span still carries full
gen_ai.input.messages/gen_ai.output.messages, transformed back into the entry point's own shape. The new sync test asserts that explicitly.Tests
Three new VCR-backed tests in
tests/test_litellm.py, one per direction plus the async variant:test_litellm_completion_via_responses_bridge_creates_one_spantest_litellm_completion_via_responses_bridge_creates_one_span_async(the path that actually bit us)test_litellm_responses_via_completion_bridge_creates_one_spanEach asserts exactly one span survives and that its usage is counted once.
Two gotchas worth knowing (also written into
CLAUDE.md):mock_responsecannot exercise the bridge —litellm.main.completionreturns from itsif mock_response or ...branch ~25 lines beforeresponses_api_bridge_checkruns, hence the cassettes. And theresponses/prefix needs an explicit provider (openai/responses/gpt-4.1-nano); a bareresponses/gpt-4.1-nanoraisesBadRequestError: LLM Provider NOT providedbecause provider resolution happens first.Evidence
tests/test_litellm.py: 34 passed (31 before + 3 new).test_anthropic/test_thinking.py,test_openai/traces/test_azure.py,test_langchain.py(langgraph),test_observe_concurrency.py,test_tracing*.py, plus 3 bedrock ERRORs.src/and re-running gives 2 spans of 8/8 tokens; with the fix, 1.Full end-to-end runs — two APEX agent tasks re-run against the editable SDK, each a same-task/same-model before/after, with the harness's own
usage.call_logas ground truth:task_699ea5ca/gpt-5.6-terracompletion+ 10responses)task_699ea5ca/gpt-5.6-terracompletiononly)task_0dab6565/claude-sonnet-5completiononly)task_0dab6565/claude-sonnet-5completiononly)Before, the trace rollup was exactly 2x the truth (the harness reported 293,765 / 2,352 and $0.149136 for that run). After, the trace rollup matches the harness's totals exactly, and the nine spans' per-call
(input, output)pairs reproduce the ninecall_logentries in order —23206/78, 23337/103, 24309/181, 24791/389, 31165/303, 33628/1162, 34853/515, 35228/435, 35600/137— all parented to theapexroot, no siblings.The Claude run is the control:
anthropic/*never hits the bridge, and it is unchanged before and after, so the guard doesn't suppress legitimate spans.Post-fix traces:
ae3a6803-763c-612c-d697-93e9b7085309(gpt),45fe4d74-f45a-1e1c-f7d4-2fe9cf6d50cf(claude).Memory
CLAUDE.mdgains two bullets in the litellm section: the bridge re-entrancy invariant (including the sibling-not-child async detail and the requirement thatwrap_responsesenter the context), and themock_response/ model-prefix testing gotchas.🤖 Generated with Claude Code