From 3156ee37a194acebdf6649c3374544a0c1b22672 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:08:53 +0100 Subject: [PATCH] Name the provider a model call actually reached DeepSeek and every other OpenAI-compatible API run through OpenAILLM, so spans and metrics reported them as openai. The provider now comes from the endpoint: known API hosts map to their gen_ai.provider.name (deepseek, azure.ai.openai, mistral_ai, ...), and an OpenAI client pointed anywhere else is openai_compatible. LLM spans also record server.address. --- docsgpt/llm/llm_creator.py | 4 ++ docsgpt/tracing/llm.py | 66 ++++++++++++++++++++++++++++++++- tests/tracing/test_llm_spans.py | 51 +++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/docsgpt/llm/llm_creator.py b/docsgpt/llm/llm_creator.py index 995600cb..d64a37a2 100644 --- a/docsgpt/llm/llm_creator.py +++ b/docsgpt/llm/llm_creator.py @@ -128,6 +128,10 @@ class LLMCreator: # llm.model_id is the upstream name (BYOM resolves it above); stamp # the canonical id (UUID for BYOM) separately for token_usage. llm._canonical_model_id = model_id + # The provider plugin that built it: ``openai_compatible`` endpoints + # run through the OpenAI client, so the class alone cannot name them + # in traces (see ``docsgpt.tracing.llm.llm_provider``). + llm._provider_plugin = plugin.name # Calls to a user's own model are recorded at $0 (see ``docsgpt/usage.py``). llm._is_byom = model is not None and getattr(model, "source", "builtin") == "user" return llm diff --git a/docsgpt/tracing/llm.py b/docsgpt/tracing/llm.py index 556fc373..ef169186 100644 --- a/docsgpt/tracing/llm.py +++ b/docsgpt/tracing/llm.py @@ -8,6 +8,7 @@ fallback each get their own span with the provider that actually ran. from __future__ import annotations from typing import Any, Dict, Iterable, Optional, Union +from urllib.parse import urlparse from docsgpt.core.settings import settings from docsgpt.tracing import core @@ -16,6 +17,66 @@ from docsgpt.tracing.otel import provider_name, record_llm_metrics #: LLM attribute a cache wrapper sets when it served the call from Redis. CACHE_HIT_ATTR = "_trace_cache_hit" +#: API hosts whose provider differs from the client class that calls them +#: (every OpenAI-compatible API runs through ``OpenAILLM``), mapped to their +#: ``gen_ai.provider.name``. Matched on the host or any subdomain of it. +_PROVIDER_HOSTS = ( + ("api.openai.com", "openai"), + ("openai.azure.com", "azure.ai.openai"), + ("deepseek.com", "deepseek"), + ("mistral.ai", "mistral_ai"), + ("x.ai", "x_ai"), + ("perplexity.ai", "perplexity"), + ("groq.com", "groq"), + ("openrouter.ai", "openrouter"), + ("novita.ai", "novita"), + ("cohere.com", "cohere"), + ("cohere.ai", "cohere"), + ("together.xyz", "together_ai"), + ("together.ai", "together_ai"), + ("fireworks.ai", "fireworks"), + ("anthropic.com", "anthropic"), + ("generativelanguage.googleapis.com", "gcp.gemini"), +) + + +def _endpoint_host(llm: Any) -> Optional[str]: + url = getattr(llm, "_effective_base_url", None) or getattr(llm, "base_url", None) + if not isinstance(url, str) or not url: + return None + try: + return urlparse(url).hostname + except ValueError: + return None + + +def llm_provider(llm: Any) -> str: + """The ``gen_ai.provider.name`` of the API ``llm`` actually calls. + + The client class alone is not enough: DeepSeek, a self-hosted vLLM or any + other OpenAI-compatible endpoint runs through ``OpenAILLM``, whose + ``provider_name`` is ``openai``. A known API host wins; an OpenAI client + pointed at any other host is reported as ``openai_compatible``. + + Args: + llm: The LLM instance making the call. + + Returns: + The provider name for spans and metrics. + """ + base = provider_name(getattr(llm, "provider_name", None)) + host = _endpoint_host(llm) + if host: + for suffix, name in _PROVIDER_HOSTS: + if host == suffix or host.endswith("." + suffix): + return name + if getattr(llm, "_provider_plugin", None) == "openai_compatible": + return "openai_compatible" + if base == "openai" and host: + # An OpenAI client aimed somewhere other than OpenAI's API. + return "openai_compatible" + return base + def start_llm_span( llm: Any, model: Optional[str], *, stream: bool, tools: Any = None @@ -25,7 +86,8 @@ def start_llm_span( return core.NOOP_SPAN attributes = { "gen_ai.operation.name": "chat", - "gen_ai.provider.name": provider_name(getattr(llm, "provider_name", None)), + "gen_ai.provider.name": llm_provider(llm), + "server.address": _endpoint_host(llm), "gen_ai.request.model": str(model) if model else None, "docsgpt.token_source": getattr(llm, "_token_usage_source", None) or "agent_stream", "docsgpt.stream": bool(stream), @@ -83,7 +145,7 @@ def finish_llm_call( if not settings.TRACES_ENABLED: return record_llm_metrics( - provider=getattr(llm, "provider_name", None), + provider=llm_provider(llm), model=str(model) if model else None, input_tokens=call_usage.get("prompt_tokens", 0), output_tokens=call_usage.get("generated_tokens", 0), diff --git a/tests/tracing/test_llm_spans.py b/tests/tracing/test_llm_spans.py index 897f0a89..966719de 100644 --- a/tests/tracing/test_llm_spans.py +++ b/tests/tracing/test_llm_spans.py @@ -226,3 +226,54 @@ class TestCacheHits: first, second = trace.spans assert first.attributes.get("docsgpt.cache_hit") is None assert second.attributes["docsgpt.cache_hit"] is True + + +class TestProviderResolution: + """The span names the provider actually called, not the client class used.""" + + @staticmethod + def _llm(provider="openai", base_url=None, plugin=None): + llm = _LLM() + llm.provider_name = provider + if base_url is not None: + llm._effective_base_url = base_url + if plugin is not None: + llm._provider_plugin = plugin + return llm + + @pytest.mark.parametrize( + "base_url, plugin, expected", + [ + ("https://api.deepseek.com/v1", "openai_compatible", "deepseek"), + ("https://my-res.openai.azure.com/openai", "openai", "azure.ai.openai"), + ("https://api.mistral.ai/v1", "openai_compatible", "mistral_ai"), + ("https://api.x.ai/v1", "openai_compatible", "x_ai"), + ("https://api.openai.com/v1", "openai", "openai"), + ("http://10.0.0.5:8000/v1", "openai_compatible", "openai_compatible"), + ("http://127.0.0.1:7899/v1", "openai", "openai_compatible"), + (None, "openai", "openai"), + ], + ) + def test_provider_from_endpoint(self, base_url, plugin, expected): + from docsgpt.tracing.llm import llm_provider + + assert llm_provider(self._llm(base_url=base_url, plugin=plugin)) == expected + + def test_native_providers_are_unchanged(self): + from docsgpt.tracing.llm import llm_provider + + assert llm_provider(self._llm(provider="anthropic")) == "anthropic" + assert llm_provider(self._llm(provider="google")) == "gcp.gen_ai" + + def test_span_and_metrics_use_it_and_record_server_address(self, trace, metrics): + llm = self._llm(base_url="https://api.deepseek.com/v1", plugin="openai_compatible") + + @gen_token_usage + def _gen(self, model, messages, stream, tools, **kwargs): + return "x" + + _gen(llm, "deepseek-chat", [], False, None) + attrs = trace.spans[0].attributes + assert attrs["gen_ai.provider.name"] == "deepseek" + assert attrs["server.address"] == "api.deepseek.com" + assert metrics.call_args.kwargs["provider"] == "deepseek"