lmnr 0.4.37__py3-none-any.whl → 0.4.45__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/sdk/laminar.py CHANGED
@@ -16,8 +16,10 @@ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExport
16
16
  from opentelemetry.util.types import AttributeValue
17
17
 
18
18
  from pydantic.alias_generators import to_snake
19
- from typing import Any, Literal, Optional, Set, Union
19
+ from typing import Any, Awaitable, Literal, Optional, Set, Union
20
20
 
21
+ import aiohttp
22
+ import asyncio
21
23
  import copy
22
24
  import datetime
23
25
  import dotenv
@@ -25,8 +27,8 @@ import json
25
27
  import logging
26
28
  import os
27
29
  import random
28
- import re
29
30
  import requests
31
+ import re
30
32
  import urllib.parse
31
33
  import uuid
32
34
 
@@ -36,7 +38,6 @@ from lmnr.openllmetry_sdk.tracing.attributes import (
36
38
  SPAN_OUTPUT,
37
39
  SPAN_PATH,
38
40
  TRACE_TYPE,
39
- USER_ID,
40
41
  )
41
42
  from lmnr.openllmetry_sdk.tracing.tracing import (
42
43
  get_span_path,
@@ -55,7 +56,10 @@ from .types import (
55
56
  PipelineRunResponse,
56
57
  NodeInput,
57
58
  PipelineRunRequest,
59
+ SemanticSearchRequest,
60
+ SemanticSearchResponse,
58
61
  TraceType,
62
+ TracingLevel,
59
63
  )
60
64
 
61
65
 
@@ -65,7 +69,6 @@ class Laminar:
65
69
  __project_api_key: Optional[str] = None
66
70
  __env: dict[str, str] = {}
67
71
  __initialized: bool = False
68
- __http_session: Optional[requests.Session] = None
69
72
 
70
73
  @classmethod
71
74
  def initialize(
@@ -130,7 +133,6 @@ class Laminar:
130
133
  cls.__env = env
131
134
  cls.__initialized = True
132
135
  cls._initialize_logger()
133
- cls.__http_session = requests.Session()
134
136
  Traceloop.init(
135
137
  exporter=OTLPSpanExporter(
136
138
  endpoint=cls.__base_grpc_url,
@@ -165,8 +167,9 @@ class Laminar:
165
167
  metadata: dict[str, str] = {},
166
168
  parent_span_id: Optional[uuid.UUID] = None,
167
169
  trace_id: Optional[uuid.UUID] = None,
168
- ) -> PipelineRunResponse:
169
- """Runs the pipeline with the given inputs
170
+ ) -> Union[PipelineRunResponse, Awaitable[PipelineRunResponse]]:
171
+ """Runs the pipeline with the given inputs. If called from an async
172
+ function, must be awaited.
170
173
 
171
174
  Args:
172
175
  pipeline (str): name of the Laminar pipeline.\
@@ -216,34 +219,47 @@ class Laminar:
216
219
  parent_span_id=parent_span_id,
217
220
  trace_id=trace_id,
218
221
  )
222
+ loop = asyncio.get_event_loop()
223
+ if loop.is_running():
224
+ return cls.__run(request)
225
+ else:
226
+ return asyncio.run(cls.__run(request))
219
227
  except Exception as e:
220
228
  raise ValueError(f"Invalid request: {e}")
221
229
 
222
- response = (
223
- cls.__http_session.post(
224
- cls.__base_http_url + "/v1/pipeline/run",
225
- data=json.dumps(request.to_dict()),
226
- headers=cls._headers(),
227
- )
228
- if cls.__http_session
229
- else requests.post(
230
- cls.__base_http_url + "/v1/pipeline/run",
231
- data=json.dumps(request.to_dict()),
232
- headers=cls._headers(),
233
- )
230
+ @classmethod
231
+ def semantic_search(
232
+ cls,
233
+ query: str,
234
+ dataset_id: uuid.UUID,
235
+ limit: Optional[int] = None,
236
+ threshold: Optional[float] = None,
237
+ ) -> SemanticSearchResponse:
238
+ """Perform a semantic search on a dataset. If called from an async
239
+ function, must be awaited.
240
+
241
+ Args:
242
+ query (str): query string to search by
243
+ dataset_id (uuid.UUID): id of the dataset to search in
244
+ limit (Optional[int], optional): maximum number of results to\
245
+ return. Defaults to None.
246
+ threshold (Optional[float], optional): minimum score for a result\
247
+ to be returned. Defaults to None.
248
+
249
+ Returns:
250
+ SemanticSearchResponse: response object containing the search results sorted by score in descending order
251
+ """
252
+ request = SemanticSearchRequest(
253
+ query=query,
254
+ dataset_id=dataset_id,
255
+ limit=limit,
256
+ threshold=threshold,
234
257
  )
235
- if response.status_code != 200:
236
- raise PipelineRunError(response)
237
- try:
238
- resp_json = response.json()
239
- keys = list(resp_json.keys())
240
- for key in keys:
241
- value = resp_json[key]
242
- del resp_json[key]
243
- resp_json[to_snake(key)] = value
244
- return PipelineRunResponse(**resp_json)
245
- except Exception:
246
- raise PipelineRunError(response)
258
+ loop = asyncio.get_event_loop()
259
+ if loop.is_running():
260
+ return cls.__semantic_search(request)
261
+ else:
262
+ return asyncio.run(cls.__semantic_search(request))
247
263
 
248
264
  @classmethod
249
265
  def event(
@@ -330,6 +346,10 @@ class Laminar:
330
346
  span. Defaults to None.
331
347
  """
332
348
 
349
+ if not cls.is_initialized():
350
+ yield
351
+ return
352
+
333
353
  with get_tracer() as tracer:
334
354
  span_path = get_span_path(name)
335
355
  ctx = set_value("span_path", span_path, context)
@@ -539,6 +559,39 @@ class Laminar:
539
559
  if output is not None and span != trace.INVALID_SPAN:
540
560
  span.set_attribute(SPAN_OUTPUT, json_dumps(output))
541
561
 
562
+ @classmethod
563
+ @contextmanager
564
+ def set_tracing_level(self, level: TracingLevel):
565
+ """Set the tracing level for the current span and the context
566
+ (i.e. any children spans created from the current span in the current
567
+ thread).
568
+
569
+ Tracing level can be one of:
570
+ - `TracingLevel.ALL`: Enable tracing for the current span and all
571
+ children spans.
572
+ - `TracingLevel.META_ONLY`: Enable tracing for the current span and all
573
+ children spans, but only record metadata, e.g. tokens, costs.
574
+ - `TracingLevel.OFF`: Disable recording any spans.
575
+
576
+ Example:
577
+ ```python
578
+ from lmnr import Laminar, TracingLevel
579
+
580
+ with Laminar.set_tracing_level(TracingLevel.META_ONLY):
581
+ openai_client.chat.completions.create()
582
+ ```
583
+ """
584
+ if level == TracingLevel.ALL:
585
+ yield
586
+ else:
587
+ level = "meta_only" if level == TracingLevel.META_ONLY else "off"
588
+ update_association_properties({"tracing_level": level})
589
+ yield
590
+ try:
591
+ remove_association_properties({"tracing_level": level})
592
+ except Exception:
593
+ pass
594
+
542
595
  @classmethod
543
596
  def set_span_attributes(
544
597
  cls,
@@ -588,7 +641,6 @@ class Laminar:
588
641
  def set_session(
589
642
  cls,
590
643
  session_id: Optional[str] = None,
591
- user_id: Optional[str] = None,
592
644
  ):
593
645
  """Set the session and user id for the current span and the context
594
646
  (i.e. any children spans created from the current span in the current
@@ -599,29 +651,18 @@ class Laminar:
599
651
  Useful to debug and group long-running\
600
652
  sessions/conversations.
601
653
  Defaults to None.
602
- user_id (Optional[str], optional). Deprecated.\
603
- Use `Laminar.set_metadata` instead.\
604
- Custom user id.\
605
- Useful for grouping spans or traces by user.\
606
- Defaults to None.
607
654
  """
608
655
  association_properties = {}
609
656
  if session_id is not None:
610
657
  association_properties[SESSION_ID] = session_id
611
- if user_id is not None:
612
- cls.__logger.warning(
613
- "User ID in set_session is deprecated and will be removed soon. "
614
- "Please use `Laminar.set_metadata` instead."
615
- )
616
- association_properties["metadata." + USER_ID] = user_id
617
658
  update_association_properties(association_properties)
618
659
 
619
660
  @classmethod
620
- def set_metadata(cls, metadata: dict[str, Any]):
661
+ def set_metadata(cls, metadata: dict[str, str]):
621
662
  """Set the metadata for the current trace.
622
663
 
623
664
  Args:
624
- metadata (dict[str, Any]): Metadata to set for the trace. Willl be\
665
+ metadata (dict[str, str]): Metadata to set for the trace. Willl be\
625
666
  sent as attributes, so must be json serializable.
626
667
  """
627
668
  props = {f"metadata.{k}": json_dumps(v) for k, v in metadata.items()}
@@ -636,20 +677,6 @@ class Laminar:
636
677
  props.pop(k)
637
678
  set_association_properties(props)
638
679
 
639
- @classmethod
640
- def _set_trace_type(
641
- cls,
642
- trace_type: TraceType,
643
- ):
644
- """Set the trace_type for the current span and the context
645
- Args:
646
- trace_type (TraceType): Type of the trace
647
- """
648
- association_properties = {
649
- TRACE_TYPE: trace_type.value,
650
- }
651
- update_association_properties(association_properties)
652
-
653
680
  @classmethod
654
681
  def clear_session(cls):
655
682
  """Clear the session and user id from the context"""
@@ -659,30 +686,33 @@ class Laminar:
659
686
  set_association_properties(props)
660
687
 
661
688
  @classmethod
662
- def create_evaluation(
689
+ async def create_evaluation(
663
690
  cls,
664
691
  data: list[EvaluationResultDatapoint],
665
692
  group_id: Optional[str] = None,
666
693
  name: Optional[str] = None,
667
694
  ) -> CreateEvaluationResponse:
668
- response = requests.post(
669
- cls.__base_http_url + "/v1/evaluations",
670
- data=json.dumps(
671
- {
695
+ async with aiohttp.ClientSession() as session:
696
+ async with session.post(
697
+ cls.__base_http_url + "/v1/evaluations",
698
+ json={
672
699
  "groupId": group_id,
673
700
  "name": name,
674
701
  "points": [datapoint.to_dict() for datapoint in data],
675
- }
676
- ),
677
- headers=cls._headers(),
678
- )
679
- if response.status_code != 200:
680
- try:
681
- resp_json = response.json()
682
- raise ValueError(f"Error creating evaluation {json.dumps(resp_json)}")
683
- except requests.exceptions.RequestException:
684
- raise ValueError(f"Error creating evaluation {response.text}")
685
- return CreateEvaluationResponse.model_validate(response.json())
702
+ },
703
+ headers=cls._headers(),
704
+ ) as response:
705
+ if response.status != 200:
706
+ try:
707
+ resp_json = await response.json()
708
+ raise ValueError(
709
+ f"Error creating evaluation {json.dumps(resp_json)}"
710
+ )
711
+ except aiohttp.ClientError:
712
+ text = await response.text()
713
+ raise ValueError(f"Error creating evaluation {text}")
714
+ resp_json = await response.json()
715
+ return CreateEvaluationResponse.model_validate(resp_json)
686
716
 
687
717
  @classmethod
688
718
  def get_datapoints(
@@ -691,6 +721,10 @@ class Laminar:
691
721
  offset: int,
692
722
  limit: int,
693
723
  ) -> GetDatapointsResponse:
724
+ # TODO: Use aiohttp. Currently, this function is called from within
725
+ # `LaminarDataset.__len__`, which is sync, but can be called from
726
+ # both sync and async. Python does not make it easy to mix things this
727
+ # way, so we should probably refactor `LaminarDataset`.
694
728
  params = {"name": dataset_name, "offset": offset, "limit": limit}
695
729
  url = (
696
730
  cls.__base_http_url
@@ -717,3 +751,66 @@ class Laminar:
717
751
  "Authorization": "Bearer " + cls.__project_api_key,
718
752
  "Content-Type": "application/json",
719
753
  }
754
+
755
+ @classmethod
756
+ def _set_trace_type(
757
+ cls,
758
+ trace_type: TraceType,
759
+ ):
760
+ """Set the trace_type for the current span and the context
761
+ Args:
762
+ trace_type (TraceType): Type of the trace
763
+ """
764
+ association_properties = {
765
+ TRACE_TYPE: trace_type.value,
766
+ }
767
+ update_association_properties(association_properties)
768
+
769
+ @classmethod
770
+ async def __run(
771
+ cls,
772
+ request: PipelineRunRequest,
773
+ ) -> PipelineRunResponse:
774
+ async with aiohttp.ClientSession() as session:
775
+ async with session.post(
776
+ cls.__base_http_url + "/v1/pipeline/run",
777
+ data=json.dumps(request.to_dict()),
778
+ headers=cls._headers(),
779
+ ) as response:
780
+ if response.status != 200:
781
+ raise PipelineRunError(response)
782
+ try:
783
+ resp_json = await response.json()
784
+ keys = list(resp_json.keys())
785
+ for key in keys:
786
+ value = resp_json[key]
787
+ del resp_json[key]
788
+ resp_json[to_snake(key)] = value
789
+ return PipelineRunResponse(**resp_json)
790
+ except Exception:
791
+ raise PipelineRunError(response)
792
+
793
+ @classmethod
794
+ async def __semantic_search(
795
+ cls,
796
+ request: SemanticSearchRequest,
797
+ ) -> SemanticSearchResponse:
798
+ async with aiohttp.ClientSession() as session:
799
+ async with session.post(
800
+ cls.__base_http_url + "/v1/semantic-search",
801
+ data=json.dumps(request.to_dict()),
802
+ headers=cls._headers(),
803
+ ) as response:
804
+ if response.status != 200:
805
+ raise ValueError(
806
+ f"Error performing semantic search: [{response.status}] {response.text}"
807
+ )
808
+ try:
809
+ resp_json = await response.json()
810
+ for result in resp_json["results"]:
811
+ result["dataset_id"] = uuid.UUID(result["datasetId"])
812
+ return SemanticSearchResponse(**resp_json)
813
+ except Exception as e:
814
+ raise ValueError(
815
+ f"Error parsing semantic search response: status={response.status} error={e}"
816
+ )
lmnr/sdk/types.py CHANGED
@@ -1,7 +1,7 @@
1
+ import aiohttp
1
2
  import datetime
2
3
  from enum import Enum
3
4
  import pydantic
4
- import requests
5
5
  from typing import Any, Awaitable, Callable, Optional, Union
6
6
  import uuid
7
7
 
@@ -55,11 +55,40 @@ class PipelineRunResponse(pydantic.BaseModel):
55
55
  run_id: str
56
56
 
57
57
 
58
+ class SemanticSearchRequest(pydantic.BaseModel):
59
+ query: str
60
+ dataset_id: uuid.UUID
61
+ limit: Optional[int] = pydantic.Field(default=None)
62
+ threshold: Optional[float] = pydantic.Field(default=None, ge=0.0, le=1.0)
63
+
64
+ def to_dict(self):
65
+ res = {
66
+ "query": self.query,
67
+ "datasetId": str(self.dataset_id),
68
+ }
69
+ if self.limit is not None:
70
+ res["limit"] = self.limit
71
+ if self.threshold is not None:
72
+ res["threshold"] = self.threshold
73
+ return res
74
+
75
+
76
+ class SemanticSearchResult(pydantic.BaseModel):
77
+ dataset_id: uuid.UUID
78
+ score: float
79
+ data: dict[str, Any]
80
+ content: str
81
+
82
+
83
+ class SemanticSearchResponse(pydantic.BaseModel):
84
+ results: list[SemanticSearchResult]
85
+
86
+
58
87
  class PipelineRunError(Exception):
59
88
  error_code: str
60
89
  error_message: str
61
90
 
62
- def __init__(self, response: requests.Response):
91
+ def __init__(self, response: aiohttp.ClientResponse):
63
92
  try:
64
93
  resp_json = response.json()
65
94
  self.error_code = resp_json["error_code"]
@@ -95,7 +124,7 @@ ExecutorFunctionReturnType = Any
95
124
  EvaluatorFunctionReturnType = Union[Numeric, dict[str, Numeric]]
96
125
 
97
126
  ExecutorFunction = Callable[
98
- [EvaluationDatapointData, Any, dict[str, Any]],
127
+ [EvaluationDatapointData, Any],
99
128
  Union[ExecutorFunctionReturnType, Awaitable[ExecutorFunctionReturnType]],
100
129
  ]
101
130
 
@@ -104,7 +133,7 @@ ExecutorFunction = Callable[
104
133
  # record of string keys and number values. The latter is useful for evaluating
105
134
  # multiple criteria in one go instead of running multiple evaluators.
106
135
  EvaluatorFunction = Callable[
107
- [ExecutorFunctionReturnType, Any, dict[str, Any]],
136
+ [ExecutorFunctionReturnType, Any],
108
137
  Union[EvaluatorFunctionReturnType, Awaitable[EvaluatorFunctionReturnType]],
109
138
  ]
110
139
 
@@ -174,3 +203,9 @@ class TraceType(Enum):
174
203
  class GetDatapointsResponse(pydantic.BaseModel):
175
204
  items: list[Datapoint]
176
205
  totalCount: int
206
+
207
+
208
+ class TracingLevel(Enum):
209
+ OFF = 0
210
+ META_ONLY = 1
211
+ ALL = 2