payi 0.1.0a107__py3-none-any.whl → 0.1.0a137__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.
Files changed (83) hide show
  1. payi/__init__.py +3 -1
  2. payi/_base_client.py +12 -12
  3. payi/_client.py +8 -8
  4. payi/_compat.py +48 -48
  5. payi/_models.py +87 -59
  6. payi/_qs.py +7 -7
  7. payi/_streaming.py +4 -6
  8. payi/_types.py +53 -12
  9. payi/_utils/__init__.py +9 -2
  10. payi/_utils/_compat.py +45 -0
  11. payi/_utils/_datetime_parse.py +136 -0
  12. payi/_utils/_sync.py +3 -31
  13. payi/_utils/_transform.py +13 -3
  14. payi/_utils/_typing.py +6 -1
  15. payi/_utils/_utils.py +5 -6
  16. payi/_version.py +1 -1
  17. payi/lib/AnthropicInstrumentor.py +83 -57
  18. payi/lib/BedrockInstrumentor.py +292 -57
  19. payi/lib/GoogleGenAiInstrumentor.py +18 -31
  20. payi/lib/OpenAIInstrumentor.py +56 -72
  21. payi/lib/ProviderRequest.py +216 -0
  22. payi/lib/StreamWrappers.py +379 -0
  23. payi/lib/VertexInstrumentor.py +18 -37
  24. payi/lib/VertexRequest.py +16 -2
  25. payi/lib/data/cohere_embed_english_v3.json +30706 -0
  26. payi/lib/helpers.py +62 -5
  27. payi/lib/instrument.py +433 -659
  28. payi/resources/categories/__init__.py +0 -14
  29. payi/resources/categories/categories.py +25 -53
  30. payi/resources/categories/resources.py +27 -23
  31. payi/resources/ingest.py +126 -132
  32. payi/resources/limits/__init__.py +14 -14
  33. payi/resources/limits/limits.py +58 -58
  34. payi/resources/limits/properties.py +171 -0
  35. payi/resources/requests/request_id/properties.py +8 -8
  36. payi/resources/requests/request_id/result.py +3 -3
  37. payi/resources/requests/response_id/properties.py +8 -8
  38. payi/resources/requests/response_id/result.py +3 -3
  39. payi/resources/use_cases/definitions/definitions.py +27 -27
  40. payi/resources/use_cases/definitions/kpis.py +23 -23
  41. payi/resources/use_cases/definitions/limit_config.py +14 -14
  42. payi/resources/use_cases/definitions/version.py +3 -3
  43. payi/resources/use_cases/kpis.py +15 -15
  44. payi/resources/use_cases/properties.py +6 -6
  45. payi/resources/use_cases/use_cases.py +7 -7
  46. payi/types/__init__.py +2 -0
  47. payi/types/bulk_ingest_response.py +3 -20
  48. payi/types/categories/__init__.py +0 -1
  49. payi/types/categories/resource_list_params.py +5 -1
  50. payi/types/category_list_resources_params.py +5 -1
  51. payi/types/category_resource_response.py +31 -1
  52. payi/types/ingest_event_param.py +7 -6
  53. payi/types/ingest_units_params.py +5 -4
  54. payi/types/limit_create_params.py +3 -3
  55. payi/types/limit_list_response.py +1 -3
  56. payi/types/limit_response.py +1 -3
  57. payi/types/limits/__init__.py +2 -9
  58. payi/types/limits/{tag_remove_params.py → property_update_params.py} +4 -5
  59. payi/types/limits/{tag_delete_response.py → property_update_response.py} +3 -3
  60. payi/types/requests/request_id/property_update_params.py +2 -2
  61. payi/types/requests/response_id/property_update_params.py +2 -2
  62. payi/types/shared/__init__.py +2 -0
  63. payi/types/shared/api_error.py +18 -0
  64. payi/types/shared/pay_i_common_models_budget_management_create_limit_base.py +3 -3
  65. payi/types/shared/properties_request.py +11 -0
  66. payi/types/shared/xproxy_result.py +2 -0
  67. payi/types/shared_params/pay_i_common_models_budget_management_create_limit_base.py +3 -3
  68. payi/types/use_cases/definitions/limit_config_create_params.py +3 -3
  69. payi/types/use_cases/property_update_params.py +2 -2
  70. {payi-0.1.0a107.dist-info → payi-0.1.0a137.dist-info}/METADATA +6 -6
  71. {payi-0.1.0a107.dist-info → payi-0.1.0a137.dist-info}/RECORD +73 -75
  72. payi/resources/categories/fixed_cost_resources.py +0 -196
  73. payi/resources/limits/tags.py +0 -507
  74. payi/types/categories/fixed_cost_resource_create_params.py +0 -21
  75. payi/types/limits/limit_tags.py +0 -16
  76. payi/types/limits/tag_create_params.py +0 -13
  77. payi/types/limits/tag_create_response.py +0 -10
  78. payi/types/limits/tag_list_response.py +0 -10
  79. payi/types/limits/tag_remove_response.py +0 -10
  80. payi/types/limits/tag_update_params.py +0 -13
  81. payi/types/limits/tag_update_response.py +0 -10
  82. {payi-0.1.0a107.dist-info → payi-0.1.0a137.dist-info}/WHEEL +0 -0
  83. {payi-0.1.0a107.dist-info → payi-0.1.0a137.dist-info}/licenses/LICENSE +0 -0
payi/lib/instrument.py CHANGED
@@ -1,150 +1,57 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
4
+ import copy
2
5
  import json
3
6
  import time
4
7
  import uuid
8
+ import atexit
5
9
  import asyncio
6
10
  import inspect
7
11
  import logging
12
+ import threading
8
13
  import traceback
9
- from abc import abstractmethod
10
14
  from enum import Enum
11
- from typing import Any, Set, Union, Callable, Optional, Sequence, TypedDict
15
+ from typing import Any, Set, Union, Optional, Sequence, TypedDict, cast
12
16
  from datetime import datetime, timezone
13
- from dataclasses import dataclass
14
17
 
15
18
  import nest_asyncio # type: ignore
16
- from wrapt import ObjectProxy # type: ignore
19
+ from wrapt import wrap_function_wrapper # type: ignore
17
20
 
18
21
  from payi import Payi, AsyncPayi, APIStatusError, APIConnectionError, __version__ as _payi_version
19
22
  from payi.types import IngestUnitsParams
20
- from payi.lib.helpers import PayiHeaderNames
23
+ from payi.lib.helpers import PayiHeaderNames, _compact_json
21
24
  from payi.types.shared import XproxyResult
22
25
  from payi.types.ingest_response import IngestResponse
23
- from payi.types.ingest_units_params import Units, ProviderResponseFunctionCall
24
26
  from payi.types.shared.xproxy_error import XproxyError
25
27
  from payi.types.pay_i_common_models_api_router_header_info_param import PayICommonModelsAPIRouterHeaderInfoParam
26
28
 
27
29
  from .helpers import PayiCategories
28
30
  from .Stopwatch import Stopwatch
31
+ from .StreamWrappers import _GeneratorWrapper, _StreamManagerWrapper, _StreamIteratorWrapper
32
+ from .ProviderRequest import PriceAs, _StreamingType, _ProviderRequest
29
33
 
30
34
  global _g_logger
31
35
  _g_logger: logging.Logger = logging.getLogger("payi.instrument")
32
36
 
33
- @dataclass
34
- class _ChunkResult:
35
- send_chunk_to_caller: bool
36
- ingest: bool = False
37
-
38
- class _ProviderRequest:
39
- def __init__(
40
- self,
41
- instrumentor: '_PayiInstrumentor',
42
- category: str,
43
- streaming_type: '_StreamingType',
44
- module_name: str,
45
- module_version: str,
46
- is_aws_client: Optional[bool] = None,
47
- is_google_vertex_or_genai_client: Optional[bool] = None,
48
- ) -> None:
49
- self._instrumentor: '_PayiInstrumentor' = instrumentor
50
- self._module_name: str = module_name
51
- self._module_version: str = module_version
52
- self._estimated_prompt_tokens: Optional[int] = None
53
- self._category: str = category
54
- self._ingest: IngestUnitsParams = { "category": category, "units": {} } # type: ignore
55
- self._streaming_type: '_StreamingType' = streaming_type
56
- self._is_aws_client: Optional[bool] = is_aws_client
57
- self._is_google_vertex_or_genai_client: Optional[bool] = is_google_vertex_or_genai_client
58
- self._function_call_builder: Optional[dict[int, ProviderResponseFunctionCall]] = None
59
- self._building_function_response: bool = False
60
- self._function_calls: Optional[list[ProviderResponseFunctionCall]] = None
61
-
62
- def process_chunk(self, _chunk: Any) -> _ChunkResult:
63
- return _ChunkResult(send_chunk_to_caller=True)
64
-
65
- def process_synchronous_response(self, response: Any, log_prompt_and_response: bool, kwargs: Any) -> Optional[object]: # noqa: ARG002
66
- return None
67
-
68
- @abstractmethod
69
- def process_request(self, instance: Any, extra_headers: 'dict[str, str]', args: Sequence[Any], kwargs: Any) -> bool:
70
- ...
71
-
72
- def process_request_prompt(self, prompt: 'dict[str, Any]', args: Sequence[Any], kwargs: 'dict[str, Any]') -> None:
73
- ...
74
-
75
- def process_initial_stream_response(self, response: Any) -> None:
76
- pass
77
-
78
- def remove_inline_data(self, prompt: 'dict[str, Any]') -> bool:# noqa: ARG002
79
- return False
80
-
81
- @property
82
- def is_aws_client(self) -> bool:
83
- return self._is_aws_client if self._is_aws_client is not None else False
84
-
85
- @property
86
- def is_google_vertex_or_genai_client(self) -> bool:
87
- return self._is_google_vertex_or_genai_client if self._is_google_vertex_or_genai_client is not None else False
88
-
89
- def process_exception(self, exception: Exception, kwargs: Any, ) -> bool: # noqa: ARG002
90
- self.exception_to_semantic_failure(exception)
91
- return True
92
-
93
- @property
94
- def supports_extra_headers(self) -> bool:
95
- return not self.is_aws_client and not self.is_google_vertex_or_genai_client
96
-
97
- @property
98
- def streaming_type(self) -> '_StreamingType':
99
- return self._streaming_type
100
-
101
- def exception_to_semantic_failure(self, e: Exception) -> None:
102
- exception_str = f"{type(e).__name__}"
103
-
104
- fields: list[str] = []
105
-
106
- for attr in dir(e):
107
- if not attr.startswith("__"):
108
- try:
109
- value = getattr(e, attr)
110
- if value and not inspect.ismethod(value) and not inspect.isfunction(value) and not callable(value):
111
- fields.append(f"{attr}={value}")
112
- except Exception as _ex:
113
- pass
114
-
115
- existing_properties = self._ingest.get("properties", None)
116
- if not existing_properties:
117
- existing_properties = {}
118
-
119
- existing_properties['system.failure'] = exception_str
120
- if fields:
121
- failure_description = ",".join(fields)
122
- existing_properties["system.failure.description"] = failure_description[:128]
123
-
124
- self._ingest["properties"] = existing_properties
37
+ class PayiInstrumentModelMapping(TypedDict, total=False):
38
+ model: str
39
+ price_as_category: Optional[str]
40
+ price_as_resource: Optional[str]
41
+ # "global", "datazone", "region", "region.<region_name>"
42
+ resource_scope: Optional[str]
125
43
 
126
- if "http_status_code" not in self._ingest:
127
- # use a non existent http status code so when presented to the user, the origin is clear
128
- self._ingest["http_status_code"] = 299
44
+ class PayiInstrumentAwsBedrockConfig(TypedDict, total=False):
45
+ guardrail_trace: Optional[bool]
46
+ add_streaming_xproxy_result: Optional[bool]
47
+ model_mappings: Optional[Sequence[PayiInstrumentModelMapping]]
129
48
 
130
- def add_streaming_function_call(self, index: int, name: Optional[str], arguments: Optional[str]) -> None:
131
- if not self._function_call_builder:
132
- self._function_call_builder = {}
49
+ class PayiInstrumentAzureOpenAiConfig(TypedDict, total=False):
50
+ # map deployment name known model
51
+ model_mappings: Sequence[PayiInstrumentModelMapping]
133
52
 
134
- if not index in self._function_call_builder:
135
- self._function_call_builder[index] = ProviderResponseFunctionCall(name=name or "", arguments=arguments or "")
136
- else:
137
- function = self._function_call_builder[index]
138
- if name:
139
- function["name"] = function["name"] + name
140
- if arguments:
141
- function["arguments"] = (function.get("arguments", "") or "") + arguments
142
-
143
- def add_synchronous_function_call(self, name: str, arguments: Optional[str]) -> None:
144
- if not self._function_calls:
145
- self._function_calls = []
146
- self._ingest["provider_response_function_calls"] = self._function_calls
147
- self._function_calls.append(ProviderResponseFunctionCall(name=name, arguments=arguments))
53
+ class PayiInstrumentOfflineInstrumentationConfig(TypedDict, total=False):
54
+ file_name: str
148
55
 
149
56
  class PayiInstrumentConfig(TypedDict, total=False):
150
57
  proxy: bool
@@ -155,37 +62,55 @@ class PayiInstrumentConfig(TypedDict, total=False):
155
62
  use_case_name: Optional[str]
156
63
  use_case_id: Optional[str]
157
64
  use_case_version: Optional[int]
158
- use_case_properties: Optional["dict[str, str]"]
65
+ use_case_properties: Optional["dict[str, Optional[str]]"]
159
66
  user_id: Optional[str]
67
+ account_name: Optional[str]
160
68
  request_tags: Optional["list[str]"]
161
- request_properties: Optional["dict[str, str]"]
69
+ request_properties: Optional["dict[str, Optional[str]]"]
70
+ aws_config: Optional[PayiInstrumentAwsBedrockConfig]
71
+ azure_openai_config: Optional[PayiInstrumentAzureOpenAiConfig]
72
+ offline_instrumentation: Optional[PayiInstrumentOfflineInstrumentationConfig]
162
73
 
163
74
  class PayiContext(TypedDict, total=False):
164
75
  use_case_name: Optional[str]
165
76
  use_case_id: Optional[str]
166
77
  use_case_version: Optional[int]
167
78
  use_case_step: Optional[str]
168
- use_case_properties: Optional["dict[str, str]"]
79
+ use_case_properties: Optional["dict[str, Optional[str]]"]
169
80
  limit_ids: Optional['list[str]']
170
81
  user_id: Optional[str]
82
+ account_name: Optional[str]
171
83
  request_tags: Optional["list[str]"]
172
- request_properties: Optional["dict[str, str]"]
84
+ request_properties: Optional["dict[str, Optional[str]]"]
173
85
  price_as_category: Optional[str]
174
86
  price_as_resource: Optional[str]
175
87
  resource_scope: Optional[str]
176
88
  last_result: Optional[Union[XproxyResult, XproxyError]]
177
89
 
90
+ class PayiInstanceDefaultContext(TypedDict, total=False):
91
+ use_case_name: Optional[str]
92
+ use_case_id: Optional[str]
93
+ use_case_version: Optional[int]
94
+ use_case_properties: Optional["dict[str, str]"]
95
+ limit_ids: Optional['list[str]']
96
+ user_id: Optional[str]
97
+ account_name: Optional[str]
98
+ request_properties: Optional["dict[str, str]"]
99
+ price_as_category: Optional[str]
100
+ price_as_resource: Optional[str]
101
+ resource_scope: Optional[str]
102
+
178
103
  class _Context(TypedDict, total=False):
179
104
  proxy: Optional[bool]
180
105
  use_case_name: Optional[str]
181
106
  use_case_id: Optional[str]
182
107
  use_case_version: Optional[int]
183
108
  use_case_step: Optional[str]
184
- use_case_properties: Optional["dict[str, str]"]
109
+ use_case_properties: Optional["dict[str, Optional[str]]"]
185
110
  limit_ids: Optional['list[str]']
186
111
  user_id: Optional[str]
187
- request_tags: Optional["list[str]"]
188
- request_properties: Optional["dict[str, str]"]
112
+ account_name: Optional[str]
113
+ request_properties: Optional["dict[str, Optional[str]]"]
189
114
  price_as_category: Optional[str]
190
115
  price_as_resource: Optional[str]
191
116
  resource_scope: Optional[str]
@@ -195,10 +120,14 @@ class _IsStreaming(Enum):
195
120
  true = 1
196
121
  kwargs = 2
197
122
 
198
- class _StreamingType(Enum):
199
- generator = 0
200
- iterator = 1
201
- stream_manager = 2
123
+ class _ThreadLocalContextStorage(threading.local):
124
+ """
125
+ Thread-local storage for context stacks. Each thread gets its own context stack.
126
+
127
+ Note: We don't use __init__ because threading.local's __init__ semantics are tricky.
128
+ Instead, we lazily initialize the context_stack attribute in the property accessor.
129
+ """
130
+ context_stack: "list[_Context]"
202
131
 
203
132
  class _InternalTrackContext:
204
133
  def __init__(
@@ -230,9 +159,6 @@ class _PayiInstrumentor:
230
159
  instruments: Union[Set[str], None] = None,
231
160
  log_prompt_and_response: bool = True,
232
161
  logger: Optional[logging.Logger] = None,
233
- prompt_and_response_logger: Optional[
234
- Callable[[str, "dict[str, str]"], None]
235
- ] = None, # (request id, dict of data to store) -> None
236
162
  global_config: PayiInstrumentConfig = {},
237
163
  caller_filename: str = ""
238
164
  ):
@@ -249,14 +175,16 @@ class _PayiInstrumentor:
249
175
  if self._apayi:
250
176
  _g_logger.debug(f"Pay-i instrumentor initialized with AsyncPayi instance: {self._apayi}")
251
177
 
252
- self._context_stack: list[_Context] = [] # Stack of context dictionaries
178
+ # Thread-local storage for context stacks - each thread gets its own stack
179
+ self._thread_local_storage = _ThreadLocalContextStorage()
180
+
253
181
  self._log_prompt_and_response: bool = log_prompt_and_response
254
- self._prompt_and_response_logger: Optional[Callable[[str, dict[str, str]], None]] = prompt_and_response_logger
255
182
 
256
183
  self._blocked_limits: set[str] = set()
257
184
  self._exceeded_limits: set[str] = set()
258
185
 
259
- self._api_connection_error_last_log_time: float = time.time()
186
+ # by not setting to time.time() the first connection error is always logged
187
+ self._api_connection_error_last_log_time: float = 0
260
188
  self._api_connection_error_count: int = 0
261
189
  self._api_connection_error_window: int = global_config.get("connection_error_logging_window", 60)
262
190
  if self._api_connection_error_window < 0:
@@ -269,21 +197,43 @@ class _PayiInstrumentor:
269
197
 
270
198
  self._last_result: Optional[Union[XproxyResult, XproxyError]] = None
271
199
 
200
+ self._offline_instrumentation = global_config.pop("offline_instrumentation", None)
201
+ self._offline_ingest_packets: list[IngestUnitsParams] = []
202
+ self._offline_instrumentation_file_name: Optional[str] = None
203
+
204
+ if self._offline_instrumentation is not None:
205
+ timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
206
+ self._offline_instrumentation_file_name = self._offline_instrumentation.get("file_name", f"payi_instrumentation_{timestamp}.json")
207
+
208
+ # Register exit handler to write packets when process exits
209
+ atexit.register(lambda: self._write_offline_ingest_packets())
210
+
272
211
  global_instrumentation = global_config.pop("global_instrumentation", True)
273
212
 
213
+ # configure first, then instrument
214
+ aws_config = global_config.get("aws_config", None)
215
+ if aws_config:
216
+ from .BedrockInstrumentor import BedrockInstrumentor
217
+ BedrockInstrumentor.configure(aws_config=aws_config)
218
+
219
+ azure_openai_config = global_config.get("azure_openai_config", None)
220
+ if azure_openai_config:
221
+ from .OpenAIInstrumentor import OpenAiInstrumentor
222
+ OpenAiInstrumentor.configure(azure_openai_config=azure_openai_config)
223
+
274
224
  if instruments is None or "*" in instruments:
275
225
  self._instrument_all()
276
226
  else:
277
- self._instrument_specific(instruments)
227
+ self._instrument_specific(instruments=instruments)
228
+
229
+ self._instrument_futures()
278
230
 
279
231
  if global_instrumentation:
280
232
  if "proxy" not in global_config:
281
233
  global_config["proxy"] = self._proxy_default
282
234
 
283
235
  # Use default clients if not provided for global ingest instrumentation
284
- if not self._payi and not self._apayi:
285
- self._payi = Payi()
286
- self._apayi = AsyncPayi()
236
+ self._ensure_payi_clients()
287
237
 
288
238
  if "use_case_name" not in global_config and caller_filename:
289
239
  description = f"Default use case for {caller_filename}.py"
@@ -292,23 +242,53 @@ class _PayiInstrumentor:
292
242
  self._payi.use_cases.definitions.create(name=caller_filename, description=description)
293
243
  elif self._apayi:
294
244
  self._call_async_use_case_definition_create(use_case_name=caller_filename, use_case_description=description)
245
+ else:
246
+ # in the case of _local_instrumentation is not None
247
+ pass
295
248
  global_config["use_case_name"] = caller_filename
296
249
  except Exception as e:
297
250
  self._logger.error(f"Error creating default use case definition based on file name {caller_filename}: {e}")
298
251
 
299
252
  self.__enter__()
300
253
 
301
- # _init_current_context will update the currrent context stack location
254
+ # _init_current_context will update the current context stack location
302
255
  context: _Context = {}
256
+
303
257
  # Copy allowed keys from global_config into context
304
258
  # Dynamically use keys from _Context TypedDict
305
259
  context_keys = list(_Context.__annotations__.keys()) if hasattr(_Context, '__annotations__') else []
306
260
  for key in context_keys:
307
261
  if key in global_config:
308
- context[key] = global_config[key] # type: ignore
262
+ context[key] = global_config[key] # type: ignore[literal-required]
263
+
264
+ self._init_current_context(**context)
265
+
266
+ def _ensure_payi_clients(self) -> None:
267
+ if self._offline_instrumentation is not None:
268
+ return
269
+
270
+ if not self._payi and not self._apayi:
271
+ self._payi = Payi()
272
+ self._apayi = AsyncPayi()
273
+
274
+ def _instrument_futures(self) -> None:
275
+ """Install hooks for all common concurrent execution patterns."""
276
+ def _thread_wrapper(wrapped: Any, instance: Any, args: Any, kwargs: Any) -> Any:
277
+ return self._thread_submit_wrapper(wrapped, instance, args, kwargs)
278
+
279
+ async def _task_wrapper(wrapped: Any, instance: Any, args: Any, kwargs: Any) -> Any:
280
+ return await self._create_task_wrapper(wrapped, instance, args, kwargs)
309
281
 
310
- self._init_current_context(**context)
282
+ try:
283
+ wrap_function_wrapper("concurrent.futures", "ThreadPoolExecutor.submit", _thread_wrapper)
284
+ except Exception as e:
285
+ self._logger.debug(f"Error wrapping ThreadPoolExecutor.submit: {e}")
311
286
 
287
+ try:
288
+ wrap_function_wrapper("asyncio", "create_task", _task_wrapper)
289
+ except Exception as e:
290
+ self._logger.debug(f"Error wrapping asyncio.create_task: {e}")
291
+
312
292
  def _instrument_all(self) -> None:
313
293
  self._instrument_openai()
314
294
  self._instrument_anthropic()
@@ -372,6 +352,93 @@ class _PayiInstrumentor:
372
352
  except Exception as e:
373
353
  self._logger.error(f"Error instrumenting Google GenAi: {e}")
374
354
 
355
+ def _thread_submit_wrapper(
356
+ self,
357
+ wrapped: Any,
358
+ _instance: Any,
359
+ args: Any,
360
+ kwargs: Any,
361
+ ) -> Any:
362
+ if len(args) > 0:
363
+ fn = args[0]
364
+ fn_args = args[1:]
365
+ captured_context = copy.deepcopy(self._context_safe)
366
+
367
+ def context_wrapper(*inner_args: Any, **inner_kwargs: Any) -> Any:
368
+ with self:
369
+ # self._context_stack[-1].update(captured_context)
370
+ self._init_current_context(**captured_context)
371
+ return fn(*inner_args, **inner_kwargs)
372
+
373
+ return wrapped(context_wrapper, *fn_args, **kwargs)
374
+ return wrapped(*args, **kwargs)
375
+
376
+ async def _create_task_wrapper(
377
+ self,
378
+ wrapped: Any,
379
+ _instance: Any,
380
+ args: Any,
381
+ kwargs: Any,
382
+ ) -> Any:
383
+ if len(args) > 0:
384
+ coro = args[0]
385
+ captured_context = copy.deepcopy(self._context_safe)
386
+
387
+ async def context_wrapper() -> Any:
388
+ with self:
389
+ # self._context_stack[-1].update(captured_context)
390
+ self._init_current_context(**captured_context)
391
+ return await coro
392
+
393
+ return wrapped(context_wrapper(), *args[1:], **kwargs)
394
+ return wrapped(*args, **kwargs)
395
+
396
+ @staticmethod
397
+ def _model_mapping_to_context_dict(model_mappings: Sequence[PayiInstrumentModelMapping]) -> 'dict[str, _Context]':
398
+ context: dict[str, _Context] = {}
399
+ for mapping in model_mappings:
400
+ model = mapping.get("model", "")
401
+ if not model:
402
+ continue
403
+
404
+ price_as_category = mapping.get("price_as_category", None)
405
+ price_as_resource = mapping.get("price_as_resource", None)
406
+ resource_scope = mapping.get("resource_scope", None)
407
+
408
+ if not price_as_category and not price_as_resource:
409
+ continue
410
+
411
+ context[model] = _Context(
412
+ price_as_category=price_as_category,
413
+ price_as_resource=price_as_resource,
414
+ resource_scope=resource_scope,
415
+ )
416
+ return context
417
+
418
+ def _write_offline_ingest_packets(self) -> None:
419
+ if not self._offline_instrumentation_file_name or not self._offline_ingest_packets:
420
+ return
421
+
422
+ try:
423
+ # Convert datetime objects to ISO strings for JSON serialization
424
+ serializable_packets: list[IngestUnitsParams] = []
425
+ for packet in self._offline_ingest_packets:
426
+ serializable_packet = packet.copy()
427
+
428
+ # Convert datetime fields to ISO format strings
429
+ if 'event_timestamp' in serializable_packet and isinstance(serializable_packet['event_timestamp'], datetime):
430
+ serializable_packet['event_timestamp'] = serializable_packet['event_timestamp'].isoformat()
431
+
432
+ serializable_packets.append(serializable_packet)
433
+
434
+ with open(self._offline_instrumentation_file_name, 'w', encoding='utf-8') as f:
435
+ json.dump(serializable_packets, f)
436
+
437
+ self._logger.debug(f"Written {len(self._offline_ingest_packets)} ingest packets to {self._offline_instrumentation_file_name}")
438
+
439
+ except Exception as e:
440
+ self._logger.error(f"Error writing offline ingest packets to {self._offline_instrumentation_file_name}: {e}")
441
+
375
442
  @staticmethod
376
443
  def _create_logged_ingest_units(
377
444
  ingest_units: IngestUnitsParams,
@@ -388,10 +455,10 @@ class _PayiInstrumentor:
388
455
 
389
456
  return log_ingest_units
390
457
 
391
- def _process_ingest_units(
392
- self,
393
- request: _ProviderRequest, log_data: 'dict[str, str]',
394
- extra_headers: 'dict[str, str]') -> None:
458
+ def _after_invoke_update_request(
459
+ self,
460
+ request: _ProviderRequest,
461
+ extra_headers: 'dict[str, str]') -> None:
395
462
  ingest_units = request._ingest
396
463
 
397
464
  if request._module_version:
@@ -401,9 +468,14 @@ class _PayiInstrumentor:
401
468
  # convert the function call builder to a list of function calls
402
469
  ingest_units["provider_response_function_calls"] = list(request._function_call_builder.values())
403
470
 
471
+ if "provider_response_id" not in ingest_units or not ingest_units["provider_response_id"]:
472
+ ingest_units["provider_response_id"] = f"payi_{uuid.uuid4()}"
473
+
404
474
  if 'resource' not in ingest_units or ingest_units['resource'] == '':
405
475
  ingest_units['resource'] = "system.unknown_model"
406
476
 
477
+ request.merge_internal_request_properties()
478
+
407
479
  request_json = ingest_units.get('provider_request_json', "")
408
480
  if request_json and self._instrument_inline_data is False:
409
481
  try:
@@ -411,7 +483,7 @@ class _PayiInstrumentor:
411
483
  if request.remove_inline_data(prompt_dict):
412
484
  self._logger.debug(f"Removed inline data from provider_request_json")
413
485
  # store the modified dict back as JSON string
414
- ingest_units['provider_request_json'] = json.dumps(prompt_dict)
486
+ ingest_units['provider_request_json'] = _compact_json(prompt_dict)
415
487
 
416
488
  except Exception as e:
417
489
  self._logger.error(f"Error serializing provider_request_json: {e}")
@@ -421,19 +493,6 @@ class _PayiInstrumentor:
421
493
  if not units or all(unit.get("input", 0) == 0 and unit.get("output", 0) == 0 for unit in units.values()):
422
494
  self._logger.info('ingesting with no token counts')
423
495
 
424
- if self._log_prompt_and_response and self._prompt_and_response_logger:
425
- response_json = ingest_units.pop("provider_response_json", None)
426
- request_json = ingest_units.pop("provider_request_json", None)
427
- stack_trace = ingest_units.get("properties", {}).pop("system.stack_trace", None) # type: ignore
428
-
429
- if response_json is not None:
430
- # response_json is a list of strings, convert a single json string
431
- log_data["provider_response_json"] = json.dumps(response_json)
432
- if request_json is not None:
433
- log_data["provider_request_json"] = request_json
434
- if stack_trace is not None:
435
- log_data["stack_trace"] = stack_trace
436
-
437
496
  def _process_ingest_units_response(self, ingest_response: IngestResponse) -> None:
438
497
  if ingest_response.xproxy_result.limits:
439
498
  for limit_id, state in ingest_response.xproxy_result.limits.items():
@@ -476,11 +535,8 @@ class _PayiInstrumentor:
476
535
 
477
536
  self._logger.debug(f"_aingest_units")
478
537
 
479
- # return early if there are no units to ingest and on a successul ingest request
480
- log_data: 'dict[str,str]' = {}
481
538
  extra_headers: 'dict[str, str]' = {}
482
-
483
- self._process_ingest_units(request, log_data=log_data, extra_headers=extra_headers)
539
+ self._after_invoke_update_request(request, extra_headers=extra_headers)
484
540
 
485
541
  try:
486
542
  if self._logger.isEnabledFor(logging.DEBUG):
@@ -490,6 +546,18 @@ class _PayiInstrumentor:
490
546
  ingest_response = await self._apayi.ingest.units(**ingest_units, extra_headers=extra_headers)
491
547
  elif self._payi:
492
548
  ingest_response = self._payi.ingest.units(**ingest_units, extra_headers=extra_headers)
549
+ elif self._offline_instrumentation is not None:
550
+ self._offline_ingest_packets.append(ingest_units.copy())
551
+
552
+ # simulate a successful ingest for local instrumentation
553
+ now=datetime.now(timezone.utc)
554
+ ingest_response = IngestResponse(
555
+ event_timestamp=now,
556
+ ingest_timestamp=now,
557
+ request_id="local_instrumentation",
558
+ xproxy_result=XproxyResult(request_id="local_instrumentation"))
559
+ pass
560
+
493
561
  else:
494
562
  self._logger.error("No payi instance to ingest units")
495
563
  return XproxyError(code="configuration_error", message="No Payi or AsyncPayi instance configured for ingesting units")
@@ -499,10 +567,6 @@ class _PayiInstrumentor:
499
567
  if ingest_response:
500
568
  self._process_ingest_units_response(ingest_response)
501
569
 
502
- if ingest_response and self._log_prompt_and_response and self._prompt_and_response_logger:
503
- request_id = ingest_response.xproxy_result.request_id
504
- self._prompt_and_response_logger(request_id, log_data) # type: ignore
505
-
506
570
  return ingest_response.xproxy_result
507
571
 
508
572
  except APIConnectionError as api_ex:
@@ -561,7 +625,7 @@ class _PayiInstrumentor:
561
625
  # Try to get the response body as JSON
562
626
  body = e.body
563
627
  if body is None:
564
- self._logger.error("APIStatusError response has no body attribute")
628
+ self._logger.warning(f"Pay-i ingest exception {e}, status {e.status_code} has no body")
565
629
  return XproxyError(code="unknown_error", message=str(e))
566
630
 
567
631
  # If body is bytes, decode to string
@@ -575,8 +639,9 @@ class _PayiInstrumentor:
575
639
  if not body_dict:
576
640
  try:
577
641
  body_dict = json.loads(body) # type: ignore
578
- except Exception as json_ex:
579
- self._logger.error(f"Failed to parse response body as JSON: {json_ex}")
642
+ except Exception:
643
+ body_type = type(body).__name__ # type: ignore
644
+ self._logger.warning(f"Pay-i ingest exception {e}, status {e.status_code} cannot parse response JSON body for body type {body_type}")
580
645
  return XproxyError(code="invalid_json", message=str(e))
581
646
 
582
647
  xproxy_error = body_dict.get("xproxy_error", {})
@@ -585,7 +650,7 @@ class _PayiInstrumentor:
585
650
  return XproxyError(code=code, message=message)
586
651
 
587
652
  except Exception as ex:
588
- self._logger.error(f"Exception in _process_api_status_error: {ex}")
653
+ self._logger.warning(f"Pay-i ingest exception {e}, status {e.status_code} processing handled exception {ex}")
589
654
  return XproxyError(code="exception", message=str(ex))
590
655
 
591
656
  def _ingest_units_worker(self, request: _ProviderRequest) -> Optional[Union[XproxyResult, XproxyError]]:
@@ -594,10 +659,8 @@ class _PayiInstrumentor:
594
659
 
595
660
  self._logger.debug(f"_ingest_units")
596
661
 
597
- # return early if there are no units to ingest and on a successul ingest request
598
- log_data: 'dict[str,str]' = {}
599
662
  extra_headers: 'dict[str, str]' = {}
600
- self._process_ingest_units(request, log_data=log_data, extra_headers=extra_headers)
663
+ self._after_invoke_update_request(request, extra_headers=extra_headers)
601
664
 
602
665
  try:
603
666
  if self._payi:
@@ -609,16 +672,18 @@ class _PayiInstrumentor:
609
672
 
610
673
  self._process_ingest_units_response(ingest_response)
611
674
 
612
- if self._log_prompt_and_response and self._prompt_and_response_logger:
613
- request_id = ingest_response.xproxy_result.request_id
614
- self._prompt_and_response_logger(request_id, log_data) # type: ignore
615
-
616
675
  return ingest_response.xproxy_result
617
676
  elif self._apayi:
618
677
  # task runs async. aingest_units will invoke the callback and post process
619
678
  sync_response = self._call_aingest_sync(request)
620
679
  self._logger.debug(f"_ingest_units: apayi success ({sync_response})")
621
680
  return sync_response
681
+ elif self._offline_instrumentation is not None:
682
+ self._offline_ingest_packets.append(ingest_units.copy())
683
+
684
+ # simulate a successful ingest for local instrumentation
685
+ return XproxyResult(request_id="local_instrumentation")
686
+
622
687
  else:
623
688
  self._logger.error("No payi instance to ingest units")
624
689
  return XproxyError(code="configuration_error", message="No Payi or AsyncPayi instance configured for ingesting units")
@@ -636,6 +701,21 @@ class _PayiInstrumentor:
636
701
  def _ingest_units(self, request: _ProviderRequest) -> Optional[Union[XproxyResult, XproxyError]]:
637
702
  return self.set_xproxy_result(self._ingest_units_worker(request))
638
703
 
704
+ @property
705
+ def _context_stack(self) -> "list[_Context]":
706
+ """
707
+ Get the thread-local context stack. On first access per thread,
708
+ initializes with the current state of the main thread's context stack.
709
+ """
710
+ # Lazy-initialize the context_stack for this thread if it doesn't exist
711
+ if not hasattr(self._thread_local_storage, 'context_stack'):
712
+ self._thread_local_storage.context_stack = []
713
+
714
+ stack = self._thread_local_storage.context_stack
715
+
716
+
717
+ return stack
718
+
639
719
  def _setup_call_func(
640
720
  self
641
721
  ) -> _Context:
@@ -646,6 +726,31 @@ class _PayiInstrumentor:
646
726
 
647
727
  return {}
648
728
 
729
+ @staticmethod
730
+ def _valid_str_or_none(value: Optional[str], default: Optional[str] = None) -> Optional[str]:
731
+ if value is None:
732
+ return default
733
+ elif len(value) == 0:
734
+ # an empty string explicitly blocks the default value
735
+ return None
736
+ else:
737
+ return value
738
+
739
+ @staticmethod
740
+ def _valid_properties_or_none(value: Optional["dict[str, Optional[str]]"], default: Optional["dict[str, Optional[str]]"] = None) -> Optional["dict[str, Optional[str]]"]:
741
+ if value is None:
742
+ return default.copy() if default else None
743
+ elif len(value) == 0:
744
+ # an empty dictionary explicitly blocks the default value
745
+ return None
746
+ elif default:
747
+ # merge dictionaries, child overrides parent keys
748
+ merged = default.copy()
749
+ merged.update(value)
750
+ return merged
751
+ else:
752
+ return value.copy()
753
+
649
754
  def _init_current_context(
650
755
  self,
651
756
  proxy: Optional[bool] = None,
@@ -655,16 +760,16 @@ class _PayiInstrumentor:
655
760
  use_case_version: Optional[int]= None,
656
761
  use_case_step: Optional[str]= None,
657
762
  user_id: Optional[str]= None,
658
- request_tags: Optional["list[str]"] = None,
659
- request_properties: Optional["dict[str, str]"] = None,
660
- use_case_properties: Optional["dict[str, str]"] = None,
763
+ account_name: Optional[str]= None,
764
+ request_properties: Optional["dict[str, Optional[str]]"] = None,
765
+ use_case_properties: Optional["dict[str, Optional[str]]"] = None,
661
766
  price_as_category: Optional[str] = None,
662
767
  price_as_resource: Optional[str] = None,
663
768
  resource_scope: Optional[str] = None,
664
769
  ) -> None:
665
770
 
666
771
  # there will always be a current context
667
- context: _Context = self.get_context() # type: ignore
772
+ context: _Context = self._context # type: ignore
668
773
  parent_context: _Context = self._context_stack[-2] if len(self._context_stack) > 1 else {}
669
774
 
670
775
  parent_proxy = parent_context.get("proxy", self._proxy_default)
@@ -705,26 +810,12 @@ class _PayiInstrumentor:
705
810
  assign_use_case_values = True
706
811
 
707
812
  if assign_use_case_values:
708
- context["use_case_id"] = use_case_id if use_case_id else parent_use_case_id
709
- context["use_case_version"] = use_case_version if use_case_version else parent_use_case_version
710
- context["use_case_step"] = use_case_step if use_case_step else parent_use_case_step
813
+ context["use_case_version"] = use_case_version if use_case_version is not None else parent_use_case_version
814
+ context["use_case_id"] = self._valid_str_or_none(use_case_id, parent_use_case_id)
815
+ context["use_case_step"] = self._valid_str_or_none(use_case_step, parent_use_case_step)
711
816
 
712
817
  parent_use_case_properties = parent_context.get("use_case_properties", None)
713
- if use_case_properties is not None:
714
- if not use_case_properties:
715
- # an empty dictionary explicitly blocks inheriting from the parent state
716
- context["use_case_properties"] = None
717
- else:
718
- if parent_use_case_properties:
719
- # merge dictionaries, child overrides parent keys
720
- merged = parent_use_case_properties.copy()
721
- merged.update(use_case_properties)
722
- context["use_case_properties"] = merged
723
- else:
724
- context["use_case_properties"] = use_case_properties.copy()
725
- elif parent_use_case_properties:
726
- # use the parent use_case_properties if it exists
727
- context["use_case_properties"] = parent_use_case_properties.copy()
818
+ context["use_case_properties"] = self._valid_properties_or_none(use_case_properties, parent_use_case_properties)
728
819
 
729
820
  parent_limit_ids = parent_context.get("limit_ids", None)
730
821
  if limit_ids is None:
@@ -738,46 +829,13 @@ class _PayiInstrumentor:
738
829
  context["limit_ids"] = list(set(limit_ids) | set(parent_limit_ids)) if parent_limit_ids else limit_ids.copy()
739
830
 
740
831
  parent_user_id = parent_context.get("user_id", None)
741
- if user_id is None:
742
- # use the parent user_id if it exists
743
- context["user_id"] = parent_user_id
744
- elif len(user_id) == 0:
745
- # caller passing an empty string explicitly blocks inheriting from the parent state
746
- context["user_id"] = None
747
- else:
748
- context["user_id"] = user_id
832
+ context["user_id"] = self._valid_str_or_none(user_id, parent_user_id)
749
833
 
750
- parent_request_tags = parent_context.get("request_tags", None)
751
- if request_tags is not None:
752
- if len(request_tags) == 0:
753
- # caller passing an empty list explicitly blocks inheriting from the parent state
754
- context["request_tags"] = None
755
- else:
756
- if parent_request_tags:
757
- # union of new and parent lists if the parent context contains request tags
758
- context["request_tags"] = list(set(request_tags) | set(parent_request_tags))
759
- else:
760
- context["request_tags"] = request_tags.copy()
761
- elif parent_request_tags:
762
- # use the parent request_tags if it exists
763
- context["request_tags"] = parent_request_tags.copy()
834
+ parent_account_name = parent_context.get("account_name", None)
835
+ context["account_name"] = self._valid_str_or_none(account_name, parent_account_name)
764
836
 
765
837
  parent_request_properties = parent_context.get("request_properties", None)
766
- if request_properties is not None:
767
- if not request_properties:
768
- # an empty dictionary explicitly blocks inheriting from the parent state
769
- context["request_properties"] = None
770
- else:
771
- if parent_request_properties:
772
- # merge dictionaries, child overrides parent keys
773
- merged = parent_request_properties.copy()
774
- merged.update(request_properties)
775
- context["request_properties"] = merged
776
- else:
777
- context["request_properties"] = request_properties.copy()
778
- elif parent_request_properties:
779
- # use the parent request_properties if it exists
780
- context["request_properties"] = parent_request_properties.copy()
838
+ context["request_properties"] = self._valid_properties_or_none(request_properties, parent_request_properties)
781
839
 
782
840
  if price_as_category:
783
841
  context["price_as_category"] = price_as_category
@@ -795,9 +853,9 @@ class _PayiInstrumentor:
795
853
  use_case_id: Optional[str],
796
854
  use_case_version: Optional[int],
797
855
  user_id: Optional[str],
798
- request_tags: Optional["list[str]"] = None,
799
- request_properties: Optional["dict[str, str]"] = None,
800
- use_case_properties: Optional["dict[str, str]"] = None,
856
+ account_name: Optional[str],
857
+ request_properties: Optional["dict[str, Optional[str]]"] = None,
858
+ use_case_properties: Optional["dict[str, Optional[str]]"] = None,
801
859
  *args: Any,
802
860
  **kwargs: Any,
803
861
  ) -> Any:
@@ -809,7 +867,7 @@ class _PayiInstrumentor:
809
867
  use_case_id=use_case_id,
810
868
  use_case_version=use_case_version,
811
869
  user_id=user_id,
812
- request_tags=request_tags,
870
+ account_name=account_name,
813
871
  request_properties=request_properties,
814
872
  use_case_properties=use_case_properties
815
873
  )
@@ -824,9 +882,9 @@ class _PayiInstrumentor:
824
882
  use_case_id: Optional[str],
825
883
  use_case_version: Optional[int],
826
884
  user_id: Optional[str],
827
- request_tags: Optional["list[str]"] = None,
828
- request_properties: Optional["dict[str, str]"] = None,
829
- use_case_properties: Optional["dict[str, str]"] = None,
885
+ account_name: Optional[str],
886
+ request_properties: Optional["dict[str, Optional[str]]"] = None,
887
+ use_case_properties: Optional["dict[str, Optional[str]]"] = None,
830
888
  *args: Any,
831
889
  **kwargs: Any,
832
890
  ) -> Any:
@@ -838,40 +896,52 @@ class _PayiInstrumentor:
838
896
  use_case_id=use_case_id,
839
897
  use_case_version=use_case_version,
840
898
  user_id=user_id,
841
- request_tags=request_tags,
899
+ account_name=account_name,
842
900
  request_properties=request_properties,
843
901
  use_case_properties=use_case_properties)
844
902
  return func(*args, **kwargs)
845
903
 
846
904
  def __enter__(self) -> Any:
847
- # Push a new context dictionary onto the stack
905
+ # Push a new context dictionary onto the thread-local stack
848
906
  self._context_stack.append({})
849
907
  return self
850
908
 
851
909
  def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
852
- # Pop the current context off the stack
910
+ # Pop the current context off the thread-local stack
853
911
  if self._context_stack:
854
912
  self._context_stack.pop()
855
913
 
856
- def get_context(self) -> Optional[_Context]:
914
+ @property
915
+ def _context(self) -> Optional[_Context]:
857
916
  # Return the current top of the stack
858
917
  return self._context_stack[-1] if self._context_stack else None
859
918
 
860
- def get_context_safe(self) -> _Context:
919
+ @property
920
+ def _context_safe(self) -> _Context:
861
921
  # Return the current top of the stack
862
- return self.get_context() or {}
922
+ return self._context or {}
923
+
924
+ def _extract_price_as(self, extra_headers: "dict[str, str]") -> PriceAs:
925
+ context = self._context_safe
863
926
 
864
- def _prepare_ingest(
927
+ return PriceAs(
928
+ category=extra_headers.pop(PayiHeaderNames.price_as_category, None) or context.get("price_as_category", None),
929
+ resource=extra_headers.pop(PayiHeaderNames.price_as_resource, None) or context.get("price_as_resource", None),
930
+ resource_scope=extra_headers.pop(PayiHeaderNames.resource_scope, None) or context.get("resource_scope", None),
931
+ )
932
+
933
+ def _before_invoke_update_request(
865
934
  self,
866
935
  request: _ProviderRequest,
867
- context: _Context,
868
936
  ingest_extra_headers: "dict[str, str]", # do not conflict with potential kwargs["extra_headers"]
869
937
  args: Sequence[Any],
870
938
  kwargs: 'dict[str, Any]',
871
939
  ) -> None:
872
940
 
941
+ # pop and ignore the request tags header since it is no longer processed
942
+ ingest_extra_headers.pop(PayiHeaderNames.request_tags, None)
943
+
873
944
  limit_ids = ingest_extra_headers.pop(PayiHeaderNames.limit_ids, None)
874
- request_tags = ingest_extra_headers.pop(PayiHeaderNames.request_tags, None)
875
945
 
876
946
  use_case_name = ingest_extra_headers.pop(PayiHeaderNames.use_case_name, None)
877
947
  use_case_id = ingest_extra_headers.pop(PayiHeaderNames.use_case_id, None)
@@ -879,11 +949,13 @@ class _PayiInstrumentor:
879
949
  use_case_step = ingest_extra_headers.pop(PayiHeaderNames.use_case_step, None)
880
950
 
881
951
  user_id = ingest_extra_headers.pop(PayiHeaderNames.user_id, None)
952
+ account_name = ingest_extra_headers.pop(PayiHeaderNames.account_name, None)
953
+
954
+ request_properties = ingest_extra_headers.pop(PayiHeaderNames.request_properties, "")
955
+ use_case_properties = ingest_extra_headers.pop(PayiHeaderNames.use_case_properties, "")
882
956
 
883
957
  if limit_ids:
884
958
  request._ingest["limit_ids"] = limit_ids.split(",")
885
- if request_tags:
886
- request._ingest["request_tags"] = request_tags.split(",")
887
959
  if use_case_name:
888
960
  request._ingest["use_case_name"] = use_case_name
889
961
  if use_case_id:
@@ -894,14 +966,12 @@ class _PayiInstrumentor:
894
966
  request._ingest["use_case_step"] = use_case_step
895
967
  if user_id:
896
968
  request._ingest["user_id"] = user_id
897
-
898
- request_properties = context.get("request_properties", None)
969
+ if account_name:
970
+ request._ingest["account_name"] = account_name
899
971
  if request_properties:
900
- request._ingest["properties"] = request_properties
901
-
902
- use_case_properties = context.get("use_case_properties", None)
972
+ request._ingest["properties"] = json.loads(request_properties)
903
973
  if use_case_properties:
904
- request._ingest["use_case_properties"] = use_case_properties
974
+ request._ingest["use_case_properties"] = json.loads(use_case_properties)
905
975
 
906
976
  if len(ingest_extra_headers) > 0:
907
977
  request._ingest["provider_request_headers"] = [PayICommonModelsAPIRouterHeaderInfoParam(name=k, value=v) for k, v in ingest_extra_headers.items()]
@@ -925,10 +995,10 @@ class _PayiInstrumentor:
925
995
  request.process_request_prompt(provider_prompt, args, kwargs)
926
996
 
927
997
  if self._log_prompt_and_response:
928
- request._ingest["provider_request_json"] = json.dumps(provider_prompt)
998
+ request._ingest["provider_request_json"] = _compact_json(provider_prompt)
929
999
 
930
1000
  request._ingest["event_timestamp"] = datetime.now(timezone.utc)
931
-
1001
+
932
1002
  async def async_invoke_wrapper(
933
1003
  self,
934
1004
  request: _ProviderRequest,
@@ -940,7 +1010,7 @@ class _PayiInstrumentor:
940
1010
  ) -> Any:
941
1011
  self._logger.debug(f"async_invoke_wrapper: instance {instance}, category {request._category}")
942
1012
 
943
- context = self.get_context()
1013
+ context = self._context
944
1014
 
945
1015
  # Bedrock client does not have an async method
946
1016
 
@@ -952,23 +1022,29 @@ class _PayiInstrumentor:
952
1022
 
953
1023
  # after _udpate_headers, all metadata to add to ingest is in extra_headers, keyed by the xproxy-xxx header name
954
1024
  extra_headers: Optional[dict[str, str]] = kwargs.get("extra_headers")
955
- if extra_headers is None:
956
- extra_headers = {}
1025
+ extra_headers = (extra_headers or {}).copy()
957
1026
  self._update_extra_headers(context, extra_headers)
958
1027
 
959
1028
  if context.get("proxy", self._proxy_default):
960
- if "extra_headers" not in kwargs and extra_headers:
1029
+ if not request.supports_extra_headers:
1030
+ kwargs.pop("extra_headers", None)
1031
+ elif extra_headers:
1032
+ # Pass the copy to the wrapped function. Assumes anthropic and openai clients
961
1033
  kwargs["extra_headers"] = extra_headers
962
1034
 
963
1035
  self._logger.debug(f"async_invoke_wrapper: sending proxy request")
964
1036
 
965
1037
  return await wrapped(*args, **kwargs)
1038
+
1039
+ request._price_as = self._extract_price_as(extra_headers)
1040
+ if not request.supports_extra_headers and "extra_headers" in kwargs:
1041
+ kwargs.pop("extra_headers", None)
966
1042
 
967
1043
  current_frame = inspect.currentframe()
968
1044
  # f_back excludes the current frame, strip() cleans up whitespace and newlines
969
1045
  stack = [frame.strip() for frame in traceback.format_stack(current_frame.f_back)] # type: ignore
970
1046
 
971
- request._ingest['properties'] = { 'system.stack_trace': json.dumps(stack) }
1047
+ request._ingest['properties'] = { 'system.stack_trace': _compact_json(stack) }
972
1048
 
973
1049
  if request.process_request(instance, extra_headers, args, kwargs) is False:
974
1050
  self._logger.debug(f"async_invoke_wrapper: calling wrapped instance")
@@ -985,9 +1061,13 @@ class _PayiInstrumentor:
985
1061
  stream = False
986
1062
 
987
1063
  try:
988
- self._prepare_ingest(request, context, extra_headers, args, kwargs)
1064
+ self._before_invoke_update_request(request, extra_headers, args, kwargs)
989
1065
  self._logger.debug(f"async_invoke_wrapper: calling wrapped instance (stream={stream})")
990
1066
 
1067
+ if "extra_headers" in kwargs:
1068
+ # replace the original extra_headers with the updated copy which has all of the Pay-i headers removed
1069
+ kwargs["extra_headers"] = extra_headers
1070
+
991
1071
  sw.start()
992
1072
  response = await wrapped(*args, **kwargs)
993
1073
 
@@ -1034,6 +1114,8 @@ class _PayiInstrumentor:
1034
1114
  request._ingest["end_to_end_latency_ms"] = duration
1035
1115
  request._ingest["http_status_code"] = 200
1036
1116
 
1117
+ request.add_instrumented_response_headers(response)
1118
+
1037
1119
  return_result: Any = request.process_synchronous_response(
1038
1120
  response=response,
1039
1121
  log_prompt_and_response=self._log_prompt_and_response,
@@ -1043,7 +1125,8 @@ class _PayiInstrumentor:
1043
1125
  self._logger.debug(f"async_invoke_wrapper: process sync response return")
1044
1126
  return return_result
1045
1127
 
1046
- await self._aingest_units(request)
1128
+ xproxy_result = await self._aingest_units(request)
1129
+ request.assign_xproxy_result(response, xproxy_result)
1047
1130
 
1048
1131
  self._logger.debug(f"async_invoke_wrapper: finished")
1049
1132
  return response
@@ -1059,7 +1142,7 @@ class _PayiInstrumentor:
1059
1142
  ) -> Any:
1060
1143
  self._logger.debug(f"invoke_wrapper: instance {instance}, category {request._category}")
1061
1144
 
1062
- context = self.get_context()
1145
+ context = self._context
1063
1146
 
1064
1147
  if not context:
1065
1148
  if not request.supports_extra_headers:
@@ -1072,26 +1155,29 @@ class _PayiInstrumentor:
1072
1155
 
1073
1156
  # after _udpate_headers, all metadata to add to ingest is in extra_headers, keyed by the xproxy-xxx header name
1074
1157
  extra_headers: Optional[dict[str, str]] = kwargs.get("extra_headers")
1075
- if extra_headers is None:
1076
- extra_headers = {}
1158
+ extra_headers = (extra_headers or {}).copy()
1077
1159
  self._update_extra_headers(context, extra_headers)
1078
1160
 
1079
1161
  if context.get("proxy", self._proxy_default):
1080
1162
  if not request.supports_extra_headers:
1081
1163
  kwargs.pop("extra_headers", None)
1082
- elif "extra_headers" not in kwargs and extra_headers:
1083
- # assumes anthropic and openai clients
1164
+ elif extra_headers:
1165
+ # Pass the copy to the wrapped function. Assumes anthropic and openai clients
1084
1166
  kwargs["extra_headers"] = extra_headers
1085
1167
 
1086
1168
  self._logger.debug(f"invoke_wrapper: sending proxy request")
1087
1169
 
1088
1170
  return wrapped(*args, **kwargs)
1171
+
1172
+ request._price_as = self._extract_price_as(extra_headers)
1173
+ if not request.supports_extra_headers and "extra_headers" in kwargs:
1174
+ kwargs.pop("extra_headers", None)
1089
1175
 
1090
1176
  current_frame = inspect.currentframe()
1091
1177
  # f_back excludes the current frame, strip() cleans up whitespace and newlines
1092
1178
  stack = [frame.strip() for frame in traceback.format_stack(current_frame.f_back)] # type: ignore
1093
1179
 
1094
- request._ingest['properties'] = { 'system.stack_trace': json.dumps(stack) }
1180
+ request._ingest['properties'] = { 'system.stack_trace': _compact_json(stack) }
1095
1181
 
1096
1182
  if request.process_request(instance, extra_headers, args, kwargs) is False:
1097
1183
  self._logger.debug(f"invoke_wrapper: calling wrapped instance")
@@ -1108,9 +1194,13 @@ class _PayiInstrumentor:
1108
1194
  stream = False
1109
1195
 
1110
1196
  try:
1111
- self._prepare_ingest(request, context, extra_headers, args, kwargs)
1197
+ self._before_invoke_update_request(request, extra_headers, args, kwargs)
1112
1198
  self._logger.debug(f"invoke_wrapper: calling wrapped instance (stream={stream})")
1113
1199
 
1200
+ if "extra_headers" in kwargs:
1201
+ # replace the original extra_headers with the updated copy which has all of the Pay-i headers removed
1202
+ kwargs["extra_headers"] = extra_headers
1203
+
1114
1204
  sw.start()
1115
1205
  response = wrapped(*args, **kwargs)
1116
1206
 
@@ -1167,15 +1257,19 @@ class _PayiInstrumentor:
1167
1257
  request._ingest["end_to_end_latency_ms"] = duration
1168
1258
  request._ingest["http_status_code"] = 200
1169
1259
 
1260
+ request.add_instrumented_response_headers(response)
1261
+
1170
1262
  return_result: Any = request.process_synchronous_response(
1171
1263
  response=response,
1172
1264
  log_prompt_and_response=self._log_prompt_and_response,
1173
1265
  kwargs=kwargs)
1266
+
1174
1267
  if return_result:
1175
1268
  self._logger.debug(f"invoke_wrapper: process sync response return")
1176
1269
  return return_result
1177
1270
 
1178
- self._ingest_units(request)
1271
+ xproxy_result = self._ingest_units(request)
1272
+ request.assign_xproxy_result(response, xproxy_result)
1179
1273
 
1180
1274
  self._logger.debug(f"invoke_wrapper: finished")
1181
1275
  return response
@@ -1184,7 +1278,7 @@ class _PayiInstrumentor:
1184
1278
  self
1185
1279
  ) -> 'dict[str, str]':
1186
1280
  extra_headers: dict[str, str] = {}
1187
- context = self.get_context()
1281
+ context = self._context
1188
1282
  if context:
1189
1283
  self._update_extra_headers(context, extra_headers)
1190
1284
 
@@ -1207,19 +1301,44 @@ class _PayiInstrumentor:
1207
1301
  context_use_case_step: Optional[str] = context.get("use_case_step")
1208
1302
 
1209
1303
  context_user_id: Optional[str] = context.get("user_id")
1210
- context_request_tags: Optional[list[str]] = context.get("request_tags")
1304
+ context_account_name: Optional[str] = context.get("account_name")
1211
1305
 
1212
1306
  context_price_as_category: Optional[str] = context.get("price_as_category")
1213
1307
  context_price_as_resource: Optional[str] = context.get("price_as_resource")
1214
1308
  context_resource_scope: Optional[str] = context.get("resource_scope")
1215
1309
 
1216
- # headers_limit_ids = extra_headers.get(PayiHeaderNames.limit_ids, None)
1217
-
1310
+ context_request_properties: Optional[dict[str, Optional[str]]] = context.get("request_properties")
1311
+ context_use_case_properties: Optional[dict[str, Optional[str]]] = context.get("use_case_properties")
1312
+
1313
+ if PayiHeaderNames.request_properties in extra_headers:
1314
+ headers_request_properties = extra_headers.get(PayiHeaderNames.request_properties, None)
1315
+
1316
+ if not headers_request_properties:
1317
+ # headers_request_properties is empty, remove it from extra_headers
1318
+ extra_headers.pop(PayiHeaderNames.request_properties, None)
1319
+ else:
1320
+ # leave the value in extra_headers
1321
+ ...
1322
+ elif context_request_properties:
1323
+ extra_headers[PayiHeaderNames.request_properties] = _compact_json(context_request_properties)
1324
+
1325
+ if PayiHeaderNames.use_case_properties in extra_headers:
1326
+ headers_use_case_properties = extra_headers.get(PayiHeaderNames.use_case_properties, None)
1327
+
1328
+ if not headers_use_case_properties:
1329
+ # headers_use_case_properties is empty, remove it from extra_headers
1330
+ extra_headers.pop(PayiHeaderNames.use_case_properties, None)
1331
+ else:
1332
+ # leave the value in extra_headers
1333
+ ...
1334
+ elif context_use_case_properties:
1335
+ extra_headers[PayiHeaderNames.use_case_properties] = _compact_json(context_use_case_properties)
1336
+
1218
1337
  # If the caller specifies limit_ids in extra_headers, it takes precedence over the decorator
1219
1338
  if PayiHeaderNames.limit_ids in extra_headers:
1220
1339
  headers_limit_ids = extra_headers.get(PayiHeaderNames.limit_ids)
1221
1340
 
1222
- if headers_limit_ids is None or len(headers_limit_ids) == 0:
1341
+ if not headers_limit_ids:
1223
1342
  # headers_limit_ids is empty, remove it from extra_headers
1224
1343
  extra_headers.pop(PayiHeaderNames.limit_ids, None)
1225
1344
  else:
@@ -1230,7 +1349,7 @@ class _PayiInstrumentor:
1230
1349
 
1231
1350
  if PayiHeaderNames.user_id in extra_headers:
1232
1351
  headers_user_id = extra_headers.get(PayiHeaderNames.user_id, None)
1233
- if headers_user_id is None or len(headers_user_id) == 0:
1352
+ if not headers_user_id:
1234
1353
  # headers_user_id is empty, remove it from extra_headers
1235
1354
  extra_headers.pop(PayiHeaderNames.user_id, None)
1236
1355
  else:
@@ -1239,9 +1358,20 @@ class _PayiInstrumentor:
1239
1358
  elif context_user_id:
1240
1359
  extra_headers[PayiHeaderNames.user_id] = context_user_id
1241
1360
 
1361
+ if PayiHeaderNames.account_name in extra_headers:
1362
+ headers_account_name = extra_headers.get(PayiHeaderNames.account_name, None)
1363
+ if not headers_account_name:
1364
+ # headers_account_name is empty, remove it from extra_headers
1365
+ extra_headers.pop(PayiHeaderNames.account_name, None)
1366
+ else:
1367
+ # leave the value in extra_headers
1368
+ ...
1369
+ elif context_account_name:
1370
+ extra_headers[PayiHeaderNames.account_name] = context_account_name
1371
+
1242
1372
  if PayiHeaderNames.use_case_name in extra_headers:
1243
1373
  headers_use_case_name = extra_headers.get(PayiHeaderNames.use_case_name, None)
1244
- if headers_use_case_name is None or len(headers_use_case_name) == 0:
1374
+ if not headers_use_case_name:
1245
1375
  # headers_use_case_name is empty, remove all use case related headers
1246
1376
  extra_headers.pop(PayiHeaderNames.use_case_name, None)
1247
1377
  extra_headers.pop(PayiHeaderNames.use_case_id, None)
@@ -1257,10 +1387,7 @@ class _PayiInstrumentor:
1257
1387
  if context_use_case_version is not None:
1258
1388
  extra_headers[PayiHeaderNames.use_case_version] = str(context_use_case_version)
1259
1389
  if context_use_case_step is not None:
1260
- extra_headers[PayiHeaderNames.use_case_step] = str(context_use_case_step)
1261
-
1262
- if PayiHeaderNames.request_tags not in extra_headers and context_request_tags:
1263
- extra_headers[PayiHeaderNames.request_tags] = ",".join(context_request_tags)
1390
+ extra_headers[PayiHeaderNames.use_case_step] = context_use_case_step
1264
1391
 
1265
1392
  if PayiHeaderNames.price_as_category not in extra_headers and context_price_as_category:
1266
1393
  extra_headers[PayiHeaderNames.price_as_category] = context_price_as_category
@@ -1271,16 +1398,6 @@ class _PayiInstrumentor:
1271
1398
  if PayiHeaderNames.resource_scope not in extra_headers and context_resource_scope:
1272
1399
  extra_headers[PayiHeaderNames.resource_scope] = context_resource_scope
1273
1400
 
1274
- @staticmethod
1275
- def update_for_vision(input: int, units: 'dict[str, Units]', estimated_prompt_tokens: Optional[int]) -> int:
1276
- if estimated_prompt_tokens:
1277
- vision = input - estimated_prompt_tokens
1278
- if (vision > 0):
1279
- units["vision"] = Units(input=vision, output=0)
1280
- input = estimated_prompt_tokens
1281
-
1282
- return input
1283
-
1284
1401
  @staticmethod
1285
1402
  def payi_wrapper(func: Any) -> Any:
1286
1403
  def _payi_wrapper(o: Any) -> Any:
@@ -1313,351 +1430,6 @@ class _PayiInstrumentor:
1313
1430
 
1314
1431
  return _payi_awrapper
1315
1432
 
1316
- class _StreamIteratorWrapper(ObjectProxy): # type: ignore
1317
- def __init__(
1318
- self,
1319
- response: Any,
1320
- instance: Any,
1321
- instrumentor: _PayiInstrumentor,
1322
- stopwatch: Stopwatch,
1323
- request: _ProviderRequest,
1324
- ) -> None:
1325
-
1326
- instrumentor._logger.debug(f"StreamIteratorWrapper: instance {instance}, category {request._category}")
1327
-
1328
- request.process_initial_stream_response(response)
1329
-
1330
- bedrock_from_stream: bool = False
1331
- if request.is_aws_client:
1332
- stream = response.get("stream", None)
1333
-
1334
- if stream:
1335
- response = stream
1336
- bedrock_from_stream = True
1337
- else:
1338
- response = response.get("body")
1339
- bedrock_from_stream = False
1340
-
1341
- super().__init__(response) # type: ignore
1342
-
1343
- self._response = response
1344
- self._instance = instance
1345
-
1346
- self._instrumentor = instrumentor
1347
- self._stopwatch: Stopwatch = stopwatch
1348
- self._responses: list[str] = []
1349
-
1350
- self._request: _ProviderRequest = request
1351
-
1352
- self._first_token: bool = True
1353
- self._bedrock_from_stream: bool = bedrock_from_stream
1354
- self._ingested: bool = False
1355
- self._iter_started: bool = False
1356
-
1357
- def __enter__(self) -> Any:
1358
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: __enter__")
1359
- return self
1360
-
1361
- def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
1362
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: __exit__")
1363
- self.__wrapped__.__exit__(exc_type, exc_val, exc_tb) # type: ignore
1364
-
1365
- async def __aenter__(self) -> Any:
1366
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: __aenter__")
1367
- return self
1368
-
1369
- async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
1370
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: __aexit__")
1371
- await self.__wrapped__.__aexit__(exc_type, exc_val, exc_tb) # type: ignore
1372
-
1373
- def __iter__(self) -> Any:
1374
- self._iter_started = True
1375
- if self._request.is_aws_client:
1376
- # MUST reside in a separate function so that the yield statement (e.g. the generator) doesn't implicitly return its own iterator and overriding self
1377
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: bedrock __iter__")
1378
- return self._iter_bedrock()
1379
-
1380
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: __iter__")
1381
- return self
1382
-
1383
- def _iter_bedrock(self) -> Any:
1384
- # botocore EventStream doesn't have a __next__ method so iterate over the wrapped object in place
1385
- for event in self.__wrapped__: # type: ignore
1386
- result: Optional[_ChunkResult] = None
1387
-
1388
- if (self._bedrock_from_stream):
1389
- result = self._evaluate_chunk(event)
1390
- else:
1391
- chunk = event.get('chunk') # type: ignore
1392
- if chunk:
1393
- decode = chunk.get('bytes').decode() # type: ignore
1394
- result = self._evaluate_chunk(decode)
1395
-
1396
- if result and result.ingest:
1397
- self._stop_iteration()
1398
-
1399
- yield event
1400
-
1401
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: bedrock iter finished")
1402
-
1403
- self._stop_iteration()
1404
-
1405
- def __aiter__(self) -> Any:
1406
- self._iter_started = True
1407
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: __aiter__")
1408
- return self
1409
-
1410
- def __next__(self) -> object:
1411
- try:
1412
- chunk: object = self.__wrapped__.__next__() # type: ignore
1413
-
1414
- if self._ingested:
1415
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: __next__ already ingested, not processing chunk {chunk}")
1416
- return chunk # type: ignore
1417
-
1418
- result = self._evaluate_chunk(chunk)
1419
-
1420
- if result.ingest:
1421
- self._stop_iteration()
1422
-
1423
- if result.send_chunk_to_caller:
1424
- return chunk # type: ignore
1425
- else:
1426
- return self.__next__()
1427
- except Exception as e:
1428
- if isinstance(e, StopIteration):
1429
- self._stop_iteration()
1430
- else:
1431
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: __next__ exception {e}")
1432
- raise e
1433
-
1434
- async def __anext__(self) -> object:
1435
- try:
1436
- chunk: object = await self.__wrapped__.__anext__() # type: ignore
1437
-
1438
- if self._ingested:
1439
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: __next__ already ingested, not processing chunk {chunk}")
1440
- return chunk # type: ignore
1441
-
1442
- result = self._evaluate_chunk(chunk)
1443
-
1444
- if result.ingest:
1445
- await self._astop_iteration()
1446
-
1447
- if result.send_chunk_to_caller:
1448
- return chunk # type: ignore
1449
- else:
1450
- return await self.__anext__()
1451
-
1452
- except Exception as e:
1453
- if isinstance(e, StopAsyncIteration):
1454
- await self._astop_iteration()
1455
- else:
1456
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: __anext__ exception {e}")
1457
- raise e
1458
-
1459
- def _evaluate_chunk(self, chunk: Any) -> _ChunkResult:
1460
- if self._first_token:
1461
- self._request._ingest["time_to_first_token_ms"] = self._stopwatch.elapsed_ms_int()
1462
- self._first_token = False
1463
-
1464
- if self._instrumentor._log_prompt_and_response:
1465
- self._responses.append(self.chunk_to_json(chunk))
1466
-
1467
- return self._request.process_chunk(chunk)
1468
-
1469
- def _process_stop_iteration(self) -> None:
1470
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: process stop iteration")
1471
-
1472
- self._stopwatch.stop()
1473
- self._request._ingest["end_to_end_latency_ms"] = self._stopwatch.elapsed_ms_int()
1474
- self._request._ingest["http_status_code"] = 200
1475
-
1476
- if self._instrumentor._log_prompt_and_response:
1477
- self._request._ingest["provider_response_json"] = self._responses
1478
-
1479
- async def _astop_iteration(self) -> None:
1480
- if self._ingested:
1481
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: astop iteration already ingested, skipping")
1482
- return
1483
-
1484
- self._process_stop_iteration()
1485
-
1486
- await self._instrumentor._aingest_units(self._request)
1487
- self._ingested = True
1488
-
1489
- def _stop_iteration(self) -> None:
1490
- if self._ingested:
1491
- self._instrumentor._logger.debug(f"StreamIteratorWrapper: stop iteration already ingested, skipping")
1492
- return
1493
-
1494
- self._process_stop_iteration()
1495
- self._instrumentor._ingest_units(self._request)
1496
- self._ingested = True
1497
-
1498
- @staticmethod
1499
- def chunk_to_json(chunk: Any) -> str:
1500
- if hasattr(chunk, "to_json"):
1501
- return str(chunk.to_json())
1502
- elif isinstance(chunk, bytes):
1503
- return chunk.decode()
1504
- elif isinstance(chunk, str):
1505
- return chunk
1506
- else:
1507
- # assume dict
1508
- return json.dumps(chunk)
1509
-
1510
- class _StreamManagerWrapper(ObjectProxy): # type: ignore
1511
- def __init__(
1512
- self,
1513
- stream_manager: Any, # type: ignore
1514
- instance: Any,
1515
- instrumentor: _PayiInstrumentor,
1516
- stopwatch: Stopwatch,
1517
- request: _ProviderRequest,
1518
- ) -> None:
1519
- instrumentor._logger.debug(f"StreamManagerWrapper: instance {instance}, category {request._category}")
1520
-
1521
- super().__init__(stream_manager) # type: ignore
1522
-
1523
- self._stream_manager = stream_manager
1524
- self._instance = instance
1525
- self._instrumentor = instrumentor
1526
- self._stopwatch: Stopwatch = stopwatch
1527
- self._responses: list[str] = []
1528
- self._request: _ProviderRequest = request
1529
- self._first_token: bool = True
1530
-
1531
- def __enter__(self) -> _StreamIteratorWrapper:
1532
- self._instrumentor._logger.debug(f"_StreamManagerWrapper: __enter__")
1533
-
1534
- return _StreamIteratorWrapper(
1535
- response=self.__wrapped__.__enter__(), # type: ignore
1536
- instance=self._instance,
1537
- instrumentor=self._instrumentor,
1538
- stopwatch=self._stopwatch,
1539
- request=self._request,
1540
- )
1541
-
1542
- class _GeneratorWrapper: # type: ignore
1543
- def __init__(
1544
- self,
1545
- generator: Any,
1546
- instance: Any,
1547
- instrumentor: _PayiInstrumentor,
1548
- stopwatch: Stopwatch,
1549
- request: _ProviderRequest,
1550
- ) -> None:
1551
- instrumentor._logger.debug(f"GeneratorWrapper: instance {instance}, category {request._category}")
1552
-
1553
- super().__init__() # type: ignore
1554
-
1555
- self._generator = generator
1556
- self._instance = instance
1557
- self._instrumentor = instrumentor
1558
- self._stopwatch: Stopwatch = stopwatch
1559
- self._log_prompt_and_response: bool = instrumentor._log_prompt_and_response
1560
- self._responses: list[str] = []
1561
- self._request: _ProviderRequest = request
1562
- self._first_token: bool = True
1563
- self._ingested: bool = False
1564
- self._iter_started: bool = False
1565
-
1566
- def __iter__(self) -> Any:
1567
- self._iter_started = True
1568
- self._instrumentor._logger.debug(f"GeneratorWrapper: __iter__")
1569
- return self
1570
-
1571
- def __aiter__(self) -> Any:
1572
- self._instrumentor._logger.debug(f"GeneratorWrapper: __aiter__")
1573
- return self
1574
-
1575
- def _process_chunk(self, chunk: Any) -> _ChunkResult:
1576
- if self._first_token:
1577
- self._request._ingest["time_to_first_token_ms"] = self._stopwatch.elapsed_ms_int()
1578
- self._first_token = False
1579
-
1580
- if self._log_prompt_and_response:
1581
- dict = self._chunk_to_dict(chunk)
1582
- self._responses.append(json.dumps(dict))
1583
-
1584
- return self._request.process_chunk(chunk)
1585
-
1586
- def __next__(self) -> Any:
1587
- try:
1588
- chunk = next(self._generator)
1589
- result = self._process_chunk(chunk)
1590
-
1591
- if result.ingest:
1592
- self._stop_iteration()
1593
-
1594
- # ignore result.send_chunk_to_caller:
1595
- return chunk
1596
-
1597
- except Exception as e:
1598
- if isinstance(e, StopIteration):
1599
- self._stop_iteration()
1600
- else:
1601
- self._instrumentor._logger.debug(f"GeneratorWrapper: __next__ exception {e}")
1602
- raise e
1603
-
1604
- async def __anext__(self) -> Any:
1605
- try:
1606
- chunk = await anext(self._generator) # type: ignore
1607
- result = self._process_chunk(chunk)
1608
-
1609
- if result.ingest:
1610
- await self._astop_iteration()
1611
-
1612
- # ignore result.send_chunk_to_caller:
1613
- return chunk # type: ignore
1614
-
1615
- except Exception as e:
1616
- if isinstance(e, StopAsyncIteration):
1617
- await self._astop_iteration()
1618
- else:
1619
- self._instrumentor._logger.debug(f"GeneratorWrapper: __anext__ exception {e}")
1620
- raise e
1621
-
1622
- @staticmethod
1623
- def _chunk_to_dict(chunk: Any) -> 'dict[str, object]':
1624
- if hasattr(chunk, "to_dict"):
1625
- return chunk.to_dict() # type: ignore
1626
- elif hasattr(chunk, "to_json_dict"):
1627
- return chunk.to_json_dict() # type: ignore
1628
- else:
1629
- return {}
1630
-
1631
- def _stop_iteration(self) -> None:
1632
- if self._ingested:
1633
- self._instrumentor._logger.debug(f"GeneratorWrapper: stop iteration already ingested, skipping")
1634
- return
1635
-
1636
- self._process_stop_iteration()
1637
-
1638
- self._instrumentor._ingest_units(self._request)
1639
- self._ingested = True
1640
-
1641
- async def _astop_iteration(self) -> None:
1642
- if self._ingested:
1643
- self._instrumentor._logger.debug(f"GeneratorWrapper: astop iteration already ingested, skipping")
1644
- return
1645
-
1646
- self._process_stop_iteration()
1647
-
1648
- await self._instrumentor._aingest_units(self._request)
1649
- self._ingested = True
1650
-
1651
- def _process_stop_iteration(self) -> None:
1652
- self._instrumentor._logger.debug(f"GeneratorWrapper: stop iteration")
1653
-
1654
- self._stopwatch.stop()
1655
- self._request._ingest["end_to_end_latency_ms"] = self._stopwatch.elapsed_ms_int()
1656
- self._request._ingest["http_status_code"] = 200
1657
-
1658
- if self._log_prompt_and_response:
1659
- self._request._ingest["provider_response_json"] = self._responses
1660
-
1661
1433
  global _instrumentor
1662
1434
  _instrumentor: Optional[_PayiInstrumentor] = None
1663
1435
 
@@ -1666,7 +1438,6 @@ def payi_instrument(
1666
1438
  payi: Optional[Union[Payi, AsyncPayi, 'list[Union[Payi, AsyncPayi]]']] = None,
1667
1439
  instruments: Optional[Set[str]] = None,
1668
1440
  log_prompt_and_response: bool = True,
1669
- prompt_and_response_logger: Optional[Callable[[str, "dict[str, str]"], None]] = None,
1670
1441
  config: Optional[PayiInstrumentConfig] = None,
1671
1442
  logger: Optional[logging.Logger] = None,
1672
1443
  ) -> None:
@@ -1699,7 +1470,6 @@ def payi_instrument(
1699
1470
  instruments=instruments,
1700
1471
  log_prompt_and_response=log_prompt_and_response,
1701
1472
  logger=logger,
1702
- prompt_and_response_logger=prompt_and_response_logger,
1703
1473
  global_config=config if config else PayiInstrumentConfig(),
1704
1474
  caller_filename=caller_filename
1705
1475
  )
@@ -1711,14 +1481,15 @@ def track(
1711
1481
  use_case_id: Optional[str] = None,
1712
1482
  use_case_version: Optional[int] = None,
1713
1483
  user_id: Optional[str] = None,
1484
+ account_name: Optional[str] = None,
1714
1485
  request_tags: Optional["list[str]"] = None,
1715
1486
  request_properties: Optional["dict[str, str]"] = None,
1716
1487
  use_case_properties: Optional["dict[str, str]"] = None,
1717
1488
  proxy: Optional[bool] = None,
1718
1489
  ) -> Any:
1490
+ _ = request_tags
1719
1491
 
1720
1492
  def _track(func: Any) -> Any:
1721
- import asyncio
1722
1493
  if asyncio.iscoroutinefunction(func):
1723
1494
  async def awrapper(*args: Any, **kwargs: Any) -> Any:
1724
1495
  if not _instrumentor:
@@ -1735,9 +1506,9 @@ def track(
1735
1506
  use_case_id,
1736
1507
  use_case_version,
1737
1508
  user_id,
1738
- request_tags,
1739
- request_properties,
1740
- use_case_properties,
1509
+ account_name,
1510
+ cast(Optional['dict[str, Optional[str]]'], request_properties),
1511
+ cast(Optional['dict[str, Optional[str]]'], use_case_properties),
1741
1512
  *args,
1742
1513
  **kwargs,
1743
1514
  )
@@ -1748,7 +1519,7 @@ def track(
1748
1519
  _g_logger.debug(f"track: no instrumentor!")
1749
1520
  return func(*args, **kwargs)
1750
1521
 
1751
- _instrumentor._logger.debug(f"track: call sync function (proxy={proxy}, limit_ids={limit_ids}, use_case_name={use_case_name}, use_case_id={use_case_id}, use_case_version={use_case_version}, user_id={user_id})")
1522
+ _instrumentor._logger.debug(f"track: call sync function (proxy={proxy}, limit_ids={limit_ids}, use_case_name={use_case_name}, use_case_id={use_case_id}, use_case_version={use_case_version}, user_id={user_id}, account_name={account_name})")
1752
1523
 
1753
1524
  return _instrumentor._call_func(
1754
1525
  func,
@@ -1758,9 +1529,9 @@ def track(
1758
1529
  use_case_id,
1759
1530
  use_case_version,
1760
1531
  user_id,
1761
- request_tags,
1762
- request_properties,
1763
- use_case_properties,
1532
+ account_name,
1533
+ cast(Optional['dict[str, Optional[str]]'], request_properties),
1534
+ cast(Optional['dict[str, Optional[str]]'], use_case_properties),
1764
1535
  *args,
1765
1536
  **kwargs,
1766
1537
  )
@@ -1775,6 +1546,7 @@ def track_context(
1775
1546
  use_case_version: Optional[int] = None,
1776
1547
  use_case_step: Optional[str] = None,
1777
1548
  user_id: Optional[str] = None,
1549
+ account_name: Optional[str] = None,
1778
1550
  request_tags: Optional["list[str]"] = None,
1779
1551
  request_properties: Optional["dict[str, str]"] = None,
1780
1552
  use_case_properties: Optional["dict[str, str]"] = None,
@@ -1796,14 +1568,16 @@ def track_context(
1796
1568
  context["use_case_step"] = use_case_step
1797
1569
 
1798
1570
  context["user_id"] = user_id
1799
- context["request_tags"] = request_tags
1571
+ context["account_name"] = account_name
1800
1572
 
1801
1573
  context["price_as_category"] = price_as_category
1802
1574
  context["price_as_resource"] = price_as_resource
1803
1575
  context["resource_scope"] = resource_scope
1804
1576
 
1805
- context["request_properties"] = request_properties
1806
- context["use_case_properties"] = use_case_properties
1577
+ context["request_properties"] = cast(Optional['dict[str, Optional[str]]'], request_properties)
1578
+ context["use_case_properties"] = cast(Optional['dict[str, Optional[str]]'], use_case_properties)
1579
+
1580
+ _ = request_tags
1807
1581
 
1808
1582
  return _InternalTrackContext(context)
1809
1583
 
@@ -1814,7 +1588,7 @@ def get_context() -> PayiContext:
1814
1588
  """
1815
1589
  if not _instrumentor:
1816
1590
  return PayiContext()
1817
- internal_context = _instrumentor.get_context() or {}
1591
+ internal_context = _instrumentor._context_safe
1818
1592
 
1819
1593
  context_dict = {
1820
1594
  key: value