lmnr 0.3.7__py3-none-any.whl → 0.4.1__py3-none-any.whl

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.
lmnr/__init__.py CHANGED
@@ -1,7 +1,4 @@
1
- from .sdk.client import Laminar
2
- from .sdk.decorators import observe, lmnr_context, wrap_llm_call
3
- from .sdk.interface import trace, TraceContext, SpanContext, initialize
4
- from .sdk.tracing_types import EvaluateEvent
1
+ from .sdk.evaluations import Evaluation
2
+ from .sdk.laminar import Laminar
5
3
  from .sdk.types import ChatMessage, PipelineRunError, PipelineRunResponse, NodeInput
6
-
7
- from .semantic_conventions import *
4
+ from .sdk.decorators import observe
lmnr/sdk/decorators.py CHANGED
@@ -1,284 +1,72 @@
1
- import datetime
2
- import functools
3
- from typing import Any, Callable, Literal, Optional, Union
4
-
5
-
6
- from .context import LaminarSingleton
7
- from .providers.fallback import FallbackProvider
8
- from ..semantic_conventions.gen_ai_spans import PROVIDER
9
- from .types import NodeInput, PipelineRunResponse
10
- from .utils import (
11
- PROVIDER_NAME_TO_OBJECT,
12
- get_input_from_func_args,
13
- is_async,
14
- is_method,
1
+ from traceloop.sdk.decorators.base import (
2
+ entity_method,
3
+ aentity_method,
15
4
  )
5
+ from opentelemetry.trace import INVALID_SPAN, get_current_span
6
+ from traceloop.sdk import Traceloop
16
7
 
8
+ from typing import Callable, Optional, ParamSpec, TypeVar, cast
17
9
 
18
- class LaminarDecorator:
19
- def observe(
20
- self,
21
- *,
22
- name: Optional[str] = None,
23
- span_type: Optional[Literal["DEFAULT", "LLM"]] = "DEFAULT",
24
- capture_input: bool = True,
25
- capture_output: bool = True,
26
- release: Optional[str] = None,
27
- user_id: Optional[str] = None,
28
- session_id: Optional[str] = None,
29
- ):
30
- """The main decorator entrypoint for Laminar. This is used to wrap functions and methods to create spans.
31
-
32
- Args:
33
- name (Optional[str], optional): Name of the span. Function name is used if not specified. Defaults to None.
34
- span_type (Literal["DEFAULT", "LLM"], optional): Type of this span. Prefer `wrap_llm_call` instead of specifying
35
- this as "LLM" . Defaults to "DEFAULT".
36
- capture_input (bool, optional): Whether to capture input parameters to the function. Defaults to True.
37
- capture_output (bool, optional): Whether to capture returned type from the function. Defaults to True.
38
- release (Optional[str], optional): Release version of your app. Useful for further grouping and analytics. Defaults to None.
39
- user_id (Optional[str], optional): Custom user_id of your user. Useful for grouping and further analytics. Defaults to None.
40
- session_id (Optional[str], optional): Custom session_id for your session. Random UUID is generated on Laminar side, if not specified.
41
- Defaults to None.
42
-
43
- Raises:
44
- Exception: re-raises the exception if the wrapped function raises an exception
45
-
46
- Returns:
47
- Any: Returns the result of the wrapped function
48
- """
49
- context_manager = LaminarSingleton().get()
50
-
51
- def decorator(func: Callable):
52
- @functools.wraps(func)
53
- def wrapper(*args, **kwargs):
54
- span = context_manager.observe_start(
55
- name=name or func.__name__,
56
- span_type=span_type,
57
- input=(
58
- get_input_from_func_args(func, is_method(func), args, kwargs)
59
- if capture_input
60
- else None
61
- ),
62
- user_id=user_id,
63
- session_id=session_id,
64
- release=release,
65
- )
66
- try:
67
- result = func(*args, **kwargs)
68
- except Exception as e:
69
- context_manager.observe_end(result=None, span=span, error=e)
70
- raise e
71
- context_manager.observe_end(
72
- result=result if capture_output else None, span=span
73
- )
74
- return result
75
-
76
- @functools.wraps(func)
77
- async def async_wrapper(*args, **kwargs):
78
- span = context_manager.observe_start(
79
- name=name or func.__name__,
80
- span_type=span_type,
81
- input=(
82
- get_input_from_func_args(func, is_method(func), args, kwargs)
83
- if capture_input
84
- else None
85
- ),
86
- user_id=user_id,
87
- session_id=session_id,
88
- release=release,
89
- )
90
- try:
91
- result = await func(*args, **kwargs)
92
- except Exception as e:
93
- context_manager.observe_end(result=None, span=span, error=e)
94
- raise e
95
- context_manager.observe_end(
96
- result=result if capture_output else None, span=span
97
- )
98
- return result
99
-
100
- return async_wrapper if is_async(func) else wrapper
101
-
102
- return decorator
103
-
104
- def update_current_span(
105
- self,
106
- metadata: Optional[dict[str, Any]] = None,
107
- attributes: Optional[dict[str, Any]] = None,
108
- override: bool = False,
109
- ):
110
- """Update the current span with any optional metadata.
111
-
112
- Args:
113
- metadata (Optional[dict[str, Any]], optional): metadata to the span. Defaults to None.
114
- override (bool, optional): Whether to override the existing metadata. If False, metadata is merged with the existing metadata. Defaults to False.
115
- """
116
- laminar = LaminarSingleton().get()
117
- laminar.update_current_span(
118
- metadata=metadata, attributes=attributes, override=override
119
- )
120
-
121
- def update_current_trace(
122
- self,
123
- user_id: Optional[str] = None,
124
- session_id: Optional[str] = None,
125
- release: Optional[str] = None,
126
- metadata: Optional[dict[str, Any]] = None,
127
- ):
128
- """Update the current trace with any optional metadata.
129
-
130
- Args:
131
- user_id (Optional[str], optional): Custom user_id of your user. Useful for grouping and further analytics. Defaults to None.
132
- session_id (Optional[str], optional): Custom session_id for your session. Random UUID is generated on Laminar side, if not specified.
133
- Defaults to None.
134
- release (Optional[str], optional): Release version of your app. Useful for further grouping and analytics. Defaults to None.
135
- metadata (Optional[dict[str, Any]], optional): metadata to the trace. Defaults to None.
136
- """
137
- laminar = LaminarSingleton().get()
138
- laminar.update_current_trace(
139
- user_id=user_id, session_id=session_id, release=release, metadata=metadata
140
- )
141
-
142
- def event(
143
- self,
144
- name: str,
145
- value: Optional[Union[str, int, float, bool]] = None,
146
- timestamp: Optional[datetime.datetime] = None,
147
- ):
148
- """Associate an event with the current span
149
-
150
- Args:
151
- name (str): name of the event. Must be predefined in the Laminar events page.
152
- value (Optional[Union[str, int, float, bool]], optional): value of the event. Must match range definition in Laminar events page. Defaults to None.
153
- timestamp (Optional[datetime.datetime], optional): If you need custom timestamp. If not specified, current time is used. Defaults to None.
154
- """
155
- laminar = LaminarSingleton().get()
156
- laminar.event(name, value=value, timestamp=timestamp)
157
-
158
- def evaluate_event(self, name: str, evaluator: str, data: dict):
159
- """Evaluate an event with the given name by evaluator based on the given data.
160
- Evaluator is the Laminar pipeline name.
161
- Data is passed as an input to the the evaluator pipeline, so you need to specify which data you want to evaluate. The prompt
162
- of the evaluator will be templated with the keys of the data dictionary.
163
-
164
- Usually, you would want to pass the output of LLM generation, users' messages, and some other surrounding data to 'data'.
165
-
166
- Args:
167
- name (str): Name of the event.
168
- evaluator (str): Name of the evaluator pipeline.
169
- data (dict): Data to be used when evaluating the event.
170
- """
171
- laminar = LaminarSingleton().get()
172
- laminar.evaluate_event(name, evaluator=evaluator, data=data)
10
+ from .laminar import Laminar as L
11
+ from .utils import is_async
173
12
 
174
- def run(
175
- self,
176
- pipeline: str,
177
- inputs: dict[str, NodeInput],
178
- env: dict[str, str] = {},
179
- metadata: dict[str, str] = {},
180
- ) -> PipelineRunResponse:
181
- """Run the laminar pipeline with the given inputs. Pipeline must be defined in the Laminar UI and have a target version.
13
+ P = ParamSpec("P")
14
+ R = TypeVar("R")
182
15
 
183
- Args:
184
- pipeline (str): pipeline name
185
- inputs (dict[str, NodeInput]): Map from input node name to input value
186
- env (dict[str, str], optional): Environment variables for the pipeline executions. Typically contains API keys. Defaults to None.
187
- metadata (dict[str, str], optional): Any additional data to associate with the resulting span. Defaults to None.
188
16
 
189
- Returns:
190
- PipelineRunResponse: Response from the pipeline execution
191
- """
192
- laminar = LaminarSingleton().get()
193
- return laminar.run_pipeline(pipeline, inputs, env, metadata)
194
-
195
-
196
- def wrap_llm_call(func: Callable, name: str = None, provider: str = None) -> Callable:
197
- """Wrap an LLM call with Laminar observability. This is a convenience function that does the same as `@observe()`, plus
198
- a few utilities around LLM-specific things, such as counting tokens and recording model params.
199
-
200
- Example usage:
201
- ```python
202
- wrap_llm_call(client.chat.completions.create)(
203
- model="gpt-4o-mini",
204
- messages=[
205
- {"role": "system", "content": "You are a helpful assistant."},
206
- {"role": "user", "content": "Hello"},
207
- ],
208
- stream=True,
209
- )
210
- ```
17
+ def observe(
18
+ *,
19
+ name: Optional[str] = None,
20
+ user_id: Optional[str] = None,
21
+ session_id: Optional[str] = None,
22
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
23
+ """The main decorator entrypoint for Laminar. This is used to wrap
24
+ functions and methods to create spans.
211
25
 
212
26
  Args:
213
- func (Callable): The function to wrap
214
- name (str, optional): Name of the resulting span. Default "{provider name} completion" if not specified. Defaults to None.
215
- provider (str, optional): LLM model provider, e.g. openai, anthropic. This is needed to help us correctly parse
216
- things like token usage. If not specified, we infer it from the name of the package,
217
- where the function is imported from. Defaults to None.
27
+ name (Optional[str], optional): Name of the span. Function
28
+ name is used if not specified.
29
+ Defaults to None.
30
+ user_id (Optional[str], optional): User ID to associate
31
+ with the span and the following context.
32
+ Defaults to None.
33
+ session_id (Optional[str], optional): Session ID to associate with the
34
+ span and the following context. Defaults to None.
218
35
 
219
36
  Raises:
220
- Exctption: re-raises the exception if the wrapped function raises an exception
37
+ Exception: re-raises the exception if the wrapped function raises
38
+ an exception
221
39
 
222
40
  Returns:
223
- Callable: the wrapped function
41
+ R: Returns the result of the wrapped function
224
42
  """
225
- laminar = LaminarSingleton().get()
226
- # Simple heuristic to determine the package from where the LLM call is imported.
227
- # This works for major providers, but will likely make no sense for custom providers.
228
- provider_name = (
229
- provider.lower().strip() if provider else func.__module__.split(".")[0]
230
- )
231
- provider_module = PROVIDER_NAME_TO_OBJECT.get(provider_name, FallbackProvider())
232
- name = name or f"{provider_module.display_name()} completion"
233
43
 
234
- @functools.wraps(func)
235
- def wrapper(*args, **kwargs):
236
- inp = kwargs.get("messages")
237
- attributes = (
238
- provider_module.extract_llm_attributes_from_args(args, kwargs)
239
- if provider_module
240
- else {}
241
- )
242
- attributes[PROVIDER] = provider_name
243
- span = laminar.observe_start(
244
- name=name, span_type="LLM", input=inp, attributes=attributes
245
- )
246
- try:
247
- result = func(*args, **kwargs)
248
- except Exception as e:
249
- laminar.observe_end(
250
- result=None, span=span, error=e, provider_name=provider_name
44
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
45
+ if not L.is_initialized():
46
+ raise Exception(
47
+ "Laminar is not initialized. Please "
48
+ + "call Laminar.initialize() first."
251
49
  )
252
- raise e
253
- return laminar.observe_end(
254
- result=result, span=span, provider_name=provider_name
255
- )
256
-
257
- @functools.wraps(func)
258
- async def async_wrapper(*args, **kwargs):
259
- inp = kwargs.get("messages")
260
- attributes = (
261
- provider_module.extract_llm_attributes_from_args(args, kwargs)
262
- if provider_module
263
- else {}
264
- )
265
- attributes[PROVIDER] = provider_name
266
- span = laminar.observe_start(
267
- name=name, span_type="LLM", input=inp, attributes=attributes
268
- )
269
- try:
270
- result = await func(*args, **kwargs)
271
- except Exception as e:
272
- laminar.observe_end(
273
- result=None, span=span, error=e, provider_name=provider_name
274
- )
275
- raise e
276
- return laminar.observe_end(
277
- result=result, span=span, provider_name=provider_name
50
+ current_span = get_current_span()
51
+ if current_span != INVALID_SPAN:
52
+ if session_id is not None:
53
+ current_span.set_attribute(
54
+ "traceloop.association.properties.session_id", session_id
55
+ )
56
+ if user_id is not None:
57
+ current_span.set_attribute(
58
+ "traceloop.association.properties.user_id", user_id
59
+ )
60
+ association_properties = {}
61
+ if session_id is not None:
62
+ association_properties["session_id"] = session_id
63
+ if user_id is not None:
64
+ association_properties["user_id"] = user_id
65
+ Traceloop.set_association_properties(association_properties)
66
+ return (
67
+ aentity_method(name=name)(func)
68
+ if is_async(func)
69
+ else entity_method(name=name)(func)
278
70
  )
279
71
 
280
- return async_wrapper if is_async(func) else wrapper
281
-
282
-
283
- lmnr_context = LaminarDecorator()
284
- observe = lmnr_context.observe
72
+ return cast(Callable[P, R], decorator)
@@ -0,0 +1,163 @@
1
+ from typing import Union
2
+
3
+ from .utils import is_async
4
+ from .types import EvaluatorFunction, ExecutorFunction, EvaluationDatapoint, Numeric
5
+ from .laminar import Laminar as L
6
+ import asyncio
7
+
8
+ from abc import ABC, abstractmethod
9
+
10
+ DEFAULT_BATCH_SIZE = 5
11
+
12
+
13
+ class EvaluationDataset(ABC):
14
+ @abstractmethod
15
+ def __init__(self, *args, **kwargs):
16
+ pass
17
+
18
+ @abstractmethod
19
+ def __len__(self) -> int:
20
+ pass
21
+
22
+ @abstractmethod
23
+ def __getitem__(self, idx) -> EvaluationDatapoint:
24
+ pass
25
+
26
+ def slice(self, start: int, end: int):
27
+ return [self[i] for i in range(max(start, 0), min(end, len(self)))]
28
+
29
+
30
+ class Evaluation:
31
+ def __init__(
32
+ self,
33
+ name,
34
+ data: Union[EvaluationDataset, list[Union[EvaluationDatapoint, dict]]],
35
+ executor: ExecutorFunction,
36
+ evaluators: list[EvaluatorFunction],
37
+ batch_size: int = DEFAULT_BATCH_SIZE,
38
+ project_api_key: str = "",
39
+ base_url: str = "https://api.lmnr.ai",
40
+ ):
41
+ """
42
+ Initializes an instance of the Evaluations class.
43
+ Parameters:
44
+ name (str): The name of the evaluation.
45
+ data (Union[List[Union[EvaluationDatapoint, dict]], EvaluationDataset]): List of data points to evaluate or an evaluation dataset.
46
+ `data` is the input to the executor function,
47
+ `target` is the input to the evaluator function.
48
+ executor (Callable[..., Any]): The executor function.
49
+ Takes the data point + any additional arguments
50
+ and returns the output to evaluate.
51
+ evaluators (List[Callable[..., Any]]): List of evaluator functions.
52
+ Each evaluator function takes the output of the executor _and_
53
+ the target data, and returns a score. The score can be a
54
+ single number or a record of string keys and number values.
55
+ If the score is a single number, it will be named after the
56
+ evaluator function. If the function is anonymous, it will be
57
+ named `evaluator_${index}`, where index is the index of the
58
+ evaluator function in the list starting from 1.
59
+ batch_size (int, optional): The batch size for evaluation.
60
+ Defaults to DEFAULT_BATCH_SIZE.
61
+ project_api_key (str, optional): The project API key.
62
+ Defaults to an empty string.
63
+ base_url (str, optional): The base URL for the LMNR API.
64
+ Useful if self-hosted elsewhere.
65
+ Defaults to "https://api.lmnr.ai".
66
+ """
67
+
68
+ self.name = name
69
+ self.executor = executor
70
+ self.evaluators = dict(
71
+ zip(
72
+ [
73
+ (
74
+ e.__name__
75
+ if e.__name__ and e.__name__ != "<lambda>"
76
+ else f"evaluator_{i+1}"
77
+ )
78
+ for i, e in enumerate(evaluators)
79
+ ],
80
+ evaluators,
81
+ )
82
+ )
83
+ self.evaluator_names = list(self.evaluators.keys())
84
+ if isinstance(data, list):
85
+ self.data = [
86
+ (
87
+ EvaluationDatapoint.model_validate(point)
88
+ if isinstance(point, dict)
89
+ else point
90
+ )
91
+ for point in data
92
+ ]
93
+ else:
94
+ self.data = data
95
+ self.batch_size = batch_size
96
+ L.initialize(project_api_key=project_api_key, base_url=base_url)
97
+
98
+ async def run(self):
99
+ """Runs the evaluation.
100
+
101
+ Creates a new evaluation if no evaluation with such name exists, or
102
+ adds data to an existing one otherwise. Evaluates data points in
103
+ batches of `self.batch_size`. The executor
104
+ function is called on each data point to get the output,
105
+ and then evaluate it by each evaluator function.
106
+ """
107
+ response = L.create_evaluation(self.name)
108
+ batch_promises = []
109
+
110
+ for i in range(0, len(self.data), self.batch_size):
111
+ batch = (
112
+ self.data[i : i + self.batch_size]
113
+ if isinstance(self.data, list)
114
+ else self.data.slice(i, i + self.batch_size)
115
+ )
116
+ batch_promises.append(self._evaluate_batch(batch))
117
+
118
+ try:
119
+ await asyncio.gather(*batch_promises)
120
+ L.update_evaluation_status(response.name, "Finished")
121
+ print(f"Evaluation {response.id} complete")
122
+ except Exception as e:
123
+ print(f"Error evaluating batch: {e}")
124
+
125
+ async def _evaluate_batch(self, batch: list[EvaluationDatapoint]):
126
+ results = []
127
+ for datapoint in batch:
128
+ output = (
129
+ await self.executor(datapoint.data)
130
+ if is_async(self.executor)
131
+ else self.executor(datapoint.data)
132
+ )
133
+ target = datapoint.target
134
+
135
+ # iterate in order of evaluators
136
+ scores = {}
137
+ for evaluator_name in self.evaluator_names:
138
+ evaluator = self.evaluators[evaluator_name]
139
+ value = (
140
+ await evaluator(output, target)
141
+ if is_async(evaluator)
142
+ else evaluator(output, target)
143
+ )
144
+
145
+ # if the evaluator returns a single number,
146
+ # use the evaluator name as the key
147
+ if isinstance(value, Numeric):
148
+ scores[evaluator_name] = value
149
+ else:
150
+ # if the evaluator returns an object,
151
+ # use the object keys as the keys
152
+ scores.update(value)
153
+
154
+ results.append(
155
+ {
156
+ "executorOutput": output,
157
+ "data": datapoint.data,
158
+ "target": target,
159
+ "scores": scores,
160
+ }
161
+ )
162
+
163
+ return L.post_evaluation_results(self.name, results)