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
|
+
]
|
|
File without changes
|