Skip to content

Commit bc2bbfa

Browse files
committed
fix(0.9.1): unified LLM-call fingerprint collapses httpx + langchain duplicates
Pre-0.9.1 the httpx transport and the LangChain callback each computed their own _fingerprint from different inputs (sha256 of body bytes vs sha256 of langchain callback metadata). The two fingerprints never collided, so the dedup LRU at runtime.track() could not collapse the two emissions for the same real call. On a typical app.invoke() with 6 LLM calls the backend saw ~12 llm_call events on the wire (2 per real call), doubling llm_call_count and skewing cost_events aggregates. Both observers now call _fingerprint_for_llm_call(model, provider, response_id) with the three signals reachable from every path: - httpx transport: payload['model'], payload['id'] - LangChain callback: invocation_params.model / response.llm_output['model_name'], response.llm_output['id'] / response.id / AIMessage.id / response.response_metadata['id'] _openai_extractor now also returns 'id' alongside 'model' so the transport has it without re-parsing the body. When any of the three signals is missing the helper falls back to the empty string on that slot — deterministic for the call, just less specific. A missing id (custom chat-model wrappers) still collapses the two observers via the model+provider combination. tests/test_unified_fingerprint.py pins the new contract: deterministic, distinct inputs → distinct fingerprints, both observers converge on the same fingerprint for the same call. Bumps version 0.9.0 → 0.9.1. CHANGELOG entry added above 0.9.0. No public-API break.
1 parent f51780a commit bc2bbfa

6 files changed

Lines changed: 907 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,64 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
77

88
---
99

10+
## [0.9.1] - 2026-06-29
11+
12+
Patch on top of 0.9.0. Unifies the LLM-call fingerprint scheme so the
13+
dedup LRU at `runtime.track()` can collapse sibling emissions from the
14+
httpx transport and the LangChain callback for the same real call.
15+
16+
### Fixed
17+
18+
- **Double-emission of llm_call events.** Pre-0.9.1 the httpx transport
19+
(`NullRunSyncTransport._emit`) and the LangChain callback
20+
(`NullRunCallback.on_llm_end`) each computed their own `_fingerprint`
21+
from different inputs — `sha256(host|status|body)` vs
22+
`sha256(json({path:"langchain_callback", run_id, response_id, model,
23+
provider, invocation_params}))`. The two fingerprints never
24+
collided, so the dedup LRU at `runtime.track()` could not collapse
25+
the two emissions for the same call. On a typical `app.invoke()`
26+
with 6 LLM calls the backend saw ~12 `llm_call` events on the wire
27+
(2 per real call), doubling `llm_call_count` and skewing
28+
`cost_events` aggregates.
29+
30+
Post-fix both observers call the same helper
31+
`_fingerprint_for_llm_call(model, provider, response_id)` with the
32+
three signals reachable from every observation path:
33+
- httpx transport reads `model` and `id` straight out of the
34+
OpenAI-style response body (`payload["model"]`,
35+
`payload["id"]`). `_openai_extractor` now also carries `"id"` on
36+
its return so the transport has it without re-parsing the body.
37+
- LangChain callback reads `model` from `invocation_params` /
38+
`response.llm_output["model_name"]` and `id` from
39+
`response.llm_output["id"]` / `response.id` / the generation's
40+
AIMessage `.id` / `response.response_metadata["id"]` — all four
41+
locations are populated by langchain-openai 1.x for OpenAI chat
42+
completions.
43+
44+
When any of the three signals is missing, the helper falls back to
45+
the empty string on that slot; the resulting fingerprint is still
46+
deterministic for the call, just less specific. A missing `id`
47+
(custom chat-model wrappers that don't surface it) still collapses
48+
the two observers via the model+provider combination.
49+
50+
### Tests
51+
52+
- `tests/test_unified_fingerprint.py` pins the new contract:
53+
deterministic fingerprint for identical inputs, distinct
54+
fingerprints for distinct inputs, the httpx transport calls the
55+
helper with values extracted from the response body, the LangChain
56+
callback produces the SAME fingerprint for the same LLM call when
57+
reading the chat-completion id from any of the four known
58+
langchain locations.
59+
- `tests/test_llm_call_metadata_flags.py` updated to match the new
60+
extractor shape (`usage["id"]` is now present alongside
61+
`usage["model"]`).
62+
63+
No public-API break. No behavior change for callers whose
64+
instrumentation already populates `model` correctly.
65+
66+
---
67+
1068
## [0.9.0] - 2026-06-29
1169

1270
Server-derived coverage replaces the in-process counter dicts.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "nullrun"
7-
version = "0.9.0"
7+
version = "0.9.1"
88
# Long form used by PyPI page meta-description and search snippets.
99
# Kept under the 200-char preview threshold so the full line is visible
1010
# without an "expand" click. Keywords are matched against likely search

src/nullrun/instrumentation/auto.py

Lines changed: 178 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,15 @@ def _openai_extractor(body: bytes, status: int) -> ExtractedUsage | None:
169169
"completion_tokens": completion,
170170
"total_tokens": total,
171171
"model": payload.get("model"),
172+
# Audit 2026-06-29 (unified fingerprint): the upstream
173+
# chat-completion id (``payload["id"]``, e.g.
174+
# ``"chatcmpl-Dw7288WJI4bBDFyQ4DnZvhPUKfaZo"`` for OpenAI) is
175+
# the tightest discriminator for collapsing the sibling
176+
# LangChain-callback emission via ``_fingerprint_for_llm_call``.
177+
# Without this, the httpx path's fingerprint scheme (sha256 of
178+
# body bytes) never collides with the callback's scheme and
179+
# the dedup LRU cannot collapse duplicates.
180+
"id": payload.get("id"),
172181
# Phase 4.1: explicit cache / reasoning / finish / tool fields.
173182
# Previously these were reachable only via raw_usage (now
174183
# stripped at the wire boundary). Backend gate/budget/loop
@@ -181,6 +190,76 @@ def _openai_extractor(body: bytes, status: int) -> ExtractedUsage | None:
181190
}
182191

183192

193+
# ---------------------------------------------------------------------------
194+
# D2.5 (Audit 2026-06-29): unified LLM-call fingerprint
195+
# ---------------------------------------------------------------------------
196+
# The httpx transport and the LangChain callback both observe the same
197+
# real LLM call, but until this commit they computed fingerprints from
198+
# different inputs:
199+
# - httpx transport: sha256(host|status|body)
200+
# - LangChain callback: sha256(json({path, run_id, response_id, ...}))
201+
# Because the inputs differ, the two fingerprints never collided and the
202+
# dedup LRU at runtime.track() could not collapse the two emissions for the
203+
# same call. On a typical `app.invoke()` with 6 LLM calls the backend
204+
# saw ~12 llm_call events on the wire (2 per real call), which doubled
205+
# the dashboard's `llm_call_count` and skewed `cost_events` aggregates.
206+
#
207+
# The fix: a single helper that both observers call with the same three
208+
# signals (model + provider + upstream chat-completion id). The three are
209+
# reachable from every observer:
210+
# - httpx transport reads `model` and `id` straight out of the response
211+
# body JSON (`payload["model"]`, `payload["id"]`).
212+
# - LangChain callback reads `model` from `invocation_params` /
213+
# `response.llm_output["model_name"]` and `id` from
214+
# `response.llm_output["id"]` / `response.id` / the generation's
215+
# AIMessage `.id` / `response.response_metadata["id"]` — all four
216+
# locations are populated by langchain-openai 1.x for OpenAI chat
217+
# completions.
218+
# When any of the three signals is missing, the helper falls back to the
219+
# empty string on that slot; the resulting fingerprint is still
220+
# deterministic for the call, just less specific. That's intentional —
221+
# a missing `id` (custom chat-model wrappers that don't surface it) still
222+
# collapses the two observers via the model+provider combination; the
223+
# narrower the key, the fewer collisions across distinct calls.
224+
225+
def _fingerprint_for_llm_call(
226+
model: str | None,
227+
provider: str | None,
228+
response_id: str | None,
229+
) -> str:
230+
"""Unified fingerprint for one real LLM call.
231+
232+
Both the httpx transport hook (``NullRunSyncTransport._emit`` /
233+
``NullRunAsyncTransport._emit``) and the LangChain callback
234+
(``NullRunCallback.on_llm_end``) call this with the same three
235+
signals so the dedup LRU at ``runtime.track()`` can collapse the
236+
sibling emission for the same call to a single wire event.
237+
238+
Args:
239+
model: provider-side model id as returned by the upstream
240+
(``"gpt-4.1-mini-2025-04-14"`` for OpenAI, ``"claude-3-5-sonnet-..."``
241+
for Anthropic, etc.). None is acceptable; the slot still
242+
contributes to the fingerprint.
243+
provider: short provider label (``"openai"``, ``"anthropic"``,
244+
``"gemini"``, etc.). Same fallback semantics as ``model``.
245+
response_id: upstream chat-completion id (``"chatcmpl-..."`` for
246+
OpenAI, ``"msg_..."`` for Anthropic, etc.). This is the
247+
tightest discriminator — two LLM calls with the same model
248+
and provider will still have distinct response_ids, so this
249+
is the slot that prevents spurious collisions across
250+
unrelated calls.
251+
252+
Returns:
253+
A 16-char hex digest suitable for the ``_fingerprint`` event
254+
field consumed by ``NullRunRuntime.track()``.
255+
"""
256+
payload = f"{model or ''}|{provider or ''}|{response_id or ''}"
257+
h = hashlib.sha256()
258+
h.update(b"llm_call|")
259+
h.update(payload.encode("utf-8"))
260+
return h.hexdigest()[:16]
261+
262+
184263
def _anthropic_extractor(body: bytes, status: int) -> ExtractedUsage | None:
185264
"""Anthropic Messages API response shape.
186265
@@ -220,6 +299,9 @@ def _anthropic_extractor(body: bytes, status: int) -> ExtractedUsage | None:
220299
"completion_tokens": out,
221300
"total_tokens": inp + out,
222301
"model": payload.get("model"),
302+
# Audit 2026-06-29 (unified fingerprint): Anthropic message id,
303+
# e.g. ``"msg_01HXYZ..."``. See _openai_extractor comment.
304+
"id": payload.get("id"),
223305
"cache_read_tokens": int(usage.get("cache_read_input_tokens", 0) or 0),
224306
"cache_write_tokens": int(usage.get("cache_creation_input_tokens", 0) or 0),
225307
# Anthropic reasoning tokens are part of output_tokens (they're
@@ -273,6 +355,11 @@ def _gemini_extractor(body: bytes, status: int) -> ExtractedUsage | None:
273355
"completion_tokens": completion,
274356
"total_tokens": total or (prompt + completion),
275357
"model": payload.get("modelVersion"),
358+
# Audit 2026-06-29 (unified fingerprint): Gemini doesn't
359+
# currently surface a stable response id at the top level;
360+
# fall back to ``None`` and rely on model+provider to
361+
# disambiguate. See _openai_extractor for the rationale.
362+
"id": payload.get("responseId") or payload.get("id"),
276363
"cache_read_tokens": int(usage.get("cachedContentTokenCount", 0) or 0),
277364
"cache_write_tokens": 0,
278365
"reasoning_tokens": 0,
@@ -326,6 +413,10 @@ def _cohere_extractor(body: bytes, status: int) -> ExtractedUsage | None:
326413
"completion_tokens": out,
327414
"total_tokens": total,
328415
"model": payload.get("model"),
416+
# Audit 2026-06-29 (unified fingerprint): Cohere v2 doesn't
417+
# surface a stable response id at the top level; rely on
418+
# model+provider for disambiguation. See _openai_extractor.
419+
"id": payload.get("id") or payload.get("generation_id"),
329420
"cache_read_tokens": 0,
330421
"cache_write_tokens": 0,
331422
"reasoning_tokens": 0,
@@ -457,6 +548,13 @@ def _bedrock_extractor(body: bytes, status: int) -> ExtractedUsage | None:
457548
"completion_tokens": out,
458549
"total_tokens": total,
459550
"model": payload.get("modelId") or payload.get("model"),
551+
# Audit 2026-06-29 (unified fingerprint): Bedrock InvokeModel
552+
# response carries ``id`` at the top level (e.g.
553+
# ``"msg_01ABC..."`` for Anthropic-on-Bedrock, ``"cmpl-..."``
554+
# for Mistral-on-Bedrock). Falls back to ``None`` when the
555+
# adapter doesn't surface one; model+provider still give us
556+
# a fingerprint slot, just less specific.
557+
"id": payload.get("id"),
460558
"cache_read_tokens": cache_read,
461559
"cache_write_tokens": cache_write,
462560
"reasoning_tokens": 0,
@@ -746,6 +844,15 @@ def _emit(
746844
# columns; raw_usage is no longer on the wire (stripped
747845
# at the track() boundary — see _WIRE_STRIP_FIELDS in
748846
# runtime.py).
847+
#
848+
# Audit 2026-06-29 (unified fingerprint): we use the
849+
# ``_fingerprint_for_llm_call`` helper so this emission
850+
# shares the same dedup key as the LangChain callback's
851+
# emission for the same call. The previous per-transport
852+
# ``_fingerprint_for(host, body, status)`` produced a key
853+
# the callback could never collide with, doubling every
854+
# real LLM call on the wire.
855+
response_id = usage.get("id")
749856
self._runtime.track(
750857
{
751858
"type": "llm_call",
@@ -768,8 +875,15 @@ def _emit(
768875
# in runtime.py — kept here only so the in-process
769876
# dedup layer can see the full vendor payload.
770877
"raw_usage": usage,
771-
# Fingerprint for dedup at the track() sink.
772-
"_fingerprint": _fingerprint_for(host, body, status),
878+
# Audit 2026-06-29 (unified fingerprint): see
879+
# ``_fingerprint_for_llm_call`` — same key the
880+
# LangChain callback computes, so the dedup LRU
881+
# collapses the two emissions for the same call.
882+
"_fingerprint": _fingerprint_for_llm_call(
883+
model_for_event,
884+
_provider_label(host),
885+
response_id,
886+
),
773887
}
774888
)
775889
except Exception as e:
@@ -889,6 +1003,12 @@ def _emit(
8891003
# Phase 4.1: see sync _emit for rationale. Async path
8901004
# uses identical event shape so the dedup key space
8911005
# stays unified across sync + async transports.
1006+
#
1007+
# Audit 2026-06-29 (unified fingerprint): see sync
1008+
# _emit for the rationale — async transport must use the
1009+
# same key the LangChain callback computes so the dedup
1010+
# LRU collapses duplicates.
1011+
response_id = usage.get("id")
8921012
self._runtime.track(
8931013
{
8941014
"type": "llm_call",
@@ -908,7 +1028,11 @@ def _emit(
9081028
"tracked": True,
9091029
},
9101030
"raw_usage": usage,
911-
"_fingerprint": _fingerprint_for(host, body, status),
1031+
"_fingerprint": _fingerprint_for_llm_call(
1032+
usage.get("model"),
1033+
_provider_label(host),
1034+
response_id,
1035+
),
9121036
}
9131037
)
9141038
except Exception as e:
@@ -1921,14 +2045,47 @@ def _emit_streaming_skipped(
19212045
`model` falls back to the request body via
19222046
`_extract_model_from_request_body` (sync-only, mirrors
19232047
`_emit`'s pattern at lines 735-739).
2048+
2049+
Audit 2026-06-29 (ghost-event dedup): the previous version
2050+
emitted the event unconditionally and without a `_fingerprint`.
2051+
Two consequences:
2052+
1. When the body read fails for an external reason
2053+
(double-consume by langchain-openai, an upstream that
2054+
already drained the stream), the SDK produced an
2055+
`llm_call` with `tokens=0, model=None` — i.e. no useful
2056+
signal — that still reached the wire. The backend's
2057+
`into_track_request_v2` handler gate (handler.rs:2046)
2058+
rejected these with HTTP 422, but the cost-pipeline
2059+
belt-and-suspenders backstop still logged every one as
2060+
`cost_pipeline_missing_model_total` and stamped the 1-cent
2061+
surcharge. Operators saw 30+ ERROR lines per `app.invoke()`
2062+
for a workload that actually had 6 real LLM calls.
2063+
2. Because no `_fingerprint` was attached, the dedup LRU at
2064+
`runtime.track()` could not collapse this emission with
2065+
any sibling emission for the same call.
2066+
Fix: drop the event entirely when we cannot recover a usable
2067+
`model` (the request body has been consumed or doesn't carry
2068+
the field — same signature as a body that genuinely cannot be
2069+
inspected), and attach a deterministic `_fingerprint` when we
2070+
do emit so dedup collapses repeats from the same call site.
19242071
"""
2072+
model = _extract_model_from_request_body(request)
2073+
if not model:
2074+
logger.debug(
2075+
"NullRun transport: dropping streaming_skipped event for host=%s "
2076+
"because model extraction also failed (likely double-consume "
2077+
"by langchain-openai upstream or empty request body); "
2078+
"the happy-path _emit() will handle attribution if available",
2079+
host,
2080+
)
2081+
return
19252082
try:
19262083
runtime.track(
19272084
{
19282085
"type": "llm_call",
19292086
"provider": _provider_label(host),
19302087
"host": host,
1931-
"model": _extract_model_from_request_body(request),
2088+
"model": model,
19322089
"tokens": 0,
19332090
"input_tokens": 0,
19342091
"output_tokens": 0,
@@ -1937,6 +2094,23 @@ def _emit_streaming_skipped(
19372094
"tracked": False,
19382095
"streaming_skipped": True,
19392096
},
2097+
# Audit 2026-06-29 (unified fingerprint): use the
2098+
# shared ``_fingerprint_for_llm_call`` helper so this
2099+
# ghost emission also collapses with any sibling
2100+
# emission the LangChain callback produces for the
2101+
# same call. The body was never read, so we don't
2102+
# have an upstream response id — but the model +
2103+
# provider pair still gives a deterministic key that
2104+
# matches the callback's emission for the same call
2105+
# when the callback has the model but not the id.
2106+
# (The pre-fix ``_fingerprint_for(host, b"<...>", 0)``
2107+
# sentinel produced a unique-per-path key that
2108+
# collided with NOTHING.)
2109+
"_fingerprint": _fingerprint_for_llm_call(
2110+
model,
2111+
_provider_label(host),
2112+
None,
2113+
),
19402114
}
19412115
)
19422116
except Exception as e: # pragma: no cover — defensive

0 commit comments

Comments
 (0)