ChatML Message Rendering#
Overview#
Langfuse normalizes LLM inputs/outputs from multiple providers into a unified ChatML message structure before rendering them in the UI. This pipeline lives in two layers:
- Shared package (
packages/shared/src/utils/chatml/): provider-specific adapters + core normalization logic - Web package (
web/src/components/trace2/components/IOPreview/): React hooks and components that consume normalized messages
The key decision point is canDisplayAsChat: if the normalized data validates as a ChatML array, the UI renders chat-bubble-style messages; otherwise it falls back to a PrettyJsonView (JSON/table view).
Data Model#
The canonical message type is defined by ChatMlMessageSchema in packages/shared/src/utils/IORepresentation/chatML/types.ts. Key fields:
| Field | Type | Notes |
|---|---|---|
role | string (optional) | system, user, assistant, tool, etc. |
content | string | array | object | null | Text, content-parts array, or structured object |
tool_calls | ToolCallSchema[] | LLM-initiated tool invocations |
tools | ToolDefinitionSchema[] | Available tool definitions |
thinking | ThinkingContentPartSchema[] | Reasoning/chain-of-thought output |
redacted_thinking | RedactedThinkingContentPartSchema[] | Encrypted Anthropic thinking |
audio | OpenAIOutputAudioSchema | Model-generated audio |
json | Record<string, unknown> | Passthrough for unrecognized fields |
Unknown fields are collected into the json passthrough key by the schema's final transform , making them available for PrettyJsonView rendering without polluting the typed message surface.
Two schema variants exist :
BaseChatMlMessageSchema/ChatMlMessageSchema: full frontend schema with transforms (used for rendering)SimpleChatMessageSchema: lightweight backend schema used only for compact representation extraction
Adapter Pipeline#
selectAdapter() in adapters/index.ts picks the right provider adapter for each observation using a priority-ordered list:
LangGraph → AI SDK → OpenAI → Gemini → Microsoft Agent → Pydantic AI → Semantic Kernel → Generic (fallback)
Each adapter implements a ProviderAdapter interface with two methods:
detect(ctx): returnstrueif the adapter recognizes the data — checked againstctx.framework,ctx.observationName, metadata hints, and schema structurepreprocess(data, kind, ctx): transforms the provider-specific format into a shape thatChatMlArraySchemacan parse
Key Adapters#
OpenAI (adapters/openai.ts):
- Handles Chat Completions (
{messages, tools}), Responses API ({output: [...]}), and single messages - Flattens nested
tool_calls[].function.{name, arguments}→ flat{id, name, arguments} - Converts
function_call/function_call_outputResponses API item types to standard ChatML roles - Extracts
reasoningitems into thethinkingarray - Detection explicitly rejects LangGraph, Semantic Kernel, Pydantic AI, and LangChain-typed messages to avoid false positives
AI SDK (adapters/aisdk.ts):
- Targets Vercel AI SDK v5; detected via
scope.name === "ai"orattributes["operation.name"]starting with"ai." - Normalizes
tool-call/tool-resultcontent part arrays into flattool_callsarrays andtool_call_idfields - Splits multi-result tool messages into separate ChatML messages
Generic / Gemini (adapters/generic.ts):
- Handles Google Gemini/VertexAI format:
{candidates: [{content: {parts, role}}]}outputs and{contents: [...]}inputs - Maps
"model"role →"assistant"and"parts"→"content" - Serves as the universal fallback (
detectalways returnstrue)
Core Normalization Functions#
packages/shared/src/utils/chatml/core.ts exports the functions that wire adapters to Zod schemas:
| Function | Purpose |
|---|---|
normalizeInput(input, ctx) | Selects adapter → preprocesses → validates via mapToChatMl |
normalizeOutput(output, ctx) | Same but uses mapOutputToChatMl for output-specific wrapping |
mapToChatMl(input) | Tries direct array parse, then [[...]] unwrap, then {messages: [...]} unwrap |
mapOutputToChatMl(output) | Handles {messages: [...]} (LangGraph/LangChain) and wraps single outputs in an array |
combineInputOutputMessages | Merges normalized input messages + output messages; defaults output role to "assistant" |
cleanLegacyOutput | Strips legacy {completion: "..."} wrappers |
React Layer: useChatMLParser Hook#
useChatMLParser (web/src/components/trace2/components/IOPreview/hooks/useChatMLParser.ts) is the single entry point for the UI:
- Calls
deepParseJsonon rawPrisma.JsonValueinputs (or accepts pre-parsed data from a Web Worker to avoid ~100ms duplicate work) - Runs
normalizeInput+normalizeOutputwith aNormalizerContextcontaining metadata and observation name - Combines messages via
combineInputOutputMessages - Extracts tool definitions (deduped by name), numbers tool call invocations on output messages only, and sorts tools by call frequency
- Returns
canDisplayAsChat—truewhen either input or output normalized successfully andmessages.length > 0
IOPreviewPretty: Chat vs. JSON Routing#
IOPreviewPretty is the top-level pretty-view component for trace observations:
- Calls
useChatMLParserwith optional pre-parsed data - Guards markdown rendering with a size estimate against
MARKDOWN_RENDER_CHARACTER_LIMITto prevent UI freeze on very large payloads - If
canDisplayAsChat: renders<ChatMessageList>with chat bubbles - Otherwise: renders
<JsonInputOutputView>(twoPrettyJsonViewpanels)
ChatMessage Component#
ChatMessage renders a single normalized message. It branches on message shape using helpers from chat-message-utils.ts:
| Condition | Rendered as |
|---|---|
isPlaceholderMessage (type === "placeholder") | MarkdownJsonView with placeholder label |
isOnlyJsonMessage (no content/tool_calls/audio, has json) | PrettyJsonView of message.json |
Tool-call-only (no content, has tool_calls) | ToolCallInvocationsView |
Has content or thinking | MarkdownJsonView (markdown) + PrettyJsonView (JSON view), with ThinkingBlock panels |
| Has additional data only | PrettyJsonView of whole message |
The markdown/JSON toggle is controlled by shouldRenderMarkdown passed from the parent. Passthrough JSON data (message.json) triggers a secondary toggle button to switch between the formatted message and its raw JSON .