77from uuid import UUID
88
99try :
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
2120except ImportError :
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
10091174class 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