Skip to content

Commit 4619b30

Browse files
fix(obs): stand down when litellm's routing table cannot be read
Greptile P2 on #523, and it is right: the previous commit warned and then kept recording with a five-provider fallback, which marks groq, deepseek, xai and ~50 others as native. litellm still sends those over the OpenAI client, so both that client and inference_call record them. A warning does not prevent the bad metric data — it just annotates it. Both halves of the suggestion are taken. **A stable source.** The list is read from `litellm.constants`, where it is defined (constants.py:789), instead of the `litellm` top level, which is an incidental re-export. litellm declares no `__all__` at all, so the top-level name carries no stability promise even informally, which is what pyright was objecting to in the first place. Verified the two are the same object. **Stand down when it is unknown.** `_over_openai_client` now returns None for "cannot tell", and `_split_model`'s second element is `bool | None`. None is deliberately not collapsed into False, because treating an unknown provider as native is exactly what double-counts it. `inference_call` returns the null recorder on None, so no record is started at all. That choice is deliberate in one direction: standing down also drops the native providers (anthropic, bedrock, vertex_ai) that nothing else records, so the fallback loses real data. It is still the right trade. A doubled token or cost figure is silent and gets believed; a gap is visible, and this one is warned about by name. The path is also effectively unreachable — litellm is a hard dependency and the constant has been there for many releases — so the conservative branch costs nothing in practice. Verified end to end: with `litellm.constants` blocked and a working sgp-obs stub present, every model resolves to None and zero records are started. Tests: 35 in this file, covering both the None contract and the property that actually protects the data — no sgp-obs record is created when routing is unknown. `./scripts/lint` (ruff, pyright 0 errors, import) and `./scripts/check-slim-deps` clean; 1714 repo-wide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9927124 commit 4619b30

2 files changed

Lines changed: 105 additions & 50 deletions

File tree

‎src/agentex/lib/core/adapters/llm/_genai_metrics.py‎

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -53,51 +53,46 @@
5353
_openai_client_providers: Any = _OPENAI_CLIENT_PROVIDERS_UNRESOLVED
5454

5555

56-
def _over_openai_client(provider: str) -> bool:
57-
"""Would the OpenAI client instrumentor already have recorded this call?
56+
def _openai_client_provider_set() -> frozenset[str] | None:
57+
"""Providers litellm dispatches over the ``openai`` client, or None if unknowable.
5858
59-
Answered from litellm's own ``openai_compatible_providers`` rather than a list of
60-
our own, because that list is what litellm actually routes on and it grows every
61-
release (54 entries as of 1.87.0: groq, deepseek, xai, fireworks_ai, ...).
59+
Imported from ``litellm.constants``, which is where the list is defined, rather
60+
than from the ``litellm`` top level, which is an incidental re-export: litellm
61+
declares no ``__all__``, so a type checker treats the top-level name as private
62+
and it carries no stability promise even informally.
6263
"""
6364
global _openai_client_providers
6465
if _openai_client_providers is _OPENAI_CLIENT_PROVIDERS_UNRESOLVED:
65-
compatible: Any = None
6666
try:
67-
import litellm
68-
69-
# Read via getattr: the attribute is not in litellm's __all__, so it is
70-
# not a promised export and a future release may rename or drop it.
71-
compatible = getattr(litellm, "openai_compatible_providers", None)
72-
except Exception: # pragma: no cover - litellm is a hard dependency
73-
compatible = None
67+
from litellm.constants import openai_compatible_providers
7468

75-
if compatible:
7669
_openai_client_providers = (
77-
frozenset(compatible) | _EXTRA_OPENAI_CLIENT_PROVIDERS
70+
frozenset(openai_compatible_providers) | _EXTRA_OPENAI_CLIENT_PROVIDERS
7871
)
79-
else:
80-
# Degrading quietly here would re-introduce the double counting this
81-
# function exists to prevent: every openai-compatible provider would look
82-
# native again and be recorded twice. Say so rather than drift.
72+
except Exception: # pragma: no cover - litellm is a hard dependency
8373
logger.warning(
84-
"litellm no longer exposes openai_compatible_providers, so GenAI "
85-
"metrics can only recognise %d providers as reaching the model over "
86-
"the OpenAI client. Calls to openai-compatible providers such as "
87-
"groq or deepseek may now be counted twice, once here and once by "
88-
"the OpenAI client instrumentor.",
89-
len(_EXTRA_OPENAI_CLIENT_PROVIDERS),
74+
"litellm.constants.openai_compatible_providers is unavailable, so "
75+
"GenAI metrics cannot tell which calls the OpenAI client instrumentor "
76+
"already records. Recording anyway would double-count every "
77+
"openai-compatible provider, so litellm gateway metrics are off for "
78+
"this process."
9079
)
91-
_openai_client_providers = _EXTRA_OPENAI_CLIENT_PROVIDERS
92-
return provider in _openai_client_providers
93-
94-
# sgp-obs is an optional install (see ``sgp_obs_setup``), and Python does NOT cache a
95-
# FAILED import, so importing inside ``inference_call`` re-walked sys.path on every
96-
# single model call for the majority of agents that do not have it. Measured on
97-
# 0.27.0b2 in a venv with five sys.path entries (a container image has more): 62us per
98-
# attempt, which took the gateway's own per-call overhead from 12us to 84us. Resolved
99-
# once, to the module or to None -- the shape ``base_acp_server`` already uses for
100-
# ``sgp_obs.context``, for the same reason.
80+
_openai_client_providers = None
81+
return _openai_client_providers
82+
83+
84+
def _over_openai_client(provider: str) -> bool | None:
85+
"""Would the OpenAI client instrumentor already have recorded this call?
86+
87+
None means "cannot tell", which is NOT the same as False and must not collapse
88+
into it: treating an unknown provider as native is what double-counts it.
89+
"""
90+
known = _openai_client_provider_set()
91+
if known is None:
92+
return None
93+
return provider in known
94+
95+
10196
_GENAI_UNRESOLVED = object()
10297
_genai_module: Any = _GENAI_UNRESOLVED
10398

@@ -126,7 +121,7 @@ def _genai() -> Any | None:
126121
return _genai_module
127122

128123

129-
def _split_model(model: str) -> tuple[str, bool]:
124+
def _split_model(model: str) -> tuple[str, bool | None]:
130125
"""``(provider, goes_out_over_the_openai_client)`` for a litellm model string.
131126
132127
``"litellm_proxy/anthropic/claude-sonnet-4"`` -> ``("anthropic", True)``
@@ -153,6 +148,10 @@ def _split_model(model: str) -> tuple[str, bool]:
153148
routing instruction, so the vendor underneath it is the interesting label — and the
154149
one thing the OpenAI client instrumentor cannot report, since from inside that
155150
client the call is simply "openai".
151+
152+
A second element of None means the routing table itself could not be read, so
153+
whether this call is already recorded elsewhere is unknown. Callers must stand down
154+
rather than guess; see :func:`inference_call`.
156155
"""
157156
proxied = model.startswith(_PROXY_PREFIX)
158157
rest = model[len(_PROXY_PREFIX):] if proxied else model
@@ -246,6 +245,13 @@ def inference_call(kwargs: dict[str, Any], args: tuple[Any, ...] = ()) -> Any:
246245
try:
247246
model = resolve_model(args, kwargs)
248247
vendor, over_openai_client = _split_model(model)
248+
if over_openai_client is None:
249+
# The routing table could not be read, so we cannot tell whether the
250+
# OpenAI client instrumentor is already recording this call. Recording
251+
# would double-count every openai-compatible provider, and a doubled
252+
# token or cost figure is worse than a missing one: the gap is visible
253+
# and warned about, the doubling is silent and gets believed.
254+
return _NULL_CALL
249255
return genai.call(
250256
provider=vendor,
251257
operation=genai.CHAT,

‎src/agentex/lib/core/adapters/llm/tests/test_genai_metrics.py‎

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -119,31 +119,80 @@ def test_the_proxy_vendor_survives_resolution(self):
119119
def test_the_provider_list_agrees_with_litellm(self):
120120
"""Pinned to litellm's own list rather than a copy of it, because the copy
121121
would go stale every release."""
122-
import litellm
122+
import litellm.constants
123123

124124
from agentex.lib.core.adapters.llm._genai_metrics import _over_openai_client
125125

126-
for provider in list(getattr(litellm, "openai_compatible_providers", []))[:20]:
126+
for provider in list(litellm.constants.openai_compatible_providers)[:20]:
127127
assert _over_openai_client(provider), provider
128128
for provider in ("anthropic", "bedrock", "vertex_ai", "gemini"):
129129
assert not _over_openai_client(provider), provider
130130

131-
def test_losing_litellms_list_is_not_silent(self, monkeypatch, caplog):
132-
"""`openai_compatible_providers` is not in litellm's __all__, so it is read
133-
defensively. But degrading quietly would re-introduce the double counting this
134-
whole function exists to prevent, so the fallback has to be audible."""
135-
import litellm
131+
def test_an_unknown_routing_table_stands_down_rather_than_guessing(
132+
self, monkeypatch, caplog
133+
):
134+
"""If the routing table cannot be read we do not know whether the OpenAI client
135+
instrumentor is already recording a call. Recording anyway would double-count
136+
every openai-compatible provider, and a doubled token or cost figure is worse
137+
than a missing one: the gap is visible and warned about, the doubling is silent
138+
and gets believed. So the recorder stands down entirely."""
139+
import builtins
136140

137-
monkeypatch.delattr(litellm, "openai_compatible_providers", raising=False)
141+
real_import = builtins.__import__
142+
143+
def no_constants(name, *args, **kwargs):
144+
if name == "litellm.constants":
145+
raise ImportError("litellm.constants is gone")
146+
return real_import(name, *args, **kwargs)
147+
148+
monkeypatch.setattr(builtins, "__import__", no_constants)
138149
_genai_metrics._reset_for_tests()
139150

140151
with caplog.at_level("WARNING", logger=_genai_metrics.logger.name):
141-
# The five explicit ones still work; the ~50 from litellm no longer do.
142-
assert _genai_metrics._over_openai_client("azure") is True
143-
assert _genai_metrics._over_openai_client("groq") is False
144-
assert any("openai_compatible_providers" in r.message for r in caplog.records), [
145-
r.message for r in caplog.records
146-
]
152+
assert _genai_metrics._over_openai_client("groq") is None
153+
assert _genai_metrics._over_openai_client("anthropic") is None
154+
assert _split_model("groq/llama3-8b-8192") == ("groq", None)
155+
156+
assert any(
157+
"openai_compatible_providers is unavailable" in r.message
158+
for r in caplog.records
159+
), [r.message for r in caplog.records]
160+
161+
def test_a_real_sgp_obs_is_not_started_when_routing_is_unknown(self, monkeypatch):
162+
"""The property that actually protects the data: no record is started at all,
163+
rather than one started with a guessed transport."""
164+
import sys
165+
import builtins
166+
167+
started = []
168+
169+
class _Genai:
170+
CHAT = "chat"
171+
OPENAI_SPEC = "openai"
172+
OPENAI = "openai"
173+
174+
@staticmethod
175+
def call(**kwargs):
176+
started.append(kwargs)
177+
return object()
178+
179+
module = type(sys)("sgp_obs.metrics")
180+
module.genai = _Genai
181+
monkeypatch.setitem(sys.modules, "sgp_obs", type(sys)("sgp_obs"))
182+
monkeypatch.setitem(sys.modules, "sgp_obs.metrics", module)
183+
184+
real_import = builtins.__import__
185+
186+
def no_constants(name, *args, **kwargs):
187+
if name == "litellm.constants":
188+
raise ImportError("litellm.constants is gone")
189+
return real_import(name, *args, **kwargs)
190+
191+
monkeypatch.setattr(builtins, "__import__", no_constants)
192+
_genai_metrics._reset_for_tests()
193+
194+
assert inference_call({"model": "groq/llama3-8b-8192"}) is _genai_metrics._NULL_CALL
195+
assert started == [], "a record was started with an unknown routing table"
147196

148197
def test_an_unresolvable_model_does_not_spam_stdout(self):
149198
"""litellm prints a red "Provider List" banner to STDOUT (not logging, so it

0 commit comments

Comments
 (0)