Skip to content

Commit 7684bb5

Browse files
fix(closes OPEN-11695): normalize LangChain provider/model and emit usageDetails so cost estimation stops returning $0
LangChain traces reported the raw class-name provider string (e.g. "chat-google-generative-ai") and the Gemini Developer API "models/" model prefix, neither recognized by the cost table -- so cost estimation silently returned $0 with empty costDetails/usageDetails. - Provider: prefer LangChain's standardized `ls_provider` metadata over the noisier `_type`, resolving both through a dual-keyed map that now covers the major LangChain chat providers (OpenAI, Azure, Anthropic, Google, Bedrock, Cohere, Ollama, Mistral, Groq, DeepSeek, Perplexity, xAI). Unknown identifiers fall through to the raw string (no regression, no wrong cost). - Model: strip a leading "models/" prefix so Gemini names match the cost table. - usageDetails: emit a per-category token map on the chat-completion step so the backend prices per category and populates costDetails. Granular categories from LangChain's usage_metadata are mapped to the cost table's keys (cache_read -> cached_tokens, cache_creation -> cache_creation_tokens, audio -> audio_input_tokens/audio_output_tokens) and subtracted from the input/output base to keep the partition non-overlapping; reasoning stays folded into output_tokens. ChatCompletionStep gains an optional usage_details field, serialized only when set -- other integrations are unaffected. Verified end-to-end against the live pipeline with the customer's inputs: - no caching: provider=Google, model=gemini-3.5-flash, cost=0.2003475, costDetails={input_tokens, output_tokens}. - with 10000 cached input tokens: usageDetails={input_tokens:17131, cached_tokens:10000, output_tokens:17739}, costDetails prices cached tokens at the cheaper cached rate, cost=0.1868475 (< non-cached, reflecting the discount). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018t3JHc1kK6pPMdVBVwMbTs
1 parent de3eb27 commit 7684bb5

3 files changed

Lines changed: 352 additions & 12 deletions

File tree

src/openlayer/lib/integrations/langchain_callback.py

Lines changed: 143 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,54 @@
2525
from .. import utils
2626
from ..tracing import enums, steps, tracer, traces
2727

28+
# Maps LangChain provider identifiers to canonical Openlayer provider names.
29+
# Keyed on BOTH LangChain's standardized ``ls_provider`` metadata value AND the
30+
# older ``_type`` (``_llm_type``) string, since either may be present depending
31+
# on the integration and LangChain version. Canonical names are chosen so their
32+
# lowercase form matches an Openlayer cost-table provider key (the backend
33+
# lowercases the provider before an exact lookup) -- which is what lets cost
34+
# estimation resolve a price. Unknown identifiers are left untouched, so an
35+
# unrecognized provider degrades to the raw string (previous behavior) rather
36+
# than a wrong, plausible-but-incorrect cost.
2837
LANGCHAIN_TO_OPENLAYER_PROVIDER_MAP = {
29-
"azure-openai-chat": "Azure",
30-
"openai-chat": "OpenAI",
31-
"chat-ollama": "Ollama",
32-
"vertexai": "Google",
33-
"amazon_bedrock_converse_chat": "Bedrock",
38+
# OpenAI
39+
"openai": "OpenAI", # ls_provider
40+
"openai-chat": "OpenAI", # _type
41+
# Azure OpenAI
42+
"azure": "Azure", # ls_provider (+ _type on some models)
43+
"azure-openai-chat": "Azure", # _type
44+
# Anthropic
45+
"anthropic": "Anthropic", # ls_provider
46+
"anthropic-chat": "Anthropic", # _type
47+
"anthropic-llm": "Anthropic", # _type
48+
# Google -- Gemini Developer API, Vertex AI, PaLM
49+
"google_genai": "Google", # ls_provider (ChatGoogleGenerativeAI)
50+
"google_vertexai": "Google", # ls_provider (ChatVertexAI)
51+
"vertexai": "Google", # _type
52+
"chat-google-generative-ai": "Google", # _type (ChatGoogleGenerativeAI)
53+
"google_gemini": "Google", # _type
54+
"google-palm-chat": "Google", # _type
55+
"google_palm": "Google", # _type
56+
# AWS Bedrock
57+
"amazon_bedrock": "Bedrock", # ls_provider + _type
58+
"amazon_bedrock_chat": "Bedrock", # _type
59+
"amazon_bedrock_converse_chat": "Bedrock", # _type
60+
# Cohere
61+
"cohere": "Cohere", # ls_provider + _type
62+
"cohere-chat": "Cohere", # _type
63+
# Ollama
64+
"ollama": "Ollama", # ls_provider
65+
"chat-ollama": "Ollama", # _type (older)
66+
"ollama-chat": "Ollama", # _type
67+
"ollama-llm": "Ollama", # _type
68+
# The following ls_provider values are derived from LangChain's base-class
69+
# naming rule (``ChatX`` -> ``x``); a wrong key is harmless (falls through),
70+
# and each canonical target lowercases to a confirmed cost-table key.
71+
"mistralai": "Mistral", # ls_provider (ChatMistralAI)
72+
"groq": "Groq", # ls_provider (ChatGroq)
73+
"deepseek": "DeepSeek", # ls_provider (ChatDeepSeek)
74+
"perplexity": "Perplexity", # ls_provider (ChatPerplexity)
75+
"xai": "xAI", # ls_provider (ChatXAI)
3476
}
3577

3678
# LiteLLM model prefixes to provider names.
@@ -399,9 +441,19 @@ def _extract_model_info(
399441
invocation_params = invocation_params or {}
400442
metadata = metadata or {}
401443

402-
provider = invocation_params.get("_type")
403-
if provider in LANGCHAIN_TO_OPENLAYER_PROVIDER_MAP:
404-
provider = LANGCHAIN_TO_OPENLAYER_PROVIDER_MAP[provider]
444+
# Provider: prefer LangChain's standardized ``ls_provider`` metadata
445+
# (set unconditionally by ``_get_ls_params``) over the noisier ``_type``
446+
# string; normalize either to a canonical Openlayer provider via the map.
447+
# Fall through to the raw identifier when unknown -- no regression, and
448+
# never a fuzzy guess that could produce a wrong cost.
449+
ls_provider = metadata.get("ls_provider")
450+
raw_type = invocation_params.get("_type")
451+
provider = (
452+
LANGCHAIN_TO_OPENLAYER_PROVIDER_MAP.get(ls_provider)
453+
or LANGCHAIN_TO_OPENLAYER_PROVIDER_MAP.get(raw_type)
454+
or ls_provider
455+
or raw_type
456+
)
405457

406458
model = (
407459
invocation_params.get("model_name")
@@ -410,6 +462,14 @@ def _extract_model_info(
410462
or serialized.get("name")
411463
)
412464

465+
# Strip the Google Gemini Developer API "models/" prefix
466+
# (e.g. "models/gemini-3.5-flash" -> "gemini-3.5-flash"). The cost table
467+
# stores bare Gemini names and no provider is named "models", so this is
468+
# safe. Runs before the LiteLLM prefix handling below so the remaining
469+
# name has no stray leading segment.
470+
if model and model.startswith("models/"):
471+
model = model[len("models/") :]
472+
413473
# Handle LiteLLM model prefix (e.g. "gemini/gemini-2.5-flash"):
414474
# extract the actual provider and strip the prefix from the model name.
415475
if model and "/" in model:
@@ -471,14 +531,77 @@ def _extract_token_info(
471531
"prompt_tokens": usage.get("input_tokens", 0),
472532
"completion_tokens": usage.get("output_tokens", 0),
473533
"total_tokens": usage.get("total_tokens", 0),
534+
"input_token_details": usage.get("input_token_details"),
535+
"output_token_details": usage.get("output_token_details"),
474536
}
475537

538+
prompt_tokens = token_usage.get("prompt_tokens", 0)
539+
completion_tokens = token_usage.get("completion_tokens", 0)
476540
return {
477-
"prompt_tokens": token_usage.get("prompt_tokens", 0),
478-
"completion_tokens": token_usage.get("completion_tokens", 0),
541+
"prompt_tokens": prompt_tokens,
542+
"completion_tokens": completion_tokens,
479543
"tokens": token_usage.get("total_tokens", 0),
544+
"usage_details": self._build_usage_details(
545+
prompt_tokens,
546+
completion_tokens,
547+
token_usage.get("input_token_details"),
548+
token_usage.get("output_token_details"),
549+
),
480550
}
481551

552+
@staticmethod
553+
def _build_usage_details(
554+
prompt_tokens: int,
555+
completion_tokens: int,
556+
input_token_details: Optional[Dict[str, int]] = None,
557+
output_token_details: Optional[Dict[str, int]] = None,
558+
) -> Optional[Dict[str, int]]:
559+
"""Build the per-category token map the cost backend prices by exact key.
560+
561+
The backend prices a **non-overlapping** partition: it sums a price for
562+
each ``usageDetails`` key it recognizes. LangChain, however, reports the
563+
granular categories (``cache_read``, ``cache_creation``, ``audio``) as
564+
**subsets** of the ``input_tokens`` / ``output_tokens`` totals. So we
565+
break each granular category out under the key the cost table uses and
566+
subtract it from the input/output base, keeping the partition
567+
non-overlapping and its sum equal to the total token count.
568+
569+
Key mapping (LangChain -> Openlayer cost table):
570+
input ``cache_read`` -> ``cached_tokens``
571+
input ``cache_creation`` -> ``cache_creation_tokens``
572+
input ``audio`` -> ``audio_input_tokens``
573+
output ``audio`` -> ``audio_output_tokens``
574+
575+
``reasoning`` is intentionally left folded into ``output_tokens`` -- it
576+
is billed at the output rate and the cost table has no separate price for
577+
it. Zero-valued categories are omitted; returns ``None`` when there are no
578+
tokens so the ``usageDetails`` column is omitted rather than emitted empty.
579+
"""
580+
input_token_details = input_token_details or {}
581+
output_token_details = output_token_details or {}
582+
583+
cache_read = int(input_token_details.get("cache_read", 0) or 0)
584+
cache_creation = int(input_token_details.get("cache_creation", 0) or 0)
585+
audio_input = int(input_token_details.get("audio", 0) or 0)
586+
audio_output = int(output_token_details.get("audio", 0) or 0)
587+
588+
# Base = total minus the granular categories broken out below.
589+
input_base = prompt_tokens - cache_read - cache_creation - audio_input
590+
output_base = completion_tokens - audio_output
591+
592+
usage_details: Dict[str, int] = {}
593+
for key, value in (
594+
("input_tokens", input_base),
595+
("output_tokens", output_base),
596+
("cached_tokens", cache_read),
597+
("cache_creation_tokens", cache_creation),
598+
("audio_input_tokens", audio_input),
599+
("audio_output_tokens", audio_output),
600+
):
601+
if value and value > 0:
602+
usage_details[key] = value
603+
return usage_details or None
604+
482605
def _extract_output(self, response: "langchain_schema.LLMResult") -> str:
483606
"""Extract output text from LLM response."""
484607
output = ""
@@ -919,10 +1042,18 @@ def _handle_llm_new_token(self, token: str, **kwargs: Any) -> Any:
9191042
run_id = kwargs.get("run_id")
9201043
if run_id and run_id in self.steps:
9211044
# Convert usage to the expected format like _extract_token_info does
1045+
prompt_tokens = usage.get("input_tokens", 0)
1046+
completion_tokens = usage.get("output_tokens", 0)
9221047
token_info = {
923-
"prompt_tokens": usage.get("input_tokens", 0),
924-
"completion_tokens": usage.get("output_tokens", 0),
1048+
"prompt_tokens": prompt_tokens,
1049+
"completion_tokens": completion_tokens,
9251050
"tokens": usage.get("total_tokens", 0),
1051+
"usage_details": self._build_usage_details(
1052+
prompt_tokens,
1053+
completion_tokens,
1054+
usage.get("input_token_details"),
1055+
usage.get("output_token_details"),
1056+
),
9261057
}
9271058

9281059
# Update the step with token usage information

src/openlayer/lib/tracing/steps.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,11 @@ def __init__(
187187
self.model: str = None
188188
self.model_parameters: Dict[str, Any] = None
189189
self.raw_output: str = None
190+
# Optional per-category token map (e.g. {"input_tokens": ...,
191+
# "output_tokens": ...}). When set, the backend prices each category by
192+
# exact key match to populate ``costDetails``; when unset it is omitted
193+
# so integrations that only report scalar tokens are unaffected.
194+
self.usage_details: Optional[Dict[str, int]] = None
190195

191196
def to_dict(self) -> Dict[str, Any]:
192197
"""Dictionary representation of the ChatCompletionStep."""
@@ -203,6 +208,8 @@ def to_dict(self) -> Dict[str, Any]:
203208
"rawOutput": self.raw_output,
204209
}
205210
)
211+
if self.usage_details:
212+
step_dict["usageDetails"] = self.usage_details
206213
return step_dict
207214

208215

0 commit comments

Comments
 (0)