Skip to content

Commit dc418a2

Browse files
fix(langchain): stop emitting space-separated provider names that price at $0 (#666)
* fix(langchain): stop emitting space-separated provider names that price at $0 Cost is resolved by lowercasing the step's `provider` and matching an llm-costs slug exactly. Matching normalizes case but NOT separators, so a space makes the value unmatchable and the row silently prices at zero. Verified against the live data-stream API with identical model and token counts: provider="Together AI" -> cost 0.0 provider="Together_AI" -> cost 0.0065 provider="together_ai" -> cost 0.0065 Two causes, both fixed: 1. The unmapped-`ls_provider` fallback ran `.replace("_", " ").title()`, converting values LangChain already supplies as valid slugs into unpriceable display strings ("vercel_ai_gateway" -> "Vercel Ai Gateway"). Dropping the `.replace()` fixes every unmapped multi-token provider; `.title()` is retained since matching is case-insensitive. 2. Six map values used literal spaces. "Together AI" -> "Together_AI" and "Fireworks AI" -> "Fireworks_AI" in both LS_PROVIDER_TO_OPENLAYER_MAP and LITELLM_PREFIX_TO_PROVIDER_MAP. "Hugging Face" -> "HuggingFace" for consistency only: llm-costs publishes no huggingface provider slug at all, so that provider remains unpriced either way. Behavior change: `step.provider` for an unmapped `ls_provider` now keeps underscores ("Some_New_Provider" rather than "Some New Provider"). Consumers displaying this field verbatim will see the separator. Adds TestProviderCostSlugs, which asserts no map value contains a space and that known prefixes resolve to real slugs, with already-correct providers (gemini, vertex_ai, anthropic, bedrock, deepseek) kept as regression canaries. * test(langchain): assert provider values resolve a real cost slug The space check catches the specific bug but not the general class: a value can be space-free and still not price anything. Port the invariant openlayer-ts asserts through PROVIDER_COST_SLUG -- every provider value must lowercase to a slug published by llm-costs, or be an explicitly listed display-only vendor. The slug set is vendored so the suite stays offline. huggingface is the sole unpriced entry: llm-costs publishes no hugging* provider at all. Verified red/green: against pristine main this fails with LS_PROVIDER_TO_OPENLAYER_MAP['fireworks'] = 'Fireworks AI' lowercases to 'fireworks ai', which is neither a published cost slug nor a known-unpriced vendor: every row would cost $0
1 parent e9e6ad3 commit dc418a2

2 files changed

Lines changed: 151 additions & 9 deletions

File tree

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

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
from .. import utils
2525
from ..tracing import enums, steps, tracer, traces
2626

27+
# Provider values below must stay matchable against the llm-costs table: cost is
28+
# resolved by lowercasing the provider and matching a slug exactly. Matching
29+
# normalizes case but NOT separators, so "Together AI" prices every row at $0
30+
# while "Together_AI" prices correctly. Never use spaces in these values.
2731
LANGCHAIN_TO_OPENLAYER_PROVIDER_MAP = {
2832
"azure-openai-chat": "Azure",
2933
"openai-chat": "OpenAI",
@@ -56,12 +60,12 @@
5660
"bedrock": "Bedrock",
5761
"amazon_bedrock": "Bedrock",
5862
"ollama": "Ollama",
59-
"huggingface": "Hugging Face",
63+
"huggingface": "HuggingFace",
6064
"replicate": "Replicate",
61-
"together": "Together AI",
65+
"together": "Together_AI",
6266
"groq": "Groq",
6367
"deepseek": "DeepSeek",
64-
"fireworks": "Fireworks AI",
68+
"fireworks": "Fireworks_AI",
6569
"perplexity": "Perplexity",
6670
}
6771

@@ -77,12 +81,12 @@
7781
"bedrock": "Bedrock",
7882
"vertex_ai": "Google",
7983
"azure": "Azure",
80-
"huggingface": "Hugging Face",
84+
"huggingface": "HuggingFace",
8185
"replicate": "Replicate",
82-
"together_ai": "Together AI",
86+
"together_ai": "Together_AI",
8387
"groq": "Groq",
8488
"deepseek": "DeepSeek",
85-
"fireworks_ai": "Fireworks AI",
89+
"fireworks_ai": "Fireworks_AI",
8690
"perplexity": "Perplexity",
8791
"ollama": "Ollama",
8892
"openai": "OpenAI",
@@ -484,7 +488,10 @@ def _extract_model_info(
484488
# 3. LiteLLM model prefix (handled below)
485489
ls_provider = metadata.get("ls_provider")
486490
if ls_provider:
487-
provider = LS_PROVIDER_TO_OPENLAYER_MAP.get(ls_provider, str(ls_provider).replace("_", " ").title())
491+
# Keep separators: cost is matched by lowercasing this value against a
492+
# llm-costs slug, and matching normalizes case but NOT separators. Turning
493+
# "vercel_ai_gateway" into "Vercel Ai Gateway" makes the row price at $0.
494+
provider = LS_PROVIDER_TO_OPENLAYER_MAP.get(ls_provider, str(ls_provider).title())
488495
else:
489496
provider = invocation_params.get("_type")
490497
if provider in LANGCHAIN_TO_OPENLAYER_PROVIDER_MAP:

‎tests/lib/integrations/test_langchain_callback.py‎

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@
3535
from langchain_core.documents import Document
3636

3737
from openlayer.lib.tracing import enums, steps
38-
from openlayer.lib.integrations.langchain_callback import OpenlayerHandler
38+
from openlayer.lib.integrations.langchain_callback import (
39+
LS_PROVIDER_TO_OPENLAYER_MAP,
40+
LITELLM_PREFIX_TO_PROVIDER_MAP,
41+
LANGCHAIN_TO_OPENLAYER_PROVIDER_MAP,
42+
OpenlayerHandler,
43+
)
3944

4045

4146
@pytest.fixture(autouse=True)
@@ -400,13 +405,19 @@ def test_ls_provider_is_primary_source(self) -> None:
400405
assert info["model"] == "claude-x"
401406

402407
def test_ls_provider_title_cases_unknown_values(self) -> None:
408+
"""Unknown ls_provider values are title-cased but keep their separators.
409+
410+
Underscores must survive: the cost table is keyed by slugs like
411+
``vercel_ai_gateway``, and replacing "_" with " " makes the provider
412+
un-priceable. See TestProviderCostSlugs.
413+
"""
403414
handler = OpenlayerHandler()
404415
info = handler._extract_model_info(
405416
serialized={},
406417
invocation_params={"model": "some-model"},
407418
metadata={"ls_provider": "some_new_provider"},
408419
)
409-
assert info["provider"] == "Some New Provider"
420+
assert info["provider"] == "Some_New_Provider"
410421

411422
def test_falls_back_to_type_map_without_ls_provider(self) -> None:
412423
handler = OpenlayerHandler()
@@ -701,3 +712,127 @@ def test_usage_details_set_on_step_via_callbacks(self) -> None:
701712

702713
def test_step_omits_usage_details_when_unset(self) -> None:
703714
assert "usageDetails" not in steps.ChatCompletionStep(name="x").to_dict()
715+
716+
717+
# --------------------------------------------------------------------------- #
718+
# Provider values must be matchable against the llm-costs table
719+
# --------------------------------------------------------------------------- #
720+
class TestProviderCostSlugs:
721+
"""Cost is resolved by lowercasing ``provider`` and matching a llm-costs slug.
722+
723+
Matching normalizes case but NOT separators (verified against the live API:
724+
provider "Together AI" priced at $0.0, while "Together_AI" and "together_ai"
725+
both priced at $0.0065 for the same model and token counts). So any provider
726+
value containing a space silently prices every row at zero.
727+
"""
728+
729+
ALL_MAPS = (
730+
("LANGCHAIN_TO_OPENLAYER_PROVIDER_MAP", LANGCHAIN_TO_OPENLAYER_PROVIDER_MAP),
731+
("LS_PROVIDER_TO_OPENLAYER_MAP", LS_PROVIDER_TO_OPENLAYER_MAP),
732+
("LITELLM_PREFIX_TO_PROVIDER_MAP", LITELLM_PREFIX_TO_PROVIDER_MAP),
733+
)
734+
735+
# Provider slugs published by https://llm-costs.openlayer.com/v1/costs that these
736+
# maps target. Vendored rather than fetched so the suite stays offline; refresh with
737+
# curl -s https://llm-costs.openlayer.com/v1/costs | jq -r '.costs[].provider' | sort -u
738+
# Last verified 2026-08-20 against 139 published providers.
739+
COST_SLUGS = frozenset(
740+
{
741+
"anthropic",
742+
"azure",
743+
"bedrock",
744+
"cohere",
745+
"deepseek",
746+
"fireworks_ai",
747+
"google",
748+
"groq",
749+
"mistral",
750+
"ollama",
751+
"openai",
752+
"perplexity",
753+
"replicate",
754+
"together_ai",
755+
}
756+
)
757+
758+
# Vendors with no provider slug upstream at all: no value can resolve a cost, so the
759+
# name is display-only. Mirrors the ``null`` entries in openlayer-ts's
760+
# PROVIDER_COST_SLUG.
761+
UNPRICED_VENDORS = frozenset({"huggingface"})
762+
763+
def test_every_provider_value_resolves_a_cost_slug(self) -> None:
764+
"""Stronger than the space check: the value must actually price something.
765+
766+
Ports the invariant openlayer-ts asserts via PROVIDER_COST_SLUG -- a provider
767+
is only worth mapping if its canonical name resolves a price, otherwise the
768+
step gets a nicer label and still costs $0.
769+
"""
770+
for name, mapping in self.ALL_MAPS:
771+
for key, value in sorted(mapping.items()):
772+
slug = value.lower()
773+
assert slug in self.COST_SLUGS or slug in self.UNPRICED_VENDORS, (
774+
f"{name}[{key!r}] = {value!r} lowercases to {slug!r}, which is neither a "
775+
f"published cost slug nor a known-unpriced vendor: every row would cost $0"
776+
)
777+
778+
def test_no_provider_value_contains_a_space(self) -> None:
779+
for name, mapping in self.ALL_MAPS:
780+
offenders = sorted({v for v in mapping.values() if " " in v})
781+
assert not offenders, f"{name} maps to space-separated values (silent $0): {offenders}"
782+
783+
@pytest.mark.parametrize(
784+
("prefix", "expected_slug"),
785+
[
786+
# regressions this class was added for
787+
("together_ai", "together_ai"),
788+
("fireworks_ai", "fireworks_ai"),
789+
("huggingface", "huggingface"),
790+
# canaries: these already resolved correctly and must keep doing so
791+
("gemini", "google"),
792+
("vertex_ai", "google"),
793+
("anthropic", "anthropic"),
794+
("bedrock", "bedrock"),
795+
("deepseek", "deepseek"),
796+
],
797+
)
798+
def test_litellm_prefix_resolves_to_cost_slug(self, prefix: str, expected_slug: str) -> None:
799+
handler = OpenlayerHandler()
800+
info = handler._extract_model_info(
801+
serialized={},
802+
invocation_params={"model": f"{prefix}/some-model"},
803+
metadata={},
804+
)
805+
assert info["provider"] is not None
806+
assert info["provider"].lower() == expected_slug
807+
assert info["model"] == "some-model"
808+
809+
@pytest.mark.parametrize(
810+
("ls_provider", "expected_slug"),
811+
[
812+
("together", "together_ai"),
813+
("fireworks", "fireworks_ai"),
814+
("huggingface", "huggingface"),
815+
("anthropic", "anthropic"),
816+
("google_genai", "google"),
817+
],
818+
)
819+
def test_ls_provider_resolves_to_cost_slug(self, ls_provider: str, expected_slug: str) -> None:
820+
handler = OpenlayerHandler()
821+
info = handler._extract_model_info(
822+
serialized={},
823+
invocation_params={"model": "some-model"},
824+
metadata={"ls_provider": ls_provider},
825+
)
826+
assert info["provider"] is not None
827+
assert info["provider"].lower() == expected_slug
828+
829+
def test_unmapped_ls_provider_preserves_slug_separators(self) -> None:
830+
"""An unmapped ls_provider that IS already a valid slug must stay matchable."""
831+
handler = OpenlayerHandler()
832+
info = handler._extract_model_info(
833+
serialized={},
834+
invocation_params={"model": "some-model"},
835+
metadata={"ls_provider": "vercel_ai_gateway"},
836+
)
837+
assert info["provider"] is not None
838+
assert info["provider"].lower() == "vercel_ai_gateway"

0 commit comments

Comments
 (0)