telemetry-dev-litellm 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: telemetry-dev-litellm
3
+ Version: 0.1.0
4
+ Summary: LiteLLM integration for telemetry.dev Python SDK
5
+ Keywords: telemetry,opentelemetry,llm,genai,litellm,tracing
6
+ Author: telemetry.dev
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Dist: telemetry-dev>=0.1.0
17
+ Requires-Dist: litellm>=1.90.2,<2.0
18
+ Requires-Dist: opentelemetry-api>=1.35.0,<2
19
+ Requires-Python: >=3.10, <3.14
20
+ Project-URL: Homepage, https://telemetry.dev
21
+ Project-URL: Repository, https://github.com/telemetry-dev/telemetry.dev
22
+ Description-Content-Type: text/markdown
23
+
24
+ # telemetry-dev-litellm
25
+
26
+ LiteLLM integration for the telemetry.dev Python SDK. It instruments `litellm.completion`, `litellm.acompletion`, `litellm.embedding`, `litellm.aembedding`, streaming `CustomStreamWrapper` responses, and `litellm.Router` calls by emitting spans through the public `telemetry_dev.start_span` API.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ pip install telemetry-dev telemetry-dev-litellm
32
+ # or
33
+ uv add telemetry-dev telemetry-dev-litellm
34
+ ```
35
+
36
+ Requires Python `>=3.10,<3.14`.
37
+
38
+ ## Initialize telemetry.dev
39
+
40
+ ```python
41
+ import telemetry_dev
42
+ from telemetry_dev_litellm import instrument_litellm
43
+
44
+ telemetry_dev.init() # reads TELEMETRY_DEV_API_KEY and OTLP settings
45
+ instrument_litellm()
46
+ ```
47
+
48
+ The core SDK fails open: without an API key, LiteLLM calls still run and telemetry is a no-op.
49
+
50
+ ## Per-call drop-ins
51
+
52
+ Use the exported functions as drop-in replacements when you do not want global monkey-patching:
53
+
54
+ ```python
55
+ from telemetry_dev_litellm import completion
56
+
57
+ response = completion(
58
+ model="gpt-4o-mini",
59
+ messages=[{"role": "user", "content": "Say hi"}],
60
+ )
61
+ ```
62
+
63
+ Available drop-ins: `completion`, `acompletion`, `embedding`, `aembedding`. Each resolves the current `litellm.<function>` at call time. If global instrumentation is already active, it delegates to the globally wrapped function so the call produces exactly one span.
64
+
65
+ ## Global instrumentation
66
+
67
+ ```python
68
+ import litellm
69
+ from telemetry_dev_litellm import instrument_litellm, uninstrument_litellm
70
+
71
+ instrument_litellm()
72
+ response = litellm.completion(model="gpt-4o-mini", messages=[...])
73
+ uninstrument_litellm()
74
+ ```
75
+
76
+ `instrument_litellm()` patches exactly four package attributes:
77
+
78
+ - `litellm.completion`
79
+ - `litellm.acompletion`
80
+ - `litellm.embedding`
81
+ - `litellm.aembedding`
82
+
83
+ It is thread-safe and idempotent. `uninstrument_litellm()` restores the original objects and is also idempotent.
84
+
85
+ Import-order limitation: references captured before instrumentation, such as `from litellm import completion`, keep pointing at the original function and are not traced. Instrument at process startup or use the `telemetry_dev_litellm` drop-ins.
86
+
87
+ ## Router wrapping
88
+
89
+ Global instrumentation traces Router deployment attempts because LiteLLM Router dispatches through the patched package attributes at call time:
90
+
91
+ ```python
92
+ import litellm
93
+ from telemetry_dev_litellm import instrument_litellm
94
+
95
+ instrument_litellm()
96
+ router = litellm.Router(model_list=[...])
97
+ router.completion(model="my-group", messages=[...]) # one span per deployment attempt
98
+ ```
99
+
100
+ Use `wrap_router(router)` for an enclosing Router-level span:
101
+
102
+ ```python
103
+ import litellm
104
+ from telemetry_dev_litellm import instrument_litellm, wrap_router
105
+
106
+ instrument_litellm()
107
+ router = wrap_router(litellm.Router(model_list=[...]))
108
+ router.completion(model="my-group", messages=[...])
109
+ ```
110
+
111
+ With both `wrap_router()` and `instrument_litellm()` active, the Router span is the parent and each deployment attempt is a child span. Router-injected LiteLLM metadata such as `model_group` and `deployment` is forwarded as `td.metadata.*` on inner attempt spans when LiteLLM provides it.
112
+
113
+ Internal LiteLLM `num_retries` retries happen inside a single wrapped call, so they are represented as one span whose duration covers all attempts. Router retries and fallbacks are separate deployment calls and can produce separate child spans under global instrumentation.
114
+
115
+ ## Captured fields
116
+
117
+ Chat spans use `type="generation"` and emit `gen_ai.operation.name="chat"`:
118
+
119
+ - request model, provider, messages, sampling parameters (including `top_k`), stop sequences, seed, frequency and presence penalties
120
+ - `response_format` as `gen_ai.output.type` (`json` for `json_object`/`json_schema`/pydantic models)
121
+ - response model, response id, output messages, finish reasons, usage, cost when available
122
+ - `metadata={...}` passed to LiteLLM as `td.metadata.*`
123
+ - errors via the core SDK's `error.type` and exception event mapping
124
+
125
+ Embedding spans use `type="embedding"` and emit `gen_ai.operation.name="embeddings"`:
126
+
127
+ - request model, provider, input, metadata
128
+ - response model, usage, cost when available
129
+ - no embedding vectors as output
130
+
131
+ Usage keys follow the core SDK contract: `input_tokens`, `output_tokens`, `total_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`, and `reasoning_output_tokens`.
132
+
133
+ ## Streaming
134
+
135
+ `stream=True` chat calls return a proxy around LiteLLM's `CustomStreamWrapper` that supports both sync and async iteration, context managers, `close()`, `aclose()`, and attribute delegation to the wrapped stream.
136
+
137
+ The integration never mutates request arguments. It does not inject `stream_options.include_usage`. On stream completion, early close, or mid-stream error, it rebuilds the best available response with `litellm.stream_chunk_builder(...)` and ends the span once. `time_to_first_chunk_ms` is recorded on the first chunk. Stream usage is best-effort: LiteLLM aggregates usage chunks when present, otherwise it estimates usage from chunks and request messages.
138
+
139
+ ## Provider attribution
140
+
141
+ LiteLLM provider names are mapped to OpenTelemetry well-known `gen_ai.provider.name` values where one exists:
142
+
143
+ | LiteLLM provider | Emitted provider |
144
+ | --- | --- |
145
+ | `openai` | `openai` |
146
+ | `azure`, `azure_text` | `azure.ai.openai` |
147
+ | `azure_ai` | `azure.ai.inference` |
148
+ | `anthropic`, `anthropic_text` | `anthropic` |
149
+ | `bedrock` | `aws.bedrock` |
150
+ | `vertex_ai`, `vertex_ai_beta` | `gcp.vertex_ai` |
151
+ | `gemini` | `gcp.gemini` |
152
+ | `mistral` | `mistral_ai` |
153
+ | `groq` | `groq` |
154
+ | `deepseek` | `deepseek` |
155
+ | `xai` | `x_ai` |
156
+ | `cohere`, `cohere_chat` | `cohere` |
157
+ | `perplexity` | `perplexity` |
158
+ | `watsonx`, `watsonx_text` | `ibm.watsonx.ai` |
159
+
160
+ Unlisted providers pass through unchanged, for example `ollama`, `openrouter`, `together_ai`, and `fireworks_ai`.
161
+
162
+ ## Cost
163
+
164
+ LiteLLM's synchronous response metadata is used when available: `_hidden_params.response_cost` maps to `gen_ai.usage.cost`. If that field is absent, the integration tries `litellm.completion_cost(completion_response=...)` and silently omits cost if LiteLLM cannot price the model.
165
+
166
+ ## Limitations
167
+
168
+ - Instrumented surfaces are limited to `completion`, `acompletion`, `embedding`, `aembedding`, and the same four methods on `Router` via `wrap_router`. `text_completion`, `litellm.responses`, image/audio/rerank/batch APIs, and provider-specific APIs are out of scope for this package version.
169
+ - References imported from LiteLLM before `instrument_litellm()` are not patched.
170
+ - Internal `num_retries` retries are not separate spans; Router deployment attempts are.
171
+ - OTel's built-in-tools scenario is not portable across LiteLLM providers and is not covered by the examples.
172
+ - Multimodal output is not covered; LiteLLM chat returns text/tool calls, while image and audio generation use APIs outside this integration.
173
+
174
+ ## Development
175
+
176
+ ```sh
177
+ uv sync
178
+ uv run pytest
179
+ uv run ruff format .
180
+ uv run ruff check .
181
+ uv run pyright
182
+ ```
183
+
184
+ Repository-level commands run this package together with the core Python SDK:
185
+
186
+ ```sh
187
+ pnpm run py:sync
188
+ pnpm run py:check
189
+ pnpm run py:test
190
+ ```
@@ -0,0 +1,167 @@
1
+ # telemetry-dev-litellm
2
+
3
+ LiteLLM integration for the telemetry.dev Python SDK. It instruments `litellm.completion`, `litellm.acompletion`, `litellm.embedding`, `litellm.aembedding`, streaming `CustomStreamWrapper` responses, and `litellm.Router` calls by emitting spans through the public `telemetry_dev.start_span` API.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pip install telemetry-dev telemetry-dev-litellm
9
+ # or
10
+ uv add telemetry-dev telemetry-dev-litellm
11
+ ```
12
+
13
+ Requires Python `>=3.10,<3.14`.
14
+
15
+ ## Initialize telemetry.dev
16
+
17
+ ```python
18
+ import telemetry_dev
19
+ from telemetry_dev_litellm import instrument_litellm
20
+
21
+ telemetry_dev.init() # reads TELEMETRY_DEV_API_KEY and OTLP settings
22
+ instrument_litellm()
23
+ ```
24
+
25
+ The core SDK fails open: without an API key, LiteLLM calls still run and telemetry is a no-op.
26
+
27
+ ## Per-call drop-ins
28
+
29
+ Use the exported functions as drop-in replacements when you do not want global monkey-patching:
30
+
31
+ ```python
32
+ from telemetry_dev_litellm import completion
33
+
34
+ response = completion(
35
+ model="gpt-4o-mini",
36
+ messages=[{"role": "user", "content": "Say hi"}],
37
+ )
38
+ ```
39
+
40
+ Available drop-ins: `completion`, `acompletion`, `embedding`, `aembedding`. Each resolves the current `litellm.<function>` at call time. If global instrumentation is already active, it delegates to the globally wrapped function so the call produces exactly one span.
41
+
42
+ ## Global instrumentation
43
+
44
+ ```python
45
+ import litellm
46
+ from telemetry_dev_litellm import instrument_litellm, uninstrument_litellm
47
+
48
+ instrument_litellm()
49
+ response = litellm.completion(model="gpt-4o-mini", messages=[...])
50
+ uninstrument_litellm()
51
+ ```
52
+
53
+ `instrument_litellm()` patches exactly four package attributes:
54
+
55
+ - `litellm.completion`
56
+ - `litellm.acompletion`
57
+ - `litellm.embedding`
58
+ - `litellm.aembedding`
59
+
60
+ It is thread-safe and idempotent. `uninstrument_litellm()` restores the original objects and is also idempotent.
61
+
62
+ Import-order limitation: references captured before instrumentation, such as `from litellm import completion`, keep pointing at the original function and are not traced. Instrument at process startup or use the `telemetry_dev_litellm` drop-ins.
63
+
64
+ ## Router wrapping
65
+
66
+ Global instrumentation traces Router deployment attempts because LiteLLM Router dispatches through the patched package attributes at call time:
67
+
68
+ ```python
69
+ import litellm
70
+ from telemetry_dev_litellm import instrument_litellm
71
+
72
+ instrument_litellm()
73
+ router = litellm.Router(model_list=[...])
74
+ router.completion(model="my-group", messages=[...]) # one span per deployment attempt
75
+ ```
76
+
77
+ Use `wrap_router(router)` for an enclosing Router-level span:
78
+
79
+ ```python
80
+ import litellm
81
+ from telemetry_dev_litellm import instrument_litellm, wrap_router
82
+
83
+ instrument_litellm()
84
+ router = wrap_router(litellm.Router(model_list=[...]))
85
+ router.completion(model="my-group", messages=[...])
86
+ ```
87
+
88
+ With both `wrap_router()` and `instrument_litellm()` active, the Router span is the parent and each deployment attempt is a child span. Router-injected LiteLLM metadata such as `model_group` and `deployment` is forwarded as `td.metadata.*` on inner attempt spans when LiteLLM provides it.
89
+
90
+ Internal LiteLLM `num_retries` retries happen inside a single wrapped call, so they are represented as one span whose duration covers all attempts. Router retries and fallbacks are separate deployment calls and can produce separate child spans under global instrumentation.
91
+
92
+ ## Captured fields
93
+
94
+ Chat spans use `type="generation"` and emit `gen_ai.operation.name="chat"`:
95
+
96
+ - request model, provider, messages, sampling parameters (including `top_k`), stop sequences, seed, frequency and presence penalties
97
+ - `response_format` as `gen_ai.output.type` (`json` for `json_object`/`json_schema`/pydantic models)
98
+ - response model, response id, output messages, finish reasons, usage, cost when available
99
+ - `metadata={...}` passed to LiteLLM as `td.metadata.*`
100
+ - errors via the core SDK's `error.type` and exception event mapping
101
+
102
+ Embedding spans use `type="embedding"` and emit `gen_ai.operation.name="embeddings"`:
103
+
104
+ - request model, provider, input, metadata
105
+ - response model, usage, cost when available
106
+ - no embedding vectors as output
107
+
108
+ Usage keys follow the core SDK contract: `input_tokens`, `output_tokens`, `total_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`, and `reasoning_output_tokens`.
109
+
110
+ ## Streaming
111
+
112
+ `stream=True` chat calls return a proxy around LiteLLM's `CustomStreamWrapper` that supports both sync and async iteration, context managers, `close()`, `aclose()`, and attribute delegation to the wrapped stream.
113
+
114
+ The integration never mutates request arguments. It does not inject `stream_options.include_usage`. On stream completion, early close, or mid-stream error, it rebuilds the best available response with `litellm.stream_chunk_builder(...)` and ends the span once. `time_to_first_chunk_ms` is recorded on the first chunk. Stream usage is best-effort: LiteLLM aggregates usage chunks when present, otherwise it estimates usage from chunks and request messages.
115
+
116
+ ## Provider attribution
117
+
118
+ LiteLLM provider names are mapped to OpenTelemetry well-known `gen_ai.provider.name` values where one exists:
119
+
120
+ | LiteLLM provider | Emitted provider |
121
+ | --- | --- |
122
+ | `openai` | `openai` |
123
+ | `azure`, `azure_text` | `azure.ai.openai` |
124
+ | `azure_ai` | `azure.ai.inference` |
125
+ | `anthropic`, `anthropic_text` | `anthropic` |
126
+ | `bedrock` | `aws.bedrock` |
127
+ | `vertex_ai`, `vertex_ai_beta` | `gcp.vertex_ai` |
128
+ | `gemini` | `gcp.gemini` |
129
+ | `mistral` | `mistral_ai` |
130
+ | `groq` | `groq` |
131
+ | `deepseek` | `deepseek` |
132
+ | `xai` | `x_ai` |
133
+ | `cohere`, `cohere_chat` | `cohere` |
134
+ | `perplexity` | `perplexity` |
135
+ | `watsonx`, `watsonx_text` | `ibm.watsonx.ai` |
136
+
137
+ Unlisted providers pass through unchanged, for example `ollama`, `openrouter`, `together_ai`, and `fireworks_ai`.
138
+
139
+ ## Cost
140
+
141
+ LiteLLM's synchronous response metadata is used when available: `_hidden_params.response_cost` maps to `gen_ai.usage.cost`. If that field is absent, the integration tries `litellm.completion_cost(completion_response=...)` and silently omits cost if LiteLLM cannot price the model.
142
+
143
+ ## Limitations
144
+
145
+ - Instrumented surfaces are limited to `completion`, `acompletion`, `embedding`, `aembedding`, and the same four methods on `Router` via `wrap_router`. `text_completion`, `litellm.responses`, image/audio/rerank/batch APIs, and provider-specific APIs are out of scope for this package version.
146
+ - References imported from LiteLLM before `instrument_litellm()` are not patched.
147
+ - Internal `num_retries` retries are not separate spans; Router deployment attempts are.
148
+ - OTel's built-in-tools scenario is not portable across LiteLLM providers and is not covered by the examples.
149
+ - Multimodal output is not covered; LiteLLM chat returns text/tool calls, while image and audio generation use APIs outside this integration.
150
+
151
+ ## Development
152
+
153
+ ```sh
154
+ uv sync
155
+ uv run pytest
156
+ uv run ruff format .
157
+ uv run ruff check .
158
+ uv run pyright
159
+ ```
160
+
161
+ Repository-level commands run this package together with the core Python SDK:
162
+
163
+ ```sh
164
+ pnpm run py:sync
165
+ pnpm run py:check
166
+ pnpm run py:test
167
+ ```
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "telemetry-dev-litellm"
3
+ version = "0.1.0"
4
+ description = "LiteLLM integration for telemetry.dev Python SDK"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10,<3.14"
8
+ authors = [{ name = "telemetry.dev" }]
9
+ keywords = ["telemetry", "opentelemetry", "llm", "genai", "litellm", "tracing"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = [
21
+ "telemetry-dev>=0.1.0",
22
+ "litellm>=1.90.2,<2.0",
23
+ "opentelemetry-api>=1.35.0,<2",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://telemetry.dev"
28
+ Repository = "https://github.com/telemetry-dev/telemetry.dev"
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "pytest>=8.3",
33
+ "pytest-asyncio>=0.25",
34
+ "ruff>=0.9",
35
+ "pyright>=1.1.390",
36
+ ]
37
+
38
+ [tool.uv.sources]
39
+ telemetry-dev = { path = "../python", editable = true }
40
+
41
+ [build-system]
42
+ requires = ["uv_build>=0.9.0,<0.10.0"]
43
+ build-backend = "uv_build"
44
+
45
+ [tool.pytest.ini_options]
46
+ asyncio_mode = "auto"
47
+ testpaths = ["tests"]
48
+
49
+ [tool.ruff]
50
+ line-length = 100
51
+ target-version = "py310"
52
+
53
+ [tool.ruff.lint]
54
+ select = ["E", "F", "I", "UP", "B", "RUF"]
55
+
56
+ [tool.pyright]
57
+ include = ["src", "tests"]
58
+ typeCheckingMode = "strict"
59
+ pythonVersion = "3.10"
@@ -0,0 +1,724 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import threading
6
+ import time
7
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping, Sequence
8
+ from functools import wraps
9
+ from inspect import Parameter
10
+ from typing import Any, TypeVar, cast
11
+
12
+ import litellm
13
+ import telemetry_dev
14
+ from opentelemetry import trace
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ RequestMapper = Callable[[tuple[Any, ...], Mapping[str, Any]], tuple[str, dict[str, Any]]]
19
+ ResponseMapper = Callable[[Any], dict[str, Any]]
20
+ _T = TypeVar("_T")
21
+
22
+ _WRAPPED_ATTR = "_telemetry_dev_litellm_wrapped"
23
+ _ORIGINAL_ATTR = "_telemetry_dev_litellm_original"
24
+ _ROUTER_WRAPPED_ATTR = "_telemetry_dev_litellm_router_wrapped"
25
+ _ORIGINALS: list[tuple[object, str, Any]] = []
26
+ _DROPIN_WRAPPERS: dict[str, tuple[Any, Callable[..., Any]]] = {}
27
+ _PENDING_CLOSE_TASKS: set[asyncio.Future[Any]] = set()
28
+ _installed = False
29
+ _install_lock = threading.Lock()
30
+
31
+ _PROVIDER_NAMES: dict[str, str] = {
32
+ "openai": "openai",
33
+ "azure": "azure.ai.openai",
34
+ "azure_text": "azure.ai.openai",
35
+ "azure_ai": "azure.ai.inference",
36
+ "anthropic": "anthropic",
37
+ "anthropic_text": "anthropic",
38
+ "bedrock": "aws.bedrock",
39
+ "vertex_ai": "gcp.vertex_ai",
40
+ "vertex_ai_beta": "gcp.vertex_ai",
41
+ "gemini": "gcp.gemini",
42
+ "mistral": "mistral_ai",
43
+ "groq": "groq",
44
+ "deepseek": "deepseek",
45
+ "xai": "x_ai",
46
+ "cohere": "cohere",
47
+ "cohere_chat": "cohere",
48
+ "perplexity": "perplexity",
49
+ "watsonx": "ibm.watsonx.ai",
50
+ "watsonx_text": "ibm.watsonx.ai",
51
+ }
52
+
53
+
54
+ def _field(value: Any, name: str) -> Any:
55
+ if isinstance(value, Mapping):
56
+ mapping = cast(Mapping[str, Any], value)
57
+ return mapping.get(name)
58
+ return getattr(value, name, None)
59
+
60
+
61
+ def _sequence_items(value: Any) -> list[Any]:
62
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
63
+ return list(cast(Sequence[Any], value))
64
+ return []
65
+
66
+
67
+ def _native(value: Any) -> Any:
68
+ if hasattr(value, "model_dump"):
69
+ return value.model_dump(mode="json", exclude_none=True)
70
+ if isinstance(value, Mapping):
71
+ mapping = cast(Mapping[Any, Any], value)
72
+ return {str(key): _native(item) for key, item in mapping.items() if item is not None}
73
+ sequence = _sequence_items(value)
74
+ if sequence:
75
+ return [_native(item) for item in sequence]
76
+ return value
77
+
78
+
79
+ def _number(value: Any) -> int | float | None:
80
+ if isinstance(value, bool):
81
+ return None
82
+ if isinstance(value, int | float):
83
+ return value
84
+ return None
85
+
86
+
87
+ def _string(value: Any) -> str | None:
88
+ return value if isinstance(value, str) else None
89
+
90
+
91
+ def _usage(fields: Mapping[str, int | float | None]) -> dict[str, int | float] | None:
92
+ usage = {key: value for key, value in fields.items() if value is not None}
93
+ return usage or None
94
+
95
+
96
+ def _stop_sequences(value: Any) -> list[str] | None:
97
+ if isinstance(value, str):
98
+ return [value]
99
+ strings = [item for item in _sequence_items(value) if isinstance(item, str)]
100
+ return strings or None
101
+
102
+
103
+ def _clean_fields(fields: Mapping[str, Any]) -> dict[str, Any]:
104
+ return {key: value for key, value in fields.items() if value is not None}
105
+
106
+
107
+ def _provider_name(provider: Any) -> str | None:
108
+ raw = _string(provider)
109
+ if raw is None:
110
+ return None
111
+ return _PROVIDER_NAMES.get(raw, raw)
112
+
113
+
114
+ def _arg(args: tuple[Any, ...], kwargs: Mapping[str, Any], name: str, index: int) -> Any:
115
+ if name in kwargs:
116
+ return kwargs[name]
117
+ if len(args) > index:
118
+ return args[index]
119
+ return None
120
+
121
+
122
+ def _stream_enabled(args: tuple[Any, ...], kwargs: Mapping[str, Any], index: int | None) -> bool:
123
+ if kwargs.get("stream") is True:
124
+ return True
125
+ return index is not None and len(args) > index and args[index] is True
126
+
127
+
128
+ def _bound_kwargs(
129
+ original: Callable[..., Any], args: tuple[Any, ...], kwargs: Mapping[str, Any]
130
+ ) -> Mapping[str, Any]:
131
+ try:
132
+ signature = inspect.signature(original)
133
+ bound = signature.bind_partial(*args, **kwargs)
134
+ except (TypeError, ValueError):
135
+ return kwargs
136
+ mapped: dict[str, Any] = dict(kwargs)
137
+ for name, value in bound.arguments.items():
138
+ parameter = signature.parameters.get(name)
139
+ if parameter is None:
140
+ continue
141
+ if parameter.kind is Parameter.VAR_KEYWORD and isinstance(value, Mapping):
142
+ mapped.update(cast(Mapping[str, Any], value))
143
+ elif parameter.kind is not Parameter.VAR_POSITIONAL:
144
+ mapped[name] = value
145
+ return mapped
146
+
147
+
148
+ def _stream_index(original: Callable[..., Any]) -> int | None:
149
+ try:
150
+ signature = inspect.signature(original)
151
+ except (TypeError, ValueError):
152
+ return None
153
+ positional_index = 0
154
+ for parameter in signature.parameters.values():
155
+ if parameter.kind in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD):
156
+ if parameter.name == "stream":
157
+ return positional_index
158
+ positional_index += 1
159
+ return None
160
+
161
+
162
+ def _resolve_provider(model_value: Any, kwargs: Mapping[str, Any]) -> tuple[str | None, str | None]:
163
+ model = _string(model_value)
164
+ if model is None:
165
+ return None, None
166
+ model_for_provider = _string(kwargs.get("deployment_id")) or model
167
+ custom_llm_provider = kwargs.get("custom_llm_provider")
168
+ if kwargs.get("azure") is True or kwargs.get("deployment_id") is not None:
169
+ custom_llm_provider = "azure"
170
+ try:
171
+ resolved_model, provider, _dynamic_api_key, _api_base = litellm.get_llm_provider(
172
+ model=model_for_provider,
173
+ custom_llm_provider=custom_llm_provider,
174
+ api_base=kwargs.get("api_base") or kwargs.get("base_url"),
175
+ )
176
+ except Exception:
177
+ return model_for_provider, None
178
+ return _string(resolved_model) or model_for_provider, _provider_name(provider)
179
+
180
+
181
+ def _metadata(value: Any) -> Mapping[str, Any] | None:
182
+ return cast(Mapping[str, Any], value) if isinstance(value, Mapping) else None
183
+
184
+
185
+ def _request_metadata(kwargs: Mapping[str, Any]) -> Mapping[str, Any] | None:
186
+ return _metadata(kwargs.get("litellm_metadata")) or _metadata(kwargs.get("metadata"))
187
+
188
+
189
+ def _output_type(response_format: Any) -> str | None:
190
+ if response_format is None:
191
+ return None
192
+ if isinstance(response_format, type):
193
+ return "json"
194
+ kind = _string(_field(response_format, "type"))
195
+ if kind in ("json_object", "json_schema"):
196
+ return "json"
197
+ return kind
198
+
199
+
200
+ def _completion_request(
201
+ args: tuple[Any, ...], kwargs: Mapping[str, Any]
202
+ ) -> tuple[str, dict[str, Any]]:
203
+ raw_model = _arg(args, kwargs, "model", 0)
204
+ model, provider = _resolve_provider(raw_model, kwargs)
205
+ return (
206
+ f"chat {model or _string(raw_model) or 'unknown'}",
207
+ {
208
+ "type": "generation",
209
+ "model": model or _string(raw_model),
210
+ "provider": provider,
211
+ "input": _arg(args, kwargs, "messages", 1),
212
+ "temperature": _number(kwargs.get("temperature")),
213
+ "top_p": _number(kwargs.get("top_p")),
214
+ "top_k": _number(kwargs.get("top_k")),
215
+ "max_tokens": _number(kwargs.get("max_completion_tokens"))
216
+ or _number(kwargs.get("max_tokens")),
217
+ "stop_sequences": _stop_sequences(kwargs.get("stop")),
218
+ "seed": _number(kwargs.get("seed")),
219
+ "frequency_penalty": _number(kwargs.get("frequency_penalty")),
220
+ "presence_penalty": _number(kwargs.get("presence_penalty")),
221
+ "output_type": _output_type(kwargs.get("response_format")),
222
+ "metadata": _request_metadata(kwargs),
223
+ },
224
+ )
225
+
226
+
227
+ def _embedding_request(
228
+ args: tuple[Any, ...], kwargs: Mapping[str, Any]
229
+ ) -> tuple[str, dict[str, Any]]:
230
+ raw_model = _arg(args, kwargs, "model", 0)
231
+ model, provider = _resolve_provider(raw_model, kwargs)
232
+ return (
233
+ f"embeddings {model or _string(raw_model) or 'unknown'}",
234
+ {
235
+ "type": "embedding",
236
+ "model": model or _string(raw_model),
237
+ "provider": provider,
238
+ "input": _arg(args, kwargs, "input", 1),
239
+ "metadata": _request_metadata(kwargs),
240
+ },
241
+ )
242
+
243
+
244
+ def _usage_from(raw: Any) -> dict[str, int | float] | None:
245
+ prompt_details = _field(raw, "prompt_tokens_details")
246
+ completion_details = _field(raw, "completion_tokens_details")
247
+ cache_creation_tokens = _number(_field(prompt_details, "cache_creation_tokens"))
248
+ if cache_creation_tokens is None:
249
+ cache_creation_tokens = _number(_field(prompt_details, "cache_write_tokens"))
250
+ return _usage(
251
+ {
252
+ "input_tokens": _number(_field(raw, "prompt_tokens")),
253
+ "output_tokens": _number(_field(raw, "completion_tokens")),
254
+ "total_tokens": _number(_field(raw, "total_tokens")),
255
+ "cache_read_input_tokens": _number(_field(prompt_details, "cached_tokens")),
256
+ "cache_creation_input_tokens": cache_creation_tokens,
257
+ "reasoning_output_tokens": _number(_field(completion_details, "reasoning_tokens")),
258
+ }
259
+ )
260
+
261
+
262
+ def _hidden_params(response: Any) -> Any:
263
+ return _field(response, "_hidden_params")
264
+
265
+
266
+ def _cost_from(response: Any) -> float | None:
267
+ hidden_cost = _number(_field(_hidden_params(response), "response_cost"))
268
+ if hidden_cost is not None and hidden_cost >= 0:
269
+ return float(hidden_cost)
270
+ try:
271
+ cost = _number(litellm.completion_cost(completion_response=response))
272
+ except Exception:
273
+ return None
274
+ if cost is None or cost < 0:
275
+ return None
276
+ return float(cost)
277
+
278
+
279
+ def _chat_output_message(message: Any) -> dict[str, Any]:
280
+ if message is None:
281
+ return {}
282
+ native = _native(message)
283
+ if not isinstance(native, dict):
284
+ return cast(dict[str, Any], native)
285
+ if _field(message, "content") is None:
286
+ native["content"] = None
287
+ return cast(dict[str, Any], native)
288
+
289
+
290
+ def _completion_response(response: Any) -> dict[str, Any]:
291
+ choices = list(_field(response, "choices") or [])
292
+ finish_reasons = [
293
+ reason
294
+ for choice in choices
295
+ if (reason := _string(_field(choice, "finish_reason"))) is not None
296
+ ]
297
+ fields: dict[str, Any] = {
298
+ "response_model": _string(_field(response, "model")),
299
+ "response_id": _string(_field(response, "id")),
300
+ "finish_reason": finish_reasons[0] if finish_reasons else None,
301
+ "output": [_chat_output_message(_field(choice, "message")) for choice in choices],
302
+ "usage": _usage_from(_field(response, "usage")),
303
+ "cost_usd": _cost_from(response),
304
+ "provider": _provider_name(_field(_hidden_params(response), "custom_llm_provider")),
305
+ "attributes": (
306
+ {"gen_ai.response.finish_reasons": finish_reasons} if len(finish_reasons) > 1 else None
307
+ ),
308
+ }
309
+ return fields
310
+
311
+
312
+ def _embedding_response(response: Any) -> dict[str, Any]:
313
+ raw_usage = _field(response, "usage")
314
+ return {
315
+ "response_model": _string(_field(response, "model")),
316
+ "usage": _usage(
317
+ {
318
+ "input_tokens": _number(_field(raw_usage, "prompt_tokens")),
319
+ "total_tokens": _number(_field(raw_usage, "total_tokens")),
320
+ }
321
+ ),
322
+ "cost_usd": _cost_from(response),
323
+ "provider": _provider_name(_field(_hidden_params(response), "custom_llm_provider")),
324
+ }
325
+
326
+
327
+ def _end_once(handle: telemetry_dev.SpanHandle) -> Callable[..., None]:
328
+ ended = False
329
+
330
+ def end(**fields: Any) -> None:
331
+ nonlocal ended
332
+ if ended:
333
+ return
334
+ ended = True
335
+ handle.end(**_clean_fields(fields))
336
+
337
+ return end
338
+
339
+
340
+ def _safe_response_fields(mapper: ResponseMapper, response: Any) -> dict[str, Any]:
341
+ try:
342
+ return mapper(response)
343
+ except Exception:
344
+ return {}
345
+
346
+
347
+ def _safe_start_span(
348
+ op: str, mapper: RequestMapper, args: tuple[Any, ...], kwargs: Mapping[str, Any]
349
+ ) -> tuple[telemetry_dev.SpanHandle, Callable[..., None], float, str | None]:
350
+ fallback_type = "embedding" if op == "embeddings" else "generation"
351
+ try:
352
+ name, fields = mapper(args, kwargs)
353
+ except Exception:
354
+ name = f"{op} unknown"
355
+ fields = {"type": fallback_type}
356
+ request_provider = _provider_name(fields.get("provider"))
357
+ handle = telemetry_dev.start_span(name, **_clean_fields(fields))
358
+ return handle, _end_once(handle), time.perf_counter(), request_provider
359
+
360
+
361
+ def _provider_from_error(error: BaseException) -> str | None:
362
+ return _provider_name(getattr(error, "llm_provider", None))
363
+
364
+
365
+ async def _maybe_await(value: Any) -> Any:
366
+ if hasattr(value, "__await__"):
367
+ return await cast(Awaitable[Any], value)
368
+ return value
369
+
370
+
371
+ def _run_sync_awaitable(value: Any) -> None:
372
+ if not inspect.isawaitable(value):
373
+ return
374
+ awaitable = value
375
+ try:
376
+ loop = asyncio.get_running_loop()
377
+ except RuntimeError:
378
+ asyncio.run(cast(Any, awaitable))
379
+ else:
380
+ task = asyncio.ensure_future(awaitable, loop=loop)
381
+ _PENDING_CLOSE_TASKS.add(task)
382
+ task.add_done_callback(_PENDING_CLOSE_TASKS.discard)
383
+
384
+
385
+ class _InstrumentedStream:
386
+ def __init__(
387
+ self,
388
+ inner: Any,
389
+ handle: telemetry_dev.SpanHandle,
390
+ messages: Any,
391
+ started_at: float,
392
+ request_provider: str | None,
393
+ ) -> None:
394
+ self._inner = inner
395
+ self._handle = handle
396
+ self._end = _end_once(handle)
397
+ self._messages = messages
398
+ self._started_at = started_at
399
+ self._request_provider = request_provider
400
+ self._chunks: list[Any] = []
401
+ self._saw_first = False
402
+ self._finished = False
403
+
404
+ def __getattr__(self, name: str) -> Any:
405
+ return getattr(self._inner, name)
406
+
407
+ def __iter__(self) -> Iterator[Any]:
408
+ return self
409
+
410
+ def __next__(self) -> Any:
411
+ try:
412
+ chunk = next(self._inner)
413
+ except StopIteration:
414
+ self.close()
415
+ raise
416
+ except BaseException as exc:
417
+ self._finish(error=exc)
418
+ raise
419
+ self._record(chunk)
420
+ return chunk
421
+
422
+ def __aiter__(self) -> AsyncIterator[Any]:
423
+ return self
424
+
425
+ async def __anext__(self) -> Any:
426
+ try:
427
+ chunk = await self._inner.__anext__()
428
+ except StopAsyncIteration:
429
+ await self.aclose()
430
+ raise
431
+ except BaseException as exc:
432
+ self._finish(error=exc)
433
+ raise
434
+ self._record(chunk)
435
+ return chunk
436
+
437
+ def __enter__(self) -> _InstrumentedStream:
438
+ enter = getattr(self._inner, "__enter__", None)
439
+ if callable(enter):
440
+ enter()
441
+ return self
442
+
443
+ def __exit__(
444
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any
445
+ ) -> bool:
446
+ self.close()
447
+ exit_method = getattr(self._inner, "__exit__", None)
448
+ if callable(exit_method):
449
+ return bool(exit_method(exc_type, exc, tb))
450
+ return False
451
+
452
+ async def __aenter__(self) -> _InstrumentedStream:
453
+ enter = getattr(self._inner, "__aenter__", None)
454
+ if callable(enter):
455
+ await _maybe_await(enter())
456
+ return self
457
+
458
+ async def __aexit__(
459
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any
460
+ ) -> bool:
461
+ await self.aclose()
462
+ exit_method = getattr(self._inner, "__aexit__", None)
463
+ if callable(exit_method):
464
+ return bool(await _maybe_await(exit_method(exc_type, exc, tb)))
465
+ return False
466
+
467
+ def _record(self, chunk: Any) -> None:
468
+ self._chunks.append(chunk)
469
+ if self._saw_first:
470
+ return
471
+ self._saw_first = True
472
+ self._handle.update(
473
+ time_to_first_chunk_ms=(time.perf_counter() - self._started_at) * 1000,
474
+ response_id=_string(_field(chunk, "id")),
475
+ response_model=_string(_field(chunk, "model")),
476
+ )
477
+
478
+ def _finish(self, error: BaseException | None = None) -> None:
479
+ if self._finished:
480
+ return
481
+ self._finished = True
482
+ fields: dict[str, Any] = {}
483
+ rebuilt: Any = None
484
+ try:
485
+ stream_chunk_builder = cast(Callable[..., Any], cast(Any, litellm).stream_chunk_builder)
486
+ rebuilt = stream_chunk_builder(self._chunks, messages=self._messages)
487
+ except Exception:
488
+ rebuilt = None
489
+ if rebuilt is not None:
490
+ fields = _safe_response_fields(_completion_response, rebuilt)
491
+ if self._request_provider is not None:
492
+ fields.pop("provider", None)
493
+ elif fields.get("provider") is None:
494
+ fields["provider"] = _provider_name(getattr(self._inner, "custom_llm_provider", None))
495
+ if error is not None:
496
+ fields["error"] = error
497
+ if self._request_provider is None and fields.get("provider") is None:
498
+ fields["provider"] = _provider_from_error(error)
499
+ self._end(**fields)
500
+
501
+ def close(self) -> None:
502
+ self._finish()
503
+ close = getattr(self._inner, "close", None)
504
+ if callable(close):
505
+ _run_sync_awaitable(close())
506
+ return
507
+ aclose = getattr(self._inner, "aclose", None)
508
+ if callable(aclose):
509
+ _run_sync_awaitable(aclose())
510
+
511
+ async def aclose(self) -> None:
512
+ self._finish()
513
+ aclose = getattr(self._inner, "aclose", None)
514
+ if callable(aclose):
515
+ await _maybe_await(aclose())
516
+ return
517
+ close = getattr(self._inner, "close", None)
518
+ if callable(close):
519
+ await _maybe_await(close())
520
+
521
+
522
+ def _wrap_sync(
523
+ original: Callable[..., Any],
524
+ op: str,
525
+ request_mapper: RequestMapper,
526
+ response_mapper: ResponseMapper,
527
+ stream_index: int | None = None,
528
+ ) -> Callable[..., Any]:
529
+ @wraps(original)
530
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
531
+ mapped_kwargs = _bound_kwargs(original, args, kwargs)
532
+ handle, end, started_at, request_provider = _safe_start_span(
533
+ op, request_mapper, args, mapped_kwargs
534
+ )
535
+ try:
536
+ with trace.use_span(
537
+ handle.span,
538
+ end_on_exit=False,
539
+ record_exception=False,
540
+ set_status_on_exception=False,
541
+ ):
542
+ result = original(*args, **kwargs)
543
+ except BaseException as exc:
544
+ end(
545
+ error=exc,
546
+ provider=None if request_provider is not None else _provider_from_error(exc),
547
+ )
548
+ raise
549
+ if op == "chat" and _stream_enabled(args, mapped_kwargs, stream_index):
550
+ return _InstrumentedStream(
551
+ result,
552
+ handle,
553
+ messages=_arg(args, mapped_kwargs, "messages", 1),
554
+ started_at=started_at,
555
+ request_provider=request_provider,
556
+ )
557
+ try:
558
+ fields = _safe_response_fields(response_mapper, result)
559
+ except BaseException as exc:
560
+ end(
561
+ error=exc,
562
+ provider=None if request_provider is not None else _provider_from_error(exc),
563
+ )
564
+ raise
565
+ end(**fields)
566
+ return result
567
+
568
+ setattr(wrapper, _WRAPPED_ATTR, True)
569
+ setattr(wrapper, _ORIGINAL_ATTR, original)
570
+ return wrapper
571
+
572
+
573
+ def _wrap_async(
574
+ original: Callable[..., Any],
575
+ op: str,
576
+ request_mapper: RequestMapper,
577
+ response_mapper: ResponseMapper,
578
+ stream_index: int | None = None,
579
+ ) -> Callable[..., Any]:
580
+ @wraps(original)
581
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
582
+ mapped_kwargs = _bound_kwargs(original, args, kwargs)
583
+ handle, end, started_at, request_provider = _safe_start_span(
584
+ op, request_mapper, args, mapped_kwargs
585
+ )
586
+ try:
587
+ with trace.use_span(
588
+ handle.span,
589
+ end_on_exit=False,
590
+ record_exception=False,
591
+ set_status_on_exception=False,
592
+ ):
593
+ result = await original(*args, **kwargs)
594
+ except BaseException as exc:
595
+ end(
596
+ error=exc,
597
+ provider=None if request_provider is not None else _provider_from_error(exc),
598
+ )
599
+ raise
600
+ if op == "chat" and _stream_enabled(args, mapped_kwargs, stream_index):
601
+ return _InstrumentedStream(
602
+ result,
603
+ handle,
604
+ messages=_arg(args, mapped_kwargs, "messages", 1),
605
+ started_at=started_at,
606
+ request_provider=request_provider,
607
+ )
608
+ try:
609
+ fields = _safe_response_fields(response_mapper, result)
610
+ except BaseException as exc:
611
+ end(
612
+ error=exc,
613
+ provider=None if request_provider is not None else _provider_from_error(exc),
614
+ )
615
+ raise
616
+ end(**fields)
617
+ return result
618
+
619
+ setattr(wrapper, _WRAPPED_ATTR, True)
620
+ setattr(wrapper, _ORIGINAL_ATTR, original)
621
+ return wrapper
622
+
623
+
624
+ def _wrapper_for(name: str, original: Callable[..., Any]) -> Callable[..., Any]:
625
+ if name in {"completion", "acompletion"}:
626
+ mapper = _completion_request
627
+ response_mapper = _completion_response
628
+ op = "chat"
629
+ else:
630
+ mapper = _embedding_request
631
+ response_mapper = _embedding_response
632
+ op = "embeddings"
633
+ stream_index = _stream_index(original)
634
+ if name in {"acompletion", "aembedding"}:
635
+ return _wrap_async(original, op, mapper, response_mapper, stream_index=stream_index)
636
+ return _wrap_sync(original, op, mapper, response_mapper, stream_index=stream_index)
637
+
638
+
639
+ def _dropin(name: str, *args: Any, **kwargs: Any) -> Any:
640
+ current = getattr(litellm, name)
641
+ if getattr(current, _WRAPPED_ATTR, False):
642
+ return current(*args, **kwargs)
643
+ cached = _DROPIN_WRAPPERS.get(name)
644
+ if cached is None or cached[0] is not current:
645
+ wrapped = _wrapper_for(name, current)
646
+ _DROPIN_WRAPPERS[name] = (current, wrapped)
647
+ else:
648
+ wrapped = cached[1]
649
+ return wrapped(*args, **kwargs)
650
+
651
+
652
+ def completion(*args: Any, **kwargs: Any) -> Any:
653
+ return _dropin("completion", *args, **kwargs)
654
+
655
+
656
+ async def acompletion(*args: Any, **kwargs: Any) -> Any:
657
+ return await _dropin("acompletion", *args, **kwargs)
658
+
659
+
660
+ def embedding(*args: Any, **kwargs: Any) -> Any:
661
+ return _dropin("embedding", *args, **kwargs)
662
+
663
+
664
+ async def aembedding(*args: Any, **kwargs: Any) -> Any:
665
+ return await _dropin("aembedding", *args, **kwargs)
666
+
667
+
668
+ def _patch_litellm_function(name: str) -> None:
669
+ current = getattr(litellm, name)
670
+ if getattr(current, _WRAPPED_ATTR, False):
671
+ return
672
+ _ORIGINALS.append((litellm, name, current))
673
+ setattr(litellm, name, _wrapper_for(name, current))
674
+
675
+
676
+ def instrument_litellm() -> None:
677
+ global _installed
678
+ with _install_lock:
679
+ if _installed:
680
+ return
681
+ for name in ("completion", "acompletion", "embedding", "aembedding"):
682
+ _patch_litellm_function(name)
683
+ _installed = True
684
+
685
+
686
+ def uninstrument_litellm() -> None:
687
+ global _installed
688
+ with _install_lock:
689
+ while _ORIGINALS:
690
+ target, name, original = _ORIGINALS.pop()
691
+ current = getattr(target, name)
692
+ if getattr(current, _ORIGINAL_ATTR, None) is original:
693
+ setattr(target, name, original)
694
+ _installed = False
695
+
696
+
697
+ def _patch_router_method(router: object, name: str) -> None:
698
+ current = getattr(router, name)
699
+ if getattr(current, _WRAPPED_ATTR, False):
700
+ return
701
+ wrapped = _wrapper_for(name, current)
702
+ setattr(router, name, wrapped)
703
+
704
+
705
+ def wrap_router(router: _T) -> _T:
706
+ if getattr(router, _ROUTER_WRAPPED_ATTR, False):
707
+ return router
708
+ for name in ("completion", "acompletion", "embedding", "aembedding"):
709
+ if hasattr(router, name):
710
+ _patch_router_method(cast(object, router), name)
711
+ setattr(router, _ROUTER_WRAPPED_ATTR, True)
712
+ return router
713
+
714
+
715
+ __all__ = [
716
+ "__version__",
717
+ "acompletion",
718
+ "aembedding",
719
+ "completion",
720
+ "embedding",
721
+ "instrument_litellm",
722
+ "uninstrument_litellm",
723
+ "wrap_router",
724
+ ]