mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 16:14:10 +00:00
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.
This commit is contained in:
1 parent
6b145d7aad
commit
3156ee37a1
3 files changed
+119
-2
No files matched your search
@@ -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
|
||||
+64
-2
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user