@@ -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+
184263def _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