payi 0.1.0a68__py3-none-any.whl → 0.1.0a70__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.

Potentially problematic release.


This version of payi might be problematic. Click here for more details.

payi/lib/instrument.py CHANGED
@@ -1,9 +1,11 @@
1
+ import os
1
2
  import json
2
3
  import uuid
3
4
  import asyncio
4
5
  import inspect
5
6
  import logging
6
7
  import traceback
8
+ from abc import abstractmethod
7
9
  from enum import Enum
8
10
  from typing import Any, Set, Union, Callable, Optional, TypedDict
9
11
  from datetime import datetime, timezone
@@ -23,19 +25,52 @@ from .Stopwatch import Stopwatch
23
25
 
24
26
 
25
27
  class _ProviderRequest:
26
- def __init__(self, instrumentor: '_PayiInstrumentor'):
28
+ def __init__(self, instrumentor: '_PayiInstrumentor', category: str):
27
29
  self._instrumentor: '_PayiInstrumentor' = instrumentor
28
30
  self._estimated_prompt_tokens: Optional[int] = None
29
- self._ingest: IngestUnitsParams
30
-
31
- def process_request(self, _kwargs: Any) -> None:
32
- return
31
+ self._category: str = category
32
+ self._ingest: IngestUnitsParams = { "category": category, "units": {} } # type: ignore
33
33
 
34
34
  def process_chunk(self, _chunk: Any) -> bool:
35
35
  return True
36
36
 
37
37
  def process_synchronous_response(self, response: Any, log_prompt_and_response: bool, kwargs: Any) -> Optional[object]: # noqa: ARG002
38
38
  return None
39
+
40
+ @abstractmethod
41
+ def process_request(self, instance: Any, extra_headers: 'dict[str, str]', kwargs: Any) -> bool:
42
+ ...
43
+
44
+ def is_bedrock(self) -> bool:
45
+ return self._category == PayiCategories.aws_bedrock
46
+
47
+ def process_exception(self, exception: Exception, kwargs: Any, ) -> bool: # noqa: ARG002
48
+ return False
49
+
50
+ def exception_to_semantic_failure(self, e: Exception) -> None:
51
+ exception_str = f"{type(e).__name__}"
52
+
53
+ fields: list[str] = []
54
+ # fields += f"args: {e.args}"
55
+
56
+ for attr in dir(e):
57
+ if not attr.startswith("__"):
58
+ try:
59
+ value = getattr(e, attr)
60
+ if value and not inspect.ismethod(value) and not inspect.isfunction(value) and not callable(value):
61
+ fields.append(f"{attr}={value}")
62
+ except Exception as _ex:
63
+ pass
64
+
65
+ properties: 'dict[str,str]' = { "system.failure": exception_str }
66
+ if fields:
67
+ failure_description = ",".join(fields)
68
+ properties["system.failure.description"] = failure_description[:128]
69
+ self._ingest["properties"] = properties
70
+
71
+ if "http_status_code" not in self._ingest:
72
+ # use a non existent http status code so when presented to the user, the origin is clear
73
+ self._ingest["http_status_code"] = 299
39
74
 
40
75
  class PayiInstrumentConfig(TypedDict, total=False):
41
76
  proxy: bool
@@ -49,7 +84,7 @@ class PayiInstrumentConfig(TypedDict, total=False):
49
84
  user_id: Optional[str]
50
85
 
51
86
  class _Context(TypedDict, total=False):
52
- proxy: bool
87
+ proxy: Optional[bool]
53
88
  experience_name: Optional[str]
54
89
  experience_id: Optional[str]
55
90
  use_case_name: Optional[str]
@@ -57,12 +92,34 @@ class _Context(TypedDict, total=False):
57
92
  use_case_version: Optional[int]
58
93
  limit_ids: Optional['list[str]']
59
94
  user_id: Optional[str]
95
+ request_tags: Optional["list[str]"]
96
+ route_as_resource: Optional[str]
97
+ resource_scope: Optional[str]
60
98
 
61
99
  class _IsStreaming(Enum):
62
100
  false = 0
63
101
  true = 1
64
102
  kwargs = 2
65
103
 
104
+ class _TrackContext:
105
+ def __init__(
106
+ self,
107
+ context: _Context,
108
+ ) -> None:
109
+ self._context = context
110
+
111
+ def __enter__(self) -> Any:
112
+ if not _instrumentor:
113
+ return self
114
+
115
+ _instrumentor.__enter__()
116
+ _instrumentor._init_current_context(**self._context)
117
+ return self
118
+
119
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
120
+ if _instrumentor:
121
+ _instrumentor.__exit__(exc_type, exc_val, exc_tb)
122
+
66
123
  class _PayiInstrumentor:
67
124
  def __init__(
68
125
  self,
@@ -73,7 +130,8 @@ class _PayiInstrumentor:
73
130
  prompt_and_response_logger: Optional[
74
131
  Callable[[str, "dict[str, str]"], None]
75
132
  ] = None, # (request id, dict of data to store) -> None
76
- global_config: Optional[PayiInstrumentConfig] = None,
133
+ global_config: PayiInstrumentConfig = {},
134
+ caller_filename: str = ""
77
135
  ):
78
136
  self._payi: Optional[Payi] = payi
79
137
  self._apayi: Optional[AsyncPayi] = apayi
@@ -85,28 +143,38 @@ class _PayiInstrumentor:
85
143
  self._blocked_limits: set[str] = set()
86
144
  self._exceeded_limits: set[str] = set()
87
145
 
146
+ # default is instrument and ingest metrics
147
+ self._proxy_default: bool = global_config.get("proxy", False)
148
+
149
+ global_instrumentation = global_config.pop("global_instrumentation", True)
150
+
88
151
  if instruments is None or "*" in instruments:
89
152
  self._instrument_all()
90
153
  else:
91
154
  self._instrument_specific(instruments)
92
155
 
93
- global_instrumentation = global_config.pop("global_instrumentation", True) if global_config else True
94
-
95
156
  if global_instrumentation:
96
- if global_config is None:
97
- global_config = {}
98
157
  if "proxy" not in global_config:
99
- global_config["proxy"] = False
158
+ global_config["proxy"] = self._proxy_default
100
159
 
101
160
  # Use default clients if not provided for global ingest instrumentation
102
- if not self._payi and not self._apayi and global_config.get("proxy") == False:
161
+ if not self._payi and not self._apayi and global_config.get("proxy") is False:
103
162
  self._payi = Payi()
104
163
  self._apayi = AsyncPayi()
105
164
 
106
- context: _Context = {}
107
- self._context_stack.append(context)
108
- # init_context will update the currrent context stack location
109
- self._init_context(context=context, parentContext={}, **global_config) # type: ignore
165
+ if "use_case_name" not in global_config and caller_filename:
166
+ try:
167
+ if self._payi:
168
+ self._payi.use_cases.definitions.create(name=caller_filename, description='')
169
+ elif self._apayi:
170
+ self._call_async_use_case_definition_create(use_case_name=caller_filename)
171
+ global_config["use_case_name"] = caller_filename
172
+ except Exception as e:
173
+ logging.error(f"Error creating default use case definition based on file name {caller_filename}: {e}")
174
+
175
+ self.__enter__()
176
+ # _init_current_context will update the currrent context stack location
177
+ self._init_current_context(**global_config) # type: ignore
110
178
 
111
179
  def _instrument_all(self) -> None:
112
180
  self._instrument_openai()
@@ -152,9 +220,7 @@ class _PayiInstrumentor:
152
220
  if int(ingest_units.get("http_status_code") or 0) < 400:
153
221
  units = ingest_units.get("units", {})
154
222
  if not units or all(unit.get("input", 0) == 0 and unit.get("output", 0) == 0 for unit in units.values()):
155
- logging.error(
156
- 'No units to ingest. For OpenAI streaming calls, make sure you pass stream_options={"include_usage": True}'
157
- )
223
+ logging.error('No units to ingest')
158
224
  return False
159
225
 
160
226
  if self._log_prompt_and_response and self._prompt_and_response_logger:
@@ -219,6 +285,25 @@ class _PayiInstrumentor:
219
285
 
220
286
  return None
221
287
 
288
+ def _call_async_use_case_definition_create(self, use_case_name: str) -> None:
289
+ if not self._apayi:
290
+ return
291
+
292
+ try:
293
+ loop = asyncio.get_running_loop()
294
+ except RuntimeError:
295
+ loop = None
296
+
297
+ try:
298
+ if loop and loop.is_running():
299
+ nest_asyncio.apply(loop) # type: ignore
300
+ asyncio.run(self._apayi.use_cases.definitions.create(name=use_case_name, description=""))
301
+ else:
302
+ # When there's no running loop, create a new one
303
+ asyncio.run(self._apayi.use_cases.definitions.create(name=use_case_name, description=""))
304
+ except Exception as e:
305
+ logging.error(f"Error calling async use_cases.definitions.create synchronously: {e}")
306
+
222
307
  def _call_aingest_sync(self, ingest_units: IngestUnitsParams) -> Optional[IngestResponse]:
223
308
  try:
224
309
  loop = asyncio.get_running_loop()
@@ -268,23 +353,17 @@ class _PayiInstrumentor:
268
353
 
269
354
  def _setup_call_func(
270
355
  self
271
- ) -> 'tuple[_Context, _Context]':
272
- context: _Context = {}
273
- parentContext: _Context = {}
356
+ ) -> _Context:
274
357
 
275
358
  if len(self._context_stack) > 0:
276
359
  # copy current context into the upcoming context
277
- context = self._context_stack[-1].copy()
278
- context.pop("proxy")
279
- parentContext = {**context}
360
+ return self._context_stack[-1].copy()
280
361
 
281
- return (context, parentContext)
362
+ return {}
282
363
 
283
- def _init_context(
364
+ def _init_current_context(
284
365
  self,
285
- context: _Context,
286
- parentContext: _Context,
287
- proxy: bool,
366
+ proxy: Optional[bool] = None,
288
367
  limit_ids: Optional["list[str]"] = None,
289
368
  experience_name: Optional[str] = None,
290
369
  experience_id: Optional[str] = None,
@@ -292,11 +371,20 @@ class _PayiInstrumentor:
292
371
  use_case_id: Optional[str]= None,
293
372
  use_case_version: Optional[int]= None,
294
373
  user_id: Optional[str]= None,
374
+ request_tags: Optional["list[str]"] = None,
375
+ route_as_resource: Optional[str] = None,
376
+ resource_scope: Optional[str] = None,
295
377
  ) -> None:
296
- context["proxy"] = proxy
297
378
 
298
- parent_experience_name = parentContext.get("experience_name", None)
299
- parent_experience_id = parentContext.get("experience_id", None)
379
+ # there will always be a current context
380
+ context: _Context = self.get_context() # type: ignore
381
+ parent_context: _Context = self._context_stack[-2] if len(self._context_stack) > 1 else {}
382
+
383
+ parent_proxy = parent_context.get("proxy", self._proxy_default)
384
+ context["proxy"] = proxy if proxy else parent_proxy
385
+
386
+ parent_experience_name = parent_context.get("experience_name", None)
387
+ parent_experience_id = parent_context.get("experience_id", None)
300
388
 
301
389
  if experience_name is None:
302
390
  # If no experience_name specified, use previous values
@@ -317,9 +405,9 @@ class _PayiInstrumentor:
317
405
  context["experience_name"] = experience_name
318
406
  context["experience_id"] = experience_id if experience_id else str(uuid.uuid4())
319
407
 
320
- parent_use_case_name = parentContext.get("use_case_name", None)
321
- parent_use_case_id = parentContext.get("use_case_id", None)
322
- parent_use_case_version = parentContext.get("use_case_version", None)
408
+ parent_use_case_name = parent_context.get("use_case_name", None)
409
+ parent_use_case_id = parent_context.get("use_case_id", None)
410
+ parent_use_case_version = parent_context.get("use_case_version", None)
323
411
 
324
412
  if use_case_name is None:
325
413
  # If no use_case_name specified, use previous values
@@ -343,7 +431,7 @@ class _PayiInstrumentor:
343
431
  context["use_case_id"] = use_case_id if use_case_id else str(uuid.uuid4())
344
432
  context["use_case_version"] = use_case_version if use_case_version else None
345
433
 
346
- parent_limit_ids = parentContext.get("limit_ids", None)
434
+ parent_limit_ids = parent_context.get("limit_ids", None)
347
435
  if limit_ids is None:
348
436
  # use the parent limit_ids if it exists
349
437
  context["limit_ids"] = parent_limit_ids
@@ -354,21 +442,27 @@ class _PayiInstrumentor:
354
442
  # union of new and parent lists if the parent context contains limit ids
355
443
  context["limit_ids"] = list(set(limit_ids) | set(parent_limit_ids)) if parent_limit_ids else limit_ids
356
444
 
445
+ parent_user_id = parent_context.get("user_id", None)
357
446
  if user_id is None:
358
447
  # use the parent user_id if it exists
359
- context["user_id"] = parentContext.get("user_id", None)
448
+ context["user_id"] = parent_user_id
360
449
  elif len(user_id) == 0:
361
450
  # caller passing an empty string explicitly blocks inheriting from the parent state
362
451
  context["user_id"] = None
363
452
  else:
364
453
  context["user_id"] = user_id
365
454
 
366
- self.set_context(context)
455
+ if request_tags:
456
+ context["request_tags"] = request_tags
457
+ if route_as_resource:
458
+ context["route_as_resource"] = route_as_resource
459
+ if resource_scope:
460
+ context["resource_scope"] = resource_scope
367
461
 
368
462
  async def _acall_func(
369
463
  self,
370
464
  func: Any,
371
- proxy: bool,
465
+ proxy: Optional[bool],
372
466
  limit_ids: Optional["list[str]"],
373
467
  experience_name: Optional[str],
374
468
  experience_id: Optional[str],
@@ -379,12 +473,8 @@ class _PayiInstrumentor:
379
473
  *args: Any,
380
474
  **kwargs: Any,
381
475
  ) -> Any:
382
- context, parentContext = self._setup_call_func()
383
-
384
476
  with self:
385
- self._init_context(
386
- context,
387
- parentContext,
477
+ self._init_current_context(
388
478
  proxy,
389
479
  limit_ids,
390
480
  experience_name,
@@ -398,7 +488,7 @@ class _PayiInstrumentor:
398
488
  def _call_func(
399
489
  self,
400
490
  func: Any,
401
- proxy: bool,
491
+ proxy: Optional[bool],
402
492
  limit_ids: Optional["list[str]"],
403
493
  experience_name: Optional[str],
404
494
  experience_id: Optional[str],
@@ -409,12 +499,8 @@ class _PayiInstrumentor:
409
499
  *args: Any,
410
500
  **kwargs: Any,
411
501
  ) -> Any:
412
- context, parentContext = self._setup_call_func()
413
-
414
502
  with self:
415
- self._init_context(
416
- context,
417
- parentContext,
503
+ self._init_current_context(
418
504
  proxy,
419
505
  limit_ids,
420
506
  experience_name,
@@ -435,15 +521,14 @@ class _PayiInstrumentor:
435
521
  if self._context_stack:
436
522
  self._context_stack.pop()
437
523
 
438
- def set_context(self, context: _Context) -> None:
439
- # Update the current top of the stack with the provided context
440
- if self._context_stack:
441
- self._context_stack[-1].update(context)
442
-
443
524
  def get_context(self) -> Optional[_Context]:
444
525
  # Return the current top of the stack
445
526
  return self._context_stack[-1] if self._context_stack else None
446
527
 
528
+ def get_context_safe(self) -> _Context:
529
+ # Return the current top of the stack
530
+ return self.get_context() or {}
531
+
447
532
  def _prepare_ingest(
448
533
  self,
449
534
  ingest: IngestUnitsParams,
@@ -499,8 +584,7 @@ class _PayiInstrumentor:
499
584
 
500
585
  async def achat_wrapper(
501
586
  self,
502
- category: str,
503
- provider: _ProviderRequest,
587
+ request: _ProviderRequest,
504
588
  is_streaming: _IsStreaming,
505
589
  wrapped: Any,
506
590
  instance: Any,
@@ -519,45 +603,20 @@ class _PayiInstrumentor:
519
603
  extra_headers = kwargs.get("extra_headers", {})
520
604
  self._update_extra_headers(context, extra_headers)
521
605
 
522
- if context.get("proxy", True):
523
- if "extra_headers" not in kwargs:
606
+ if context.get("proxy", self._proxy_default):
607
+ if "extra_headers" not in kwargs and extra_headers:
524
608
  kwargs["extra_headers"] = extra_headers
525
609
 
526
610
  return await wrapped(*args, **kwargs)
527
611
 
528
- provider._ingest = {"category": category, "units": {}} # type: ignore
529
- provider._ingest["resource"] = kwargs.get("model", "")
530
-
531
- if category == PayiCategories.openai and instance and hasattr(instance, "_client"):
532
- from .OpenAIInstrumentor import OpenAiInstrumentor # noqa: I001
533
-
534
- if OpenAiInstrumentor.is_azure(instance):
535
- route_as_resource = extra_headers.pop(PayiHeaderNames.route_as_resource, None)
536
- resource_scope = extra_headers.pop(PayiHeaderNames.resource_scope, None)
537
-
538
- if not route_as_resource:
539
- logging.error("Azure OpenAI route as resource not found, not ingesting")
540
- return await wrapped(*args, **kwargs)
541
-
542
- if resource_scope:
543
- if not(resource_scope in ["global", "datazone"] or resource_scope.startswith("region")):
544
- logging.error("Azure OpenAI invalid resource scope, not ingesting")
545
- return wrapped(*args, **kwargs)
546
-
547
- provider._ingest["resource_scope"] = resource_scope
548
-
549
- category = PayiCategories.azure_openai
550
-
551
- provider._ingest["category"] = category
552
- provider._ingest["resource"] = route_as_resource
553
-
554
612
  current_frame = inspect.currentframe()
555
613
  # f_back excludes the current frame, strip() cleans up whitespace and newlines
556
614
  stack = [frame.strip() for frame in traceback.format_stack(current_frame.f_back)] # type: ignore
557
615
 
558
- provider._ingest['properties'] = { 'system.stack_trace': json.dumps(stack) }
616
+ request._ingest['properties'] = { 'system.stack_trace': json.dumps(stack) }
559
617
 
560
- provider.process_request(kwargs)
618
+ if request.process_request(instance, extra_headers, kwargs) is False:
619
+ return await wrapped(*args, **kwargs)
561
620
 
562
621
  sw = Stopwatch()
563
622
  stream: bool = False
@@ -570,7 +629,7 @@ class _PayiInstrumentor:
570
629
  stream = False
571
630
 
572
631
  try:
573
- self._prepare_ingest(provider._ingest, extra_headers, kwargs)
632
+ self._prepare_ingest(request._ingest, extra_headers, kwargs)
574
633
  sw.start()
575
634
  response = await wrapped(*args, **kwargs)
576
635
 
@@ -578,7 +637,9 @@ class _PayiInstrumentor:
578
637
  sw.stop()
579
638
  duration = sw.elapsed_ms_int()
580
639
 
581
- # TODO ingest error
640
+ if request.process_exception(e, kwargs):
641
+ request._ingest["end_to_end_latency_ms"] = duration
642
+ await self._aingest_units(request._ingest)
582
643
 
583
644
  raise e
584
645
 
@@ -589,18 +650,17 @@ class _PayiInstrumentor:
589
650
  instrumentor=self,
590
651
  log_prompt_and_response=self._log_prompt_and_response,
591
652
  stopwatch=sw,
592
- provider=provider,
593
- is_bedrock=False,
653
+ request=request,
594
654
  )
595
655
 
596
656
  return stream_result
597
657
 
598
658
  sw.stop()
599
659
  duration = sw.elapsed_ms_int()
600
- provider._ingest["end_to_end_latency_ms"] = duration
601
- provider._ingest["http_status_code"] = 200
660
+ request._ingest["end_to_end_latency_ms"] = duration
661
+ request._ingest["http_status_code"] = 200
602
662
 
603
- return_result: Any = provider.process_synchronous_response(
663
+ return_result: Any = request.process_synchronous_response(
604
664
  response=response,
605
665
  log_prompt_and_response=self._log_prompt_and_response,
606
666
  kwargs=kwargs)
@@ -608,14 +668,13 @@ class _PayiInstrumentor:
608
668
  if return_result:
609
669
  return return_result
610
670
 
611
- await self._aingest_units(provider._ingest)
671
+ await self._aingest_units(request._ingest)
612
672
 
613
673
  return response
614
674
 
615
675
  def chat_wrapper(
616
676
  self,
617
- category: str,
618
- provider: _ProviderRequest,
677
+ request: _ProviderRequest,
619
678
  is_streaming: _IsStreaming,
620
679
  wrapped: Any,
621
680
  instance: Any,
@@ -624,10 +683,8 @@ class _PayiInstrumentor:
624
683
  ) -> Any:
625
684
  context = self.get_context()
626
685
 
627
- is_bedrock:bool = category == PayiCategories.aws_bedrock
628
-
629
686
  if not context:
630
- if is_bedrock:
687
+ if request.is_bedrock():
631
688
  # boto3 doesn't allow extra_headers
632
689
  kwargs.pop("extra_headers", None)
633
690
 
@@ -638,50 +695,24 @@ class _PayiInstrumentor:
638
695
  extra_headers = kwargs.get("extra_headers", {})
639
696
  self._update_extra_headers(context, extra_headers)
640
697
 
641
- if context.get("proxy", True):
642
- if "extra_headers" not in kwargs:
698
+ if context.get("proxy", self._proxy_default):
699
+ if request.is_bedrock():
700
+ # boto3 doesn't allow extra_headers
701
+ kwargs.pop("extra_headers", None)
702
+ elif "extra_headers" not in kwargs and extra_headers:
703
+ # assumes anthropic and openai clients
643
704
  kwargs["extra_headers"] = extra_headers
644
705
 
645
706
  return wrapped(*args, **kwargs)
646
707
 
647
- provider._ingest = {"category": category, "units": {}} # type: ignore
648
- if is_bedrock:
649
- # boto3 doesn't allow extra_headers
650
- kwargs.pop("extra_headers", None)
651
- provider._ingest["resource"] = kwargs.get("modelId", "")
652
- else:
653
- provider._ingest["resource"] = kwargs.get("model", "")
654
-
655
- if category == PayiCategories.openai and instance and hasattr(instance, "_client"):
656
- from .OpenAIInstrumentor import OpenAiInstrumentor # noqa: I001
657
-
658
- if OpenAiInstrumentor.is_azure(instance):
659
- route_as_resource:str = extra_headers.pop(PayiHeaderNames.route_as_resource, None)
660
- resource_scope:str = extra_headers.pop(PayiHeaderNames.resource_scope, None)
661
-
662
- if not route_as_resource:
663
- logging.error("Azure OpenAI route as resource not found, not ingesting")
664
- return wrapped(*args, **kwargs)
665
-
666
- if resource_scope:
667
- if not(resource_scope in ["global", "datazone"] or resource_scope.startswith("region")):
668
- logging.error("Azure OpenAI invalid resource scope, not ingesting")
669
- return wrapped(*args, **kwargs)
670
-
671
- provider._ingest["resource_scope"] = resource_scope
672
-
673
- category = PayiCategories.azure_openai
674
-
675
- provider._ingest["category"] = category
676
- provider._ingest["resource"] = route_as_resource
677
-
678
708
  current_frame = inspect.currentframe()
679
709
  # f_back excludes the current frame, strip() cleans up whitespace and newlines
680
710
  stack = [frame.strip() for frame in traceback.format_stack(current_frame.f_back)] # type: ignore
681
711
 
682
- provider._ingest['properties'] = { 'system.stack_trace': json.dumps(stack) }
712
+ request._ingest['properties'] = { 'system.stack_trace': json.dumps(stack) }
683
713
 
684
- provider.process_request(kwargs)
714
+ if request.process_request(instance, extra_headers, kwargs) is False:
715
+ return wrapped(*args, **kwargs)
685
716
 
686
717
  sw = Stopwatch()
687
718
  stream: bool = False
@@ -694,7 +725,7 @@ class _PayiInstrumentor:
694
725
  stream = False
695
726
 
696
727
  try:
697
- self._prepare_ingest(provider._ingest, extra_headers, kwargs)
728
+ self._prepare_ingest(request._ingest, extra_headers, kwargs)
698
729
  sw.start()
699
730
  response = wrapped(*args, **kwargs)
700
731
 
@@ -702,7 +733,9 @@ class _PayiInstrumentor:
702
733
  sw.stop()
703
734
  duration = sw.elapsed_ms_int()
704
735
 
705
- # TODO ingest error
736
+ if request.process_exception(e, kwargs):
737
+ request._ingest["end_to_end_latency_ms"] = duration
738
+ self._ingest_units(request._ingest)
706
739
 
707
740
  raise e
708
741
 
@@ -713,11 +746,10 @@ class _PayiInstrumentor:
713
746
  instrumentor=self,
714
747
  log_prompt_and_response=self._log_prompt_and_response,
715
748
  stopwatch=sw,
716
- provider=provider,
717
- is_bedrock=is_bedrock,
749
+ request=request,
718
750
  )
719
751
 
720
- if is_bedrock:
752
+ if request.is_bedrock():
721
753
  if "body" in response:
722
754
  response["body"] = stream_result
723
755
  else:
@@ -728,20 +760,30 @@ class _PayiInstrumentor:
728
760
 
729
761
  sw.stop()
730
762
  duration = sw.elapsed_ms_int()
731
- provider._ingest["end_to_end_latency_ms"] = duration
732
- provider._ingest["http_status_code"] = 200
763
+ request._ingest["end_to_end_latency_ms"] = duration
764
+ request._ingest["http_status_code"] = 200
733
765
 
734
- return_result: Any = provider.process_synchronous_response(
766
+ return_result: Any = request.process_synchronous_response(
735
767
  response=response,
736
768
  log_prompt_and_response=self._log_prompt_and_response,
737
769
  kwargs=kwargs)
738
770
  if return_result:
739
771
  return return_result
740
772
 
741
- self._ingest_units(provider._ingest)
773
+ self._ingest_units(request._ingest)
742
774
 
743
775
  return response
744
776
 
777
+ def _create_extra_headers(
778
+ self
779
+ ) -> 'dict[str, str]':
780
+ extra_headers: dict[str, str] = {}
781
+ context = self.get_context()
782
+ if context:
783
+ self._update_extra_headers(context, extra_headers)
784
+
785
+ return extra_headers
786
+
745
787
  @staticmethod
746
788
  def _update_extra_headers(
747
789
  context: _Context,
@@ -754,6 +796,9 @@ class _PayiInstrumentor:
754
796
  context_use_case_id: Optional[str] = context.get("use_case_id")
755
797
  context_use_case_version: Optional[int] = context.get("use_case_version")
756
798
  context_user_id: Optional[str] = context.get("user_id")
799
+ context_request_tags: Optional[list[str]] = context.get("request_tags")
800
+ context_route_as_resource: Optional[str] = context.get("route_as_resource")
801
+ context_resource_scope: Optional[str] = context.get("resource_scope")
757
802
 
758
803
  # headers_limit_ids = extra_headers.get(PayiHeaderNames.limit_ids, None)
759
804
 
@@ -812,6 +857,15 @@ class _PayiInstrumentor:
812
857
  if context_experience_id is not None:
813
858
  extra_headers[PayiHeaderNames.experience_id] = context_experience_id
814
859
 
860
+ if PayiHeaderNames.request_tags not in extra_headers and context_request_tags:
861
+ extra_headers[PayiHeaderNames.request_tags] = ",".join(context_request_tags)
862
+
863
+ if PayiHeaderNames.route_as_resource not in extra_headers and context_route_as_resource:
864
+ extra_headers[PayiHeaderNames.route_as_resource] = context_route_as_resource
865
+
866
+ if PayiHeaderNames.resource_scope not in extra_headers and context_resource_scope:
867
+ extra_headers[PayiHeaderNames.resource_scope] = context_resource_scope
868
+
815
869
  @staticmethod
816
870
  def update_for_vision(input: int, units: 'dict[str, Units]', estimated_prompt_tokens: Optional[int]) -> int:
817
871
  if estimated_prompt_tokens:
@@ -861,14 +915,13 @@ class ChatStreamWrapper(ObjectProxy): # type: ignore
861
915
  instance: Any,
862
916
  instrumentor: _PayiInstrumentor,
863
917
  stopwatch: Stopwatch,
864
- provider: _ProviderRequest,
918
+ request: _ProviderRequest,
865
919
  log_prompt_and_response: bool = True,
866
- is_bedrock: bool = False,
867
920
  ) -> None:
868
921
 
869
922
  bedrock_from_stream: bool = False
870
- if is_bedrock:
871
- provider._ingest["provider_response_id"] = response["ResponseMetadata"]["RequestId"]
923
+ if request.is_bedrock():
924
+ request._ingest["provider_response_id"] = response["ResponseMetadata"]["RequestId"]
872
925
  stream = response.get("stream", None)
873
926
 
874
927
  if stream:
@@ -888,10 +941,10 @@ class ChatStreamWrapper(ObjectProxy): # type: ignore
888
941
  self._log_prompt_and_response: bool = log_prompt_and_response
889
942
  self._responses: list[str] = []
890
943
 
891
- self._provider: _ProviderRequest = provider
944
+ self._request: _ProviderRequest = request
892
945
 
893
946
  self._first_token: bool = True
894
- self._is_bedrock: bool = is_bedrock
947
+ self._is_bedrock: bool = request.is_bedrock()
895
948
  self._bedrock_from_stream: bool = bedrock_from_stream
896
949
 
897
950
  def __enter__(self) -> Any:
@@ -929,9 +982,9 @@ class ChatStreamWrapper(ObjectProxy): # type: ignore
929
982
  def __aiter__(self) -> Any:
930
983
  return self
931
984
 
932
- def __next__(self) -> Any:
985
+ def __next__(self) -> object:
933
986
  try:
934
- chunk: Any = self.__wrapped__.__next__() # type: ignore
987
+ chunk: object = self.__wrapped__.__next__() # type: ignore
935
988
  except Exception as e:
936
989
  if isinstance(e, StopIteration):
937
990
  self._stop_iteration()
@@ -940,11 +993,11 @@ class ChatStreamWrapper(ObjectProxy): # type: ignore
940
993
  if self._evaluate_chunk(chunk) == False:
941
994
  return self.__next__()
942
995
 
943
- return chunk
996
+ return chunk # type: ignore
944
997
 
945
- async def __anext__(self) -> Any:
998
+ async def __anext__(self) -> object:
946
999
  try:
947
- chunk: Any = await self.__wrapped__.__anext__() # type: ignore
1000
+ chunk: object = await self.__wrapped__.__anext__() # type: ignore
948
1001
  except Exception as e:
949
1002
  if isinstance(e, StopAsyncIteration):
950
1003
  await self._astop_iteration()
@@ -952,33 +1005,34 @@ class ChatStreamWrapper(ObjectProxy): # type: ignore
952
1005
  else:
953
1006
  if self._evaluate_chunk(chunk) == False:
954
1007
  return await self.__anext__()
955
- return chunk
1008
+
1009
+ return chunk # type: ignore
956
1010
 
957
1011
  def _evaluate_chunk(self, chunk: Any) -> bool:
958
1012
  if self._first_token:
959
- self._provider._ingest["time_to_first_token_ms"] = self._stopwatch.elapsed_ms_int()
1013
+ self._request._ingest["time_to_first_token_ms"] = self._stopwatch.elapsed_ms_int()
960
1014
  self._first_token = False
961
1015
 
962
1016
  if self._log_prompt_and_response:
963
1017
  self._responses.append(self.chunk_to_json(chunk))
964
1018
 
965
- return self._provider.process_chunk(chunk)
1019
+ return self._request.process_chunk(chunk)
966
1020
 
967
1021
  def _process_stop_iteration(self) -> None:
968
1022
  self._stopwatch.stop()
969
- self._provider._ingest["end_to_end_latency_ms"] = self._stopwatch.elapsed_ms_int()
970
- self._provider._ingest["http_status_code"] = 200
1023
+ self._request._ingest["end_to_end_latency_ms"] = self._stopwatch.elapsed_ms_int()
1024
+ self._request._ingest["http_status_code"] = 200
971
1025
 
972
1026
  if self._log_prompt_and_response:
973
- self._provider._ingest["provider_response_json"] = self._responses
1027
+ self._request._ingest["provider_response_json"] = self._responses
974
1028
 
975
1029
  async def _astop_iteration(self) -> None:
976
1030
  self._process_stop_iteration()
977
- await self._instrumentor._aingest_units(self._provider._ingest)
1031
+ await self._instrumentor._aingest_units(self._request._ingest)
978
1032
 
979
1033
  def _stop_iteration(self) -> None:
980
1034
  self._process_stop_iteration()
981
- self._instrumentor._ingest_units(self._provider._ingest)
1035
+ self._instrumentor._ingest_units(self._request._ingest)
982
1036
 
983
1037
  @staticmethod
984
1038
  def chunk_to_json(chunk: Any) -> str:
@@ -1019,7 +1073,11 @@ def payi_instrument(
1019
1073
  payi_param = p
1020
1074
  elif isinstance(p, AsyncPayi): # type: ignore
1021
1075
  apayi_param = p
1022
-
1076
+ frameinfo = inspect.stack()[1]
1077
+ caller_filename = os.path.basename(frameinfo.filename).replace(' ', '_').lower()
1078
+ if caller_filename.endswith('.py'):
1079
+ caller_filename = caller_filename[:-3]
1080
+
1023
1081
  # allow for both payi and apayi to be None for the @proxy case
1024
1082
  _instrumentor = _PayiInstrumentor(
1025
1083
  payi=payi_param,
@@ -1027,9 +1085,92 @@ def payi_instrument(
1027
1085
  instruments=instruments,
1028
1086
  log_prompt_and_response=log_prompt_and_response,
1029
1087
  prompt_and_response_logger=prompt_and_response_logger,
1030
- global_config=config,
1088
+ global_config=config if config else PayiInstrumentConfig(),
1089
+ caller_filename=caller_filename
1031
1090
  )
1032
1091
 
1092
+ def track(
1093
+ limit_ids: Optional["list[str]"] = None,
1094
+ use_case_name: Optional[str] = None,
1095
+ use_case_id: Optional[str] = None,
1096
+ use_case_version: Optional[int] = None,
1097
+ user_id: Optional[str] = None,
1098
+ proxy: Optional[bool] = None,
1099
+ ) -> Any:
1100
+
1101
+ def _track(func: Any) -> Any:
1102
+ import asyncio
1103
+ if asyncio.iscoroutinefunction(func):
1104
+ async def awrapper(*args: Any, **kwargs: Any) -> Any:
1105
+ if not _instrumentor:
1106
+ return await func(*args, **kwargs)
1107
+ # Call the instrumentor's _call_func for async functions
1108
+ return await _instrumentor._acall_func(
1109
+ func,
1110
+ proxy,
1111
+ limit_ids,
1112
+ None, # experience_name,
1113
+ None, #experience_id,
1114
+ use_case_name,
1115
+ use_case_id,
1116
+ use_case_version,
1117
+ user_id,
1118
+ *args,
1119
+ **kwargs,
1120
+ )
1121
+ return awrapper
1122
+ else:
1123
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
1124
+ if not _instrumentor:
1125
+ return func(*args, **kwargs)
1126
+ return _instrumentor._call_func(
1127
+ func,
1128
+ proxy,
1129
+ limit_ids,
1130
+ None, # experience_name,
1131
+ None, # experience_id,
1132
+ use_case_name,
1133
+ use_case_id,
1134
+ use_case_version,
1135
+ user_id,
1136
+ *args,
1137
+ **kwargs,
1138
+ )
1139
+ return wrapper
1140
+ return _track
1141
+
1142
+ def track_context(
1143
+ limit_ids: Optional["list[str]"] = None,
1144
+ experience_name: Optional[str] = None,
1145
+ experience_id: Optional[str] = None,
1146
+ use_case_name: Optional[str] = None,
1147
+ use_case_id: Optional[str] = None,
1148
+ use_case_version: Optional[int] = None,
1149
+ user_id: Optional[str] = None,
1150
+ request_tags: Optional["list[str]"] = None,
1151
+ route_as_resource: Optional[str] = None,
1152
+ resource_scope: Optional[str] = None,
1153
+ proxy: Optional[bool] = None,
1154
+ ) -> _TrackContext:
1155
+ if not _instrumentor:
1156
+ raise RuntimeError("Pay-i instrumentor not initialized. Use payi_instrument() to initialize.")
1157
+
1158
+ # Create a new context for tracking
1159
+ context: _Context = {}
1160
+ context["limit_ids"] = limit_ids
1161
+ context["experience_name"] = experience_name
1162
+ context["experience_id"] = experience_id
1163
+ context["use_case_name"] = use_case_name
1164
+ context["use_case_id"] = use_case_id
1165
+ context["use_case_version"] = use_case_version
1166
+ context["user_id"] = user_id
1167
+ context["request_tags"] = request_tags
1168
+ context["route_as_resource"] = route_as_resource
1169
+ context["resource_scope"] = resource_scope
1170
+ context["proxy"] = proxy
1171
+
1172
+ return _TrackContext(context)
1173
+
1033
1174
  def ingest(
1034
1175
  limit_ids: Optional["list[str]"] = None,
1035
1176
  experience_name: Optional[str] = None,