Skip to content

Commit d3f35d6

Browse files
feat(langchain): modernize callback for v1 (tokens, provider, langgraph, content blocks)
- _extract_token_info reads AIMessage.usage_metadata first (the v1-standardized source) before llm_output, and captures input/output token details (cache_read, cache_creation, reasoning) under step metadata token_details. - Use metadata["ls_provider"] as the primary provider source (mapped to Openlayer provider names), falling back to the _type map and LiteLLM prefixes. - Name chain steps from metadata["langgraph_node"] and auto-map metadata["thread_id"] to session_id (opt-out via map_thread_id_to_session), never clobbering an explicitly set session. - _message_to_dict normalizes v1 content blocks: text joined into content, non-text blocks preserved under content_blocks. - Drop the dead langchain.schema / langchain.callbacks.base import fallback (removed in LangChain v1); import from langchain_core only. Implements the medium/low items of OPEN-11315. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e54ea69 commit d3f35d6

2 files changed

Lines changed: 555 additions & 33 deletions

File tree

‎src/openlayer/lib/integrations/langchain_callback.py‎

Lines changed: 202 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@
77
from uuid import UUID
88

99
try:
10-
try:
11-
from langchain_core import messages as langchain_schema
12-
from langchain_core.callbacks.base import (
13-
AsyncCallbackHandler,
14-
BaseCallbackHandler,
15-
)
16-
except ImportError:
17-
from langchain import schema as langchain_schema
18-
from langchain.callbacks.base import AsyncCallbackHandler, BaseCallbackHandler
10+
# langchain-core is sufficient for this handler. The legacy
11+
# ``langchain.schema`` / ``langchain.callbacks.base`` import paths were
12+
# removed in LangChain v1, so we import from ``langchain_core`` directly.
13+
from langchain_core import messages as langchain_schema
14+
from langchain_core.callbacks.base import (
15+
AsyncCallbackHandler,
16+
BaseCallbackHandler,
17+
)
1918

2019
HAVE_LANGCHAIN = True
2120
except ImportError:
@@ -33,6 +32,34 @@
3332
"amazon_bedrock_converse_chat": "Bedrock",
3433
}
3534

35+
# LangChain v1 injects a standardized ``metadata["ls_provider"]`` (e.g.
36+
# "openai", "anthropic", "google_genai", "groq"...). This maps those values to
37+
# Openlayer's canonical provider names. Values without an explicit entry fall
38+
# back to a title-cased version of the ls_provider string.
39+
LS_PROVIDER_TO_OPENLAYER_MAP = {
40+
"openai": "OpenAI",
41+
"azure": "Azure",
42+
"azure_openai": "Azure",
43+
"anthropic": "Anthropic",
44+
"google": "Google",
45+
"google_genai": "Google",
46+
"google_vertexai": "Google",
47+
"vertexai": "Google",
48+
"cohere": "Cohere",
49+
"mistralai": "Mistral",
50+
"mistral": "Mistral",
51+
"bedrock": "Bedrock",
52+
"amazon_bedrock": "Bedrock",
53+
"ollama": "Ollama",
54+
"huggingface": "Hugging Face",
55+
"replicate": "Replicate",
56+
"together": "Together AI",
57+
"groq": "Groq",
58+
"deepseek": "DeepSeek",
59+
"fireworks": "Fireworks AI",
60+
"perplexity": "Perplexity",
61+
}
62+
3663
# LiteLLM model prefixes to provider names.
3764
# When models are accessed via a LiteLLM proxy (e.g. "gemini/gemini-2.5-flash"),
3865
# the LangChain _type is "openai-chat" which incorrectly maps to "OpenAI".
@@ -73,7 +100,7 @@ def __init__(self, **kwargs: Any) -> None:
73100
if not HAVE_LANGCHAIN:
74101
raise ImportError(
75102
"LangChain library is not installed. Please install it with: pip "
76-
"install langchain"
103+
"install langchain-core"
77104
)
78105
super().__init__()
79106
self.metadata: Dict[str, Any] = kwargs or {}
@@ -85,6 +112,9 @@ def __init__(self, **kwargs: Any) -> None:
85112
self._inference_id = kwargs.get("inference_id")
86113
# Extract metadata_transformer from kwargs if provided
87114
self._metadata_transformer = kwargs.get("metadata_transformer")
115+
# Opt-out flag: auto-map LangGraph metadata["thread_id"] to the trace's
116+
# session_id when the user has not explicitly provided one.
117+
self._map_thread_id_to_session = kwargs.get("map_thread_id_to_session", True)
88118

89119
def _start_step(
90120
self,
@@ -374,7 +404,14 @@ def _message_to_dict(
374404
elif message_type == "system":
375405
role = "system"
376406

377-
result: Dict[str, Any] = {"role": role, "content": str(message.content)}
407+
content_str, content_blocks = self._normalize_content(message.content)
408+
result: Dict[str, Any] = {"role": role, "content": content_str}
409+
410+
# With ``LC_OUTPUT_VERSION=v1`` the content can be a list of typed
411+
# blocks. Preserve any non-text blocks (reasoning, tool_call, image...)
412+
# structurally rather than stringifying the whole list.
413+
if content_blocks:
414+
result["content_blocks"] = content_blocks
378415

379416
# Preserve tool calls on assistant messages (e.g. AIMessage.tool_calls).
380417
# Without this, agent turns that only emit tool calls would drop them
@@ -386,6 +423,41 @@ def _message_to_dict(
386423

387424
return result
388425

426+
@staticmethod
427+
def _normalize_content(content: Any) -> tuple:
428+
"""Normalize message content into a (text, non_text_blocks) pair.
429+
430+
Handles the three shapes ``message.content`` can take:
431+
* a plain string (returned unchanged, no blocks);
432+
* a list of strings (joined into the text);
433+
* a list of typed blocks (dicts with a ``type`` key) as produced
434+
under ``LC_OUTPUT_VERSION=v1`` -- text is extracted from
435+
``text``-type blocks while non-text blocks are preserved
436+
structurally.
437+
438+
Defensive: never raises on unexpected shapes.
439+
"""
440+
if isinstance(content, str):
441+
return content, []
442+
443+
if isinstance(content, list):
444+
text_parts: List[str] = []
445+
non_text_blocks: List[Any] = []
446+
for block in content:
447+
if isinstance(block, str):
448+
text_parts.append(block)
449+
elif isinstance(block, dict):
450+
if block.get("type") == "text" and "text" in block:
451+
text_parts.append(str(block.get("text", "")))
452+
else:
453+
non_text_blocks.append(block)
454+
else:
455+
non_text_blocks.append(block)
456+
return "".join(text_parts), non_text_blocks
457+
458+
# Fallback for any other shape.
459+
return str(content), []
460+
389461
def _messages_to_prompt_format(
390462
self, messages: List[List["langchain_schema.BaseMessage"]]
391463
) -> List[Dict[str, str]]:
@@ -409,9 +481,19 @@ def _extract_model_info(
409481
invocation_params = invocation_params or {}
410482
metadata = metadata or {}
411483

412-
provider = invocation_params.get("_type")
413-
if provider in LANGCHAIN_TO_OPENLAYER_PROVIDER_MAP:
414-
provider = LANGCHAIN_TO_OPENLAYER_PROVIDER_MAP[provider]
484+
# Provider resolution order:
485+
# 1. metadata["ls_provider"] (standardized by LangChain v1)
486+
# 2. invocation_params["_type"] via the legacy _type map
487+
# 3. LiteLLM model prefix (handled below)
488+
ls_provider = metadata.get("ls_provider")
489+
if ls_provider:
490+
provider = LS_PROVIDER_TO_OPENLAYER_MAP.get(
491+
ls_provider, str(ls_provider).replace("_", " ").title()
492+
)
493+
else:
494+
provider = invocation_params.get("_type")
495+
if provider in LANGCHAIN_TO_OPENLAYER_PROVIDER_MAP:
496+
provider = LANGCHAIN_TO_OPENLAYER_PROVIDER_MAP[provider]
415497

416498
model = (
417499
invocation_params.get("model_name")
@@ -443,15 +525,49 @@ def _extract_model_info(
443525
def _extract_token_info(
444526
self, response: "langchain_schema.LLMResult"
445527
) -> Dict[str, Any]:
446-
"""Extract token information generically from LLM response."""
447-
llm_output = response.llm_output or {}
528+
"""Extract token information generically from LLM response.
529+
530+
In LangChain v1, ``AIMessage.usage_metadata`` is the guaranteed,
531+
standardized token source, so it is read FIRST. The provider-specific
532+
``llm_output`` / ``generation_info`` paths (Ollama ``prompt_eval_count``,
533+
Google ``usage_metadata`` in generation_info) remain as fallbacks so
534+
nothing regresses.
535+
536+
When ``usage_metadata`` carries ``input_token_details`` /
537+
``output_token_details`` (e.g. ``cache_creation``, ``cache_read``,
538+
``reasoning``, ``audio``), those are surfaced under a ``token_details``
539+
key so cost is accurate for prompt-caching / reasoning models.
540+
"""
541+
token_usage: Dict[str, Any] = {}
542+
token_details: Dict[str, Any] = {}
448543

449-
# Try standard token_usage location first
450-
token_usage = (
451-
llm_output.get("token_usage") or llm_output.get("estimatedTokens") or {}
452-
)
544+
# 1. usage_metadata on the message (standardized, v1-first).
545+
if response.generations:
546+
gen = response.generations[0][0]
547+
usage = getattr(getattr(gen, "message", None), "usage_metadata", None)
548+
if usage:
549+
token_usage = {
550+
"prompt_tokens": usage.get("input_tokens", 0),
551+
"completion_tokens": usage.get("output_tokens", 0),
552+
"total_tokens": usage.get("total_tokens", 0),
553+
}
554+
input_details = usage.get("input_token_details")
555+
if input_details:
556+
token_details["input_token_details"] = dict(input_details)
557+
output_details = usage.get("output_token_details")
558+
if output_details:
559+
token_details["output_token_details"] = dict(output_details)
560+
561+
# 2. Fall back to llm_output for providers that surface it there.
562+
if not token_usage:
563+
llm_output = response.llm_output or {}
564+
token_usage = (
565+
llm_output.get("token_usage")
566+
or llm_output.get("estimatedTokens")
567+
or {}
568+
)
453569

454-
# Fallback to generation info for providers like Ollama/Google
570+
# 3. Fall back to generation_info for providers like Ollama/Google.
455571
if not token_usage and response.generations:
456572
gen = response.generations[0][0]
457573
generation_info = gen.generation_info or {}
@@ -473,21 +589,15 @@ def _extract_token_info(
473589
"completion_tokens": usage.get("candidates_token_count", 0),
474590
"total_tokens": usage.get("total_token_count", 0),
475591
}
476-
# AWS Bedrock / newer LangChain style - usage_metadata on the message
477-
elif hasattr(gen, "message") and hasattr(gen.message, "usage_metadata"):
478-
usage = gen.message.usage_metadata
479-
if usage:
480-
token_usage = {
481-
"prompt_tokens": usage.get("input_tokens", 0),
482-
"completion_tokens": usage.get("output_tokens", 0),
483-
"total_tokens": usage.get("total_tokens", 0),
484-
}
485592

486-
return {
593+
result = {
487594
"prompt_tokens": token_usage.get("prompt_tokens", 0),
488595
"completion_tokens": token_usage.get("completion_tokens", 0),
489596
"tokens": token_usage.get("total_tokens", 0),
490597
}
598+
if token_details:
599+
result["token_details"] = token_details
600+
return result
491601

492602
def _extract_output(self, response: "langchain_schema.LLMResult") -> str:
493603
"""Extract output text from LLM response.
@@ -570,6 +680,7 @@ def _handle_llm_start(
570680
metadata={"tags": tags} if tags else None,
571681
**model_info,
572682
)
683+
self._apply_thread_id_session(run_id, metadata)
573684

574685
def _handle_chat_model_start(
575686
self,
@@ -603,6 +714,7 @@ def _handle_chat_model_start(
603714
metadata={"tags": tags} if tags else None,
604715
**model_info,
605716
)
717+
self._apply_thread_id_session(run_id, metadata)
606718

607719
def _handle_llm_end(
608720
self,
@@ -629,6 +741,13 @@ def _handle_llm_end(
629741
):
630742
token_info = self._extract_token_info(response)
631743

744+
# ChatCompletionStep has no dedicated field for fine-grained token
745+
# details (cache_read/creation, reasoning, audio...), so surface them
746+
# under step metadata where cost-relevant breakdowns can be read.
747+
token_details = token_info.pop("token_details", None)
748+
if token_details:
749+
step.metadata = {**step.metadata, "token_details": token_details}
750+
632751
self._end_step(
633752
run_id=run_id,
634753
parent_run_id=parent_run_id,
@@ -663,8 +782,15 @@ def _handle_chain_start(
663782
# Extract chain name from serialized data or use provided name
664783
# Handle case where serialized can be None
665784
serialized = serialized or {}
785+
metadata = metadata or {}
786+
787+
# LangGraph injects ``metadata["langgraph_node"]`` for each graph node.
788+
# Prefer it so graph nodes are identifiable in the trace rather than
789+
# showing the generic chain/runnable name.
790+
langgraph_node = metadata.get("langgraph_node")
666791
chain_name = (
667-
name
792+
langgraph_node
793+
or name
668794
or (serialized.get("id", [])[-1] if serialized.get("id") else None)
669795
or "Chain"
670796
)
@@ -687,10 +813,11 @@ def _handle_chain_start(
687813
metadata={
688814
"tags": tags,
689815
"serialized": serialized,
690-
**(metadata or {}),
816+
**metadata,
691817
**kwargs,
692818
},
693819
)
820+
self._apply_thread_id_session(run_id, metadata)
694821

695822
def _handle_chain_end(
696823
self,
@@ -1005,6 +1132,44 @@ def _handle_llm_new_token(self, token: str, **kwargs: Any) -> Any:
10051132
step.log(**token_info)
10061133
return
10071134

1135+
def _apply_thread_id_session(
1136+
self, run_id: UUID, metadata: Optional[Dict[str, Any]]
1137+
) -> None:
1138+
"""Map LangGraph ``metadata["thread_id"]`` to the trace's session_id.
1139+
1140+
Any LangGraph app with a checkpointer injects ``thread_id`` into
1141+
callback metadata. When ``map_thread_id_to_session`` is enabled (the
1142+
default) and the user has not explicitly set a session via
1143+
``UserSessionContext``, the thread_id is written to the trace's
1144+
``session_id`` metadata so it is promoted to the ``session_id`` column
1145+
by ``post_process_trace``.
1146+
1147+
An explicitly-set session_id is never clobbered: ``post_process_trace``
1148+
re-applies the ``UserSessionContext`` value after merging trace
1149+
metadata, and this method additionally skips writing when a context
1150+
session_id is present.
1151+
"""
1152+
if not self._map_thread_id_to_session or not metadata:
1153+
return
1154+
1155+
thread_id = metadata.get("thread_id")
1156+
if not thread_id:
1157+
return
1158+
1159+
# Do not clobber an explicitly-provided session_id.
1160+
if tracer.UserSessionContext.get_session_id() is not None:
1161+
return
1162+
1163+
target_trace = tracer.get_current_trace() or self._find_owning_trace(run_id)
1164+
if target_trace is None:
1165+
return
1166+
1167+
existing = (target_trace.metadata or {}).get("session_id")
1168+
if existing:
1169+
return
1170+
1171+
target_trace.update_metadata(session_id=str(thread_id))
1172+
10081173

10091174
class OpenlayerHandler(OpenlayerHandlerMixin, BaseCallbackHandlerClass): # type: ignore[misc]
10101175
"""LangChain callback handler that logs to Openlayer."""
@@ -1020,13 +1185,15 @@ def __init__(
10201185
metadata_transformer: Optional[
10211186
Callable[[Dict[str, Any]], Dict[str, Any]]
10221187
] = None,
1188+
map_thread_id_to_session: bool = True,
10231189
**kwargs: Any,
10241190
) -> None:
10251191
# Add both inference_id and metadata_transformer to kwargs so they get passed to mixin
10261192
if inference_id is not None:
10271193
kwargs["inference_id"] = inference_id
10281194
if metadata_transformer is not None:
10291195
kwargs["metadata_transformer"] = metadata_transformer
1196+
kwargs["map_thread_id_to_session"] = map_thread_id_to_session
10301197
super().__init__(**kwargs)
10311198
# Store the ignore flags as instance variables
10321199
self._ignore_llm = ignore_llm
@@ -1191,13 +1358,15 @@ def __init__(
11911358
metadata_transformer: Optional[
11921359
Callable[[Dict[str, Any]], Dict[str, Any]]
11931360
] = None,
1361+
map_thread_id_to_session: bool = True,
11941362
**kwargs: Any,
11951363
) -> None:
11961364
# Add both inference_id and metadata_transformer to kwargs so they get passed to mixin
11971365
if inference_id is not None:
11981366
kwargs["inference_id"] = inference_id
11991367
if metadata_transformer is not None:
12001368
kwargs["metadata_transformer"] = metadata_transformer
1369+
kwargs["map_thread_id_to_session"] = map_thread_id_to_session
12011370
super().__init__(**kwargs)
12021371
# Store the ignore flags as instance variables
12031372
self._ignore_llm = ignore_llm

0 commit comments

Comments
 (0)