revenium-python-sdk 0.1.0__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 (73) hide show
  1. revenium_middleware/__init__.py +184 -0
  2. revenium_middleware/_core/__init__.py +65 -0
  3. revenium_middleware/_core/config.py +165 -0
  4. revenium_middleware/_core/context.py +109 -0
  5. revenium_middleware/_core/decorators.py +202 -0
  6. revenium_middleware/_core/metering.py +207 -0
  7. revenium_middleware/_core/prompt_extraction.py +55 -0
  8. revenium_middleware/_core/subscriber.py +51 -0
  9. revenium_middleware/_core/trace_fields.py +265 -0
  10. revenium_middleware/anthropic/__init__.py +108 -0
  11. revenium_middleware/anthropic/bedrock_adapter.py +753 -0
  12. revenium_middleware/anthropic/config.py +29 -0
  13. revenium_middleware/anthropic/middleware.py +1070 -0
  14. revenium_middleware/anthropic/prompt_extractor.py +178 -0
  15. revenium_middleware/anthropic/provider.py +141 -0
  16. revenium_middleware/anthropic/summary_printer.py +286 -0
  17. revenium_middleware/anthropic/trace_fields.py +158 -0
  18. revenium_middleware/google/__init__.py +114 -0
  19. revenium_middleware/google/common/__init__.py +127 -0
  20. revenium_middleware/google/common/exceptions.py +137 -0
  21. revenium_middleware/google/common/protocols.py +192 -0
  22. revenium_middleware/google/common/summary_printer.py +271 -0
  23. revenium_middleware/google/common/trace_fields.py +205 -0
  24. revenium_middleware/google/common/types.py +208 -0
  25. revenium_middleware/google/common/utils.py +1111 -0
  26. revenium_middleware/google/config.py +64 -0
  27. revenium_middleware/google/google_ai/__init__.py +53 -0
  28. revenium_middleware/google/google_ai/middleware.py +667 -0
  29. revenium_middleware/google/google_ai/provider.py +135 -0
  30. revenium_middleware/google/prompt_extractor.py +396 -0
  31. revenium_middleware/google/vertex_ai/__init__.py +56 -0
  32. revenium_middleware/google/vertex_ai/middleware.py +1162 -0
  33. revenium_middleware/google/vertex_ai/provider.py +99 -0
  34. revenium_middleware/litellm/__init__.py +25 -0
  35. revenium_middleware/litellm/client/__init__.py +81 -0
  36. revenium_middleware/litellm/client/config.py +53 -0
  37. revenium_middleware/litellm/client/context.py +198 -0
  38. revenium_middleware/litellm/client/decorators.py +912 -0
  39. revenium_middleware/litellm/client/hooks.py +192 -0
  40. revenium_middleware/litellm/client/integrations/__init__.py +26 -0
  41. revenium_middleware/litellm/client/integrations/crewai.py +446 -0
  42. revenium_middleware/litellm/client/middleware.py +321 -0
  43. revenium_middleware/litellm/client/summary_printer.py +314 -0
  44. revenium_middleware/litellm/client/trace_fields.py +51 -0
  45. revenium_middleware/litellm/client/validation.py +207 -0
  46. revenium_middleware/litellm/proxy/__init__.py +25 -0
  47. revenium_middleware/litellm/proxy/middleware.py +217 -0
  48. revenium_middleware/ollama/__init__.py +28 -0
  49. revenium_middleware/ollama/middleware.py +569 -0
  50. revenium_middleware/ollama/trace_fields.py +63 -0
  51. revenium_middleware/openai/__init__.py +23 -0
  52. revenium_middleware/openai/azure_config.py +169 -0
  53. revenium_middleware/openai/azure_model_resolver.py +219 -0
  54. revenium_middleware/openai/config.py +45 -0
  55. revenium_middleware/openai/exceptions.py +115 -0
  56. revenium_middleware/openai/langchain/__init__.py +114 -0
  57. revenium_middleware/openai/langchain/_utils.py +129 -0
  58. revenium_middleware/openai/langchain/unified_handler.py +526 -0
  59. revenium_middleware/openai/middleware.py +1451 -0
  60. revenium_middleware/openai/prompt_extractor.py +173 -0
  61. revenium_middleware/openai/provider.py +170 -0
  62. revenium_middleware/openai/summary_printer.py +292 -0
  63. revenium_middleware/openai/trace_fields.py +98 -0
  64. revenium_middleware/perplexity/__init__.py +97 -0
  65. revenium_middleware/perplexity/middleware.py +379 -0
  66. revenium_middleware/perplexity/perplexity_sdk.py +256 -0
  67. revenium_middleware/perplexity/provider.py +84 -0
  68. revenium_middleware/perplexity/trace_fields.py +25 -0
  69. revenium_python_sdk-0.1.0.dist-info/METADATA +252 -0
  70. revenium_python_sdk-0.1.0.dist-info/RECORD +73 -0
  71. revenium_python_sdk-0.1.0.dist-info/WHEEL +5 -0
  72. revenium_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
  73. revenium_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,202 @@
1
+ """
2
+ Decorators for selective metering in Revenium middleware.
3
+
4
+ This module provides decorators that allow users to selectively meter
5
+ specific functions/endpoints instead of automatically metering all API calls.
6
+ """
7
+
8
+ import functools
9
+ import asyncio
10
+ from typing import Optional, Dict, Any, Callable, TypeVar
11
+
12
+ from .context import (
13
+ set_decorated_context,
14
+ clear_decorated_context,
15
+ set_injected_metadata,
16
+ clear_injected_metadata,
17
+ )
18
+
19
+ # Type variables for generic decorator support
20
+ F = TypeVar('F', bound=Callable[..., Any])
21
+
22
+
23
+ def revenium_meter(
24
+ operation_type: Optional[str] = None,
25
+ metadata: Optional[Dict[str, Any]] = None,
26
+ name: Optional[str] = None
27
+ ) -> Callable[[F], F]:
28
+ """
29
+ Decorator to mark a function for selective metering.
30
+
31
+ When selective metering is enabled in the middleware configuration,
32
+ only functions decorated with @revenium_meter will have their API
33
+ calls metered. When selective metering is disabled (default), this decorator
34
+ has no effect and all API calls are metered automatically.
35
+
36
+ Args:
37
+ operation_type: Optional override for operation type (e.g., 'CHAT', 'EMBED')
38
+ metadata: Optional default metadata to include in metering calls
39
+ name: Optional friendly name for the function (for logging/debugging)
40
+
41
+ Returns:
42
+ Decorated function that sets context when called
43
+
44
+ Example:
45
+ @revenium_meter(metadata={'task_type': 'chat_analysis'})
46
+ def analyze_text(text: str) -> str:
47
+ response = client.chat.completions.create(
48
+ model="gpt-4o-mini",
49
+ messages=[{"role": "user", "content": text}]
50
+ )
51
+ return response.choices[0].message.content
52
+ """
53
+ def decorator(func: F) -> F:
54
+ # Determine if function is async
55
+ is_async = asyncio.iscoroutinefunction(func)
56
+
57
+ if is_async:
58
+ @functools.wraps(func)
59
+ async def async_wrapper(*args, **kwargs):
60
+ # Prepare metadata
61
+ func_metadata = {
62
+ 'function_name': name or func.__name__,
63
+ 'is_decorated': True,
64
+ }
65
+ if operation_type:
66
+ func_metadata['operation_type'] = operation_type
67
+ if metadata:
68
+ func_metadata.update(metadata)
69
+
70
+ # Set context before calling function
71
+ set_decorated_context(True, func_metadata)
72
+ try:
73
+ result = await func(*args, **kwargs)
74
+ return result
75
+ finally:
76
+ # Clear context after function completes
77
+ clear_decorated_context()
78
+
79
+ return async_wrapper # type: ignore
80
+ else:
81
+ @functools.wraps(func)
82
+ def sync_wrapper(*args, **kwargs):
83
+ # Prepare metadata
84
+ func_metadata = {
85
+ 'function_name': name or func.__name__,
86
+ 'is_decorated': True,
87
+ }
88
+ if operation_type:
89
+ func_metadata['operation_type'] = operation_type
90
+ if metadata:
91
+ func_metadata.update(metadata)
92
+
93
+ # Set context before calling function
94
+ set_decorated_context(True, func_metadata)
95
+ try:
96
+ result = func(*args, **kwargs)
97
+ return result
98
+ finally:
99
+ # Clear context after function completes
100
+ clear_decorated_context()
101
+
102
+ return sync_wrapper # type: ignore
103
+
104
+ return decorator
105
+
106
+
107
+ # Alias for convenience
108
+ track_usage = revenium_meter
109
+
110
+
111
+ def revenium_metadata(**metadata_kwargs) -> Callable[[F], F]:
112
+ """
113
+ Decorator to inject metadata into all API calls within a function's scope.
114
+
115
+ This decorator automatically injects metadata into every API call made
116
+ within the decorated function, eliminating the need to pass usage_metadata to
117
+ each individual call. API-level metadata (passed directly to API calls) takes
118
+ precedence over decorator-injected metadata.
119
+
120
+ This decorator works independently of selective metering and can be used:
121
+ - With automatic metering (default behavior)
122
+ - With selective metering (when @revenium_meter is also used)
123
+ - Alongside @revenium_meter on the same function
124
+
125
+ Args:
126
+ **metadata_kwargs: Arbitrary keyword arguments that will be injected as metadata.
127
+ Common fields include:
128
+ - trace_id: Unique identifier for a conversation or session
129
+ - task_type: Classification of the AI operation
130
+ - organization_id: Customer or department ID
131
+ - subscription_id: Reference to a billing plan
132
+ - product_id: Your product or feature making the AI call
133
+ - agent: Identifier for the specific AI agent
134
+ - response_quality_score: Quality metric (0-1)
135
+ - subscriber: Nested object with user information
136
+
137
+ Returns:
138
+ Decorated function that injects metadata into API calls
139
+
140
+ Example:
141
+ @revenium_metadata(org_id="acme", task_type="analysis")
142
+ def analyze_documents(docs):
143
+ # All API calls here automatically get the metadata
144
+ response = client.chat.completions.create(
145
+ model="gpt-4o",
146
+ messages=[{"role": "user", "content": "Analyze this"}]
147
+ )
148
+ return response
149
+
150
+ # Can be combined with @revenium_meter for selective metering
151
+ @revenium_meter()
152
+ @revenium_metadata(org_id="acme", trace_id="session-123")
153
+ def metered_analysis():
154
+ response = client.chat.completions.create(...)
155
+ return response
156
+
157
+ # API-level metadata overrides decorator metadata
158
+ @revenium_metadata(task_type="default")
159
+ def mixed_metadata():
160
+ # Uses decorator metadata: {"task_type": "default"}
161
+ response1 = client.chat.completions.create(...)
162
+
163
+ # API-level overrides: {"task_type": "special"}
164
+ response2 = client.chat.completions.create(
165
+ ...,
166
+ usage_metadata={"task_type": "special"}
167
+ )
168
+ return response1, response2
169
+ """
170
+ def decorator(func: F) -> F:
171
+ # Determine if function is async
172
+ is_async = asyncio.iscoroutinefunction(func)
173
+
174
+ if is_async:
175
+ @functools.wraps(func)
176
+ async def async_wrapper(*args, **kwargs):
177
+ # Set injected metadata context before calling function
178
+ set_injected_metadata(metadata_kwargs)
179
+ try:
180
+ result = await func(*args, **kwargs)
181
+ return result
182
+ finally:
183
+ # Clear injected metadata context after function completes
184
+ clear_injected_metadata()
185
+
186
+ return async_wrapper # type: ignore
187
+ else:
188
+ @functools.wraps(func)
189
+ def sync_wrapper(*args, **kwargs):
190
+ # Set injected metadata context before calling function
191
+ set_injected_metadata(metadata_kwargs)
192
+ try:
193
+ result = func(*args, **kwargs)
194
+ return result
195
+ finally:
196
+ # Clear injected metadata context after function completes
197
+ clear_injected_metadata()
198
+
199
+ return sync_wrapper # type: ignore
200
+
201
+ return decorator
202
+
@@ -0,0 +1,207 @@
1
+ import os
2
+ import time
3
+ import logging
4
+ import asyncio
5
+ import threading
6
+ import atexit
7
+ import signal
8
+ from typing import Literal, Awaitable, Any, Optional, Callable
9
+ from revenium_metering import ReveniumMetering
10
+
11
+ # Get the logger that was configured in __init__.py
12
+ logger = logging.getLogger("revenium_middleware")
13
+
14
+ # Define a StopReason literal type for strict typing of stop_reason
15
+ StopReason = Literal["END", "END_SEQUENCE", "TIMEOUT", "TOKEN_LIMIT", "COST_LIMIT", "COMPLETION_LIMIT", "ERROR"]
16
+
17
+ api_key = os.environ.get("REVENIUM_METERING_API_KEY") or "DUMMY_API_KEY"
18
+ client = ReveniumMetering(api_key=api_key)
19
+
20
+ # Keep track of active metering threads
21
+ active_threads = []
22
+ shutdown_event = threading.Event()
23
+
24
+ def handle_exit(signum=None, frame=None):
25
+ # Check if shutdown is already initiated to prevent redundant logging/actions
26
+ if shutdown_event.is_set():
27
+ return
28
+
29
+ logger.debug("Shutdown initiated, waiting for metering calls to complete...")
30
+ shutdown_event.set()
31
+
32
+ # Give threads a chance to notice the shutdown event
33
+ # Use a small delay, but avoid blocking excessively if called from signal handler
34
+ try:
35
+ time.sleep(0.1)
36
+ except InterruptedError:
37
+ # Handle potential interruption if called from a signal handler during sleep
38
+ logger.debug("Sleep interrupted during shutdown.")
39
+ # Ensure the event is still set
40
+ shutdown_event.set()
41
+
42
+
43
+ # Iterate over a copy of the list to avoid modification issues during iteration
44
+ threads_to_join = list(active_threads)
45
+ for thread in threads_to_join:
46
+ if thread.is_alive():
47
+ logger.debug(f"Waiting for metering thread {thread.name} to finish...")
48
+ thread.join(timeout=5.0) # Wait up to 5 seconds for the thread
49
+ if thread.is_alive():
50
+ logger.warning(f"Metering thread {thread.name} did not complete in time.")
51
+ else:
52
+ logger.debug(f"Metering thread {thread.name} finished.")
53
+ # Clean up thread reference if it's already finished or after joining
54
+ if thread in active_threads:
55
+ try:
56
+ active_threads.remove(thread)
57
+ except ValueError:
58
+ # Thread might have been removed by itself in the finally block
59
+ pass
60
+
61
+
62
+ logger.debug("Shutdown complete")
63
+
64
+ # If called from a signal handler, exit the program
65
+ if signum is not None:
66
+ logger.debug(f"Exiting due to signal {signum}")
67
+ os._exit(0)
68
+
69
+ # Always register atexit handler, works in any thread
70
+ atexit.register(handle_exit)
71
+
72
+ # Only register signal handlers if in the main thread
73
+ if threading.current_thread() is threading.main_thread():
74
+ try:
75
+ signal.signal(signal.SIGINT, handle_exit)
76
+ signal.signal(signal.SIGTERM, handle_exit)
77
+ logger.debug("SIGINT and SIGTERM handlers registered.")
78
+ except ValueError as e:
79
+ # This can happen in environments where signal handling is restricted (e.g., mod_wsgi)
80
+ logger.warning(f"Could not register signal handlers: {e}. Shutdown will rely on atexit.")
81
+ else:
82
+ logger.debug("Not running in main thread, skipping signal handler registration. Shutdown will rely on atexit.")
83
+
84
+
85
+ class MeteringThread(threading.Thread):
86
+ def __init__(self, coro, *args, **kwargs):
87
+ # Default to non-daemon threads so atexit handlers wait for them
88
+ daemon = kwargs.pop('daemon', False)
89
+ super().__init__(*args, **kwargs)
90
+ self.coro = coro
91
+ self.daemon = daemon # Store daemon status
92
+ self.error = None
93
+ self.loop = None
94
+ # Assign a more descriptive name if not provided
95
+ if self.name is None:
96
+ self.name = f"MeteringThread-{id(self)}"
97
+
98
+
99
+ def run(self):
100
+ # Check shutdown event *before* starting the loop
101
+ if shutdown_event.is_set():
102
+ logger.debug(f"Metering thread {self.name} not starting due to shutdown.")
103
+ # Ensure thread is removed from active_threads if it was added
104
+ if self in active_threads:
105
+ try:
106
+ active_threads.remove(self)
107
+ except ValueError:
108
+ pass # Should not happen if logic is correct, but handle defensively
109
+ return
110
+
111
+ try:
112
+ # Create and set a new event loop for this thread
113
+ self.loop = asyncio.new_event_loop()
114
+ asyncio.set_event_loop(self.loop)
115
+ logger.debug(f"Metering thread {self.name} started with loop {id(self.loop)}")
116
+ try:
117
+ # Run the coroutine until it completes
118
+ self.loop.run_until_complete(self.coro)
119
+ finally:
120
+ # Ensure async generators are properly shut down
121
+ logger.debug(f"Shutting down async generators for loop {id(self.loop)} in thread {self.name}")
122
+ self.loop.run_until_complete(self.loop.shutdown_asyncgens())
123
+ # Close the event loop
124
+ logger.debug(f"Closing event loop {id(self.loop)} in thread {self.name}")
125
+ self.loop.close()
126
+ logger.debug(f"Event loop {id(self.loop)} closed in thread {self.name}")
127
+ except Exception as e:
128
+ # Log errors unless it's during shutdown
129
+ if not shutdown_event.is_set():
130
+ self.error = e
131
+ # Use exc_info=True to include traceback in the log
132
+ logger.warning(f"Error in metering thread {self.name}: {str(e)}", exc_info=True)
133
+ else:
134
+ logger.debug(f"Exception ignored in metering thread {self.name} during shutdown: {str(e)}")
135
+ finally:
136
+ # Ensure the thread is removed from the active list upon completion or error
137
+ if self in active_threads:
138
+ try:
139
+ active_threads.remove(self)
140
+ logger.debug(f"Removed thread {self.name} from active list.")
141
+ except ValueError:
142
+ # Can happen if handle_exit removes it first during shutdown join timeout
143
+ logger.debug(f"Thread {self.name} already removed from active list.")
144
+ else:
145
+ # This case might indicate the thread wasn't properly added or removed elsewhere
146
+ logger.warning(f"Thread {self.name} finished but was not found in active_threads list.")
147
+
148
+
149
+ def run_async_in_thread(coroutine_or_func):
150
+ """
151
+ Helper function to run an async coroutine or a regular function in a background thread
152
+ with better handling of interpreter shutdown.
153
+
154
+ Args:
155
+ coroutine_or_func: Either an awaitable coroutine or a regular function
156
+
157
+ Returns:
158
+ Optional[threading.Thread]: The thread running the task, or None if shutdown initiated.
159
+ """
160
+ if shutdown_event.is_set():
161
+ logger.warning("Not starting new metering thread during shutdown")
162
+ return None
163
+
164
+ # Check if we received a coroutine or a regular function
165
+ if asyncio.iscoroutine(coroutine_or_func):
166
+ # It's a coroutine, use it directly
167
+ coro = coroutine_or_func
168
+ elif callable(coroutine_or_func):
169
+ # It's a callable (sync function), wrap it
170
+ async def wrapper():
171
+ # Check shutdown again before potentially long-running sync call
172
+ if shutdown_event.is_set():
173
+ logger.debug("Skipping sync function execution due to shutdown.")
174
+ return None # Or raise an exception? Returning None seems safer.
175
+ try:
176
+ return coroutine_or_func()
177
+ except Exception as e:
178
+ logger.warning(f"Exception in wrapped sync function: {e}", exc_info=True)
179
+ # Propagate or handle error as needed
180
+ raise # Re-raise the exception to be caught by MeteringThread run method
181
+ coro = wrapper()
182
+ else:
183
+ # If it's neither a coroutine nor callable, it's likely an error or unexpected input
184
+ logger.error(f"Invalid type passed to run_async_in_thread: {type(coroutine_or_func)}. Expected coroutine or callable.")
185
+ # Decide how to handle this: return None, raise TypeError, etc.
186
+ # Returning None might be safest to avoid crashing the caller unexpectedly.
187
+ return None
188
+
189
+
190
+ # Create and start the thread
191
+ # Pass daemon=False explicitly if that's the desired default
192
+ thread = MeteringThread(coro, daemon=False)
193
+ active_threads.append(thread)
194
+ logger.debug(f"Starting and adding thread {thread.name} to active list (now {len(active_threads)} threads).")
195
+ try:
196
+ thread.start()
197
+ except RuntimeError as e:
198
+ logger.error(f"Failed to start thread {thread.name}: {e}", exc_info=True)
199
+ # Clean up: remove the thread we failed to start
200
+ if thread in active_threads:
201
+ try:
202
+ active_threads.remove(thread)
203
+ except ValueError:
204
+ pass # Should be there, but handle defensively
205
+ return None # Indicate failure to start
206
+
207
+ return thread
@@ -0,0 +1,55 @@
1
+ """
2
+ Shared prompt extraction utilities.
3
+
4
+ This module provides prompt extraction functions shared across all provider
5
+ middlewares. Provider-specific prompt_extractor modules import from here.
6
+ """
7
+
8
+ import logging
9
+ from typing import Dict, Any, Optional
10
+
11
+ from .config import Config
12
+
13
+ logger = logging.getLogger("revenium_middleware.extension")
14
+
15
+
16
+ def extract_streaming_response_content(
17
+ accumulated_content: str,
18
+ prompts_truncated: bool = False,
19
+ max_prompt_length: Optional[int] = None,
20
+ ) -> Dict[str, Any]:
21
+ """
22
+ Extract output response content from accumulated streaming chunks.
23
+
24
+ Args:
25
+ accumulated_content: Accumulated text from streaming response
26
+ prompts_truncated: Whether prompts were already truncated (from request)
27
+ max_prompt_length: Maximum prompt length (defaults to Config.MAX_PROMPT_LENGTH)
28
+
29
+ Returns:
30
+ Dict containing:
31
+ - outputResponse: String or None (assistant response content)
32
+ - promptsTruncated: Boolean (True if any field was truncated)
33
+ """
34
+ if max_prompt_length is None:
35
+ max_prompt_length = Config.MAX_PROMPT_LENGTH
36
+
37
+ output_response = accumulated_content if accumulated_content else None
38
+ was_truncated = prompts_truncated
39
+
40
+ # Apply truncation - keep total at max_prompt_length
41
+ if output_response and len(output_response) > max_prompt_length:
42
+ marker = "...[TRUNCATED]"
43
+ marker_len = len(marker)
44
+ truncate_at = max_prompt_length - marker_len
45
+ output_response = output_response[:truncate_at] + marker
46
+ was_truncated = True
47
+ logger.debug(
48
+ f"Streaming output response truncated to "
49
+ f"{max_prompt_length} characters"
50
+ )
51
+
52
+ return {
53
+ 'outputResponse': output_response,
54
+ 'promptsTruncated': was_truncated
55
+ }
@@ -0,0 +1,51 @@
1
+ """
2
+ Shared subscriber extraction from usage metadata.
3
+
4
+ This module provides the canonical implementation of subscriber extraction
5
+ shared across all provider middlewares.
6
+ """
7
+
8
+
9
+ def extract_subscriber_from_metadata(usage_metadata: dict) -> dict:
10
+ """
11
+ Extract subscriber from usage_metadata.
12
+
13
+ Supports both nested and flat patterns:
14
+ - Nested: usage_metadata["subscriber"]["id"], ["email"], ["credential"]
15
+ - Flat: usage_metadata["subscriber_id"], ["subscriber_email"], etc.
16
+
17
+ Args:
18
+ usage_metadata: Dictionary containing usage metadata
19
+
20
+ Returns:
21
+ Dictionary containing subscriber information (id, email, credential)
22
+ """
23
+ subscriber = {}
24
+
25
+ # Pattern 1: Nested subscriber object (OpenAI, Ollama, Anthropic non-Bedrock, LiteLLM)
26
+ if "subscriber" in usage_metadata and isinstance(usage_metadata["subscriber"], dict):
27
+ nested = usage_metadata["subscriber"]
28
+
29
+ if nested.get("id"):
30
+ subscriber["id"] = nested["id"]
31
+ if nested.get("email"):
32
+ subscriber["email"] = nested["email"]
33
+ if nested.get("credential") and isinstance(nested["credential"], dict):
34
+ subscriber["credential"] = {
35
+ "name": nested["credential"].get("name"),
36
+ "value": nested["credential"].get("value"),
37
+ }
38
+
39
+ # Pattern 2: Flat field pattern (Anthropic Bedrock legacy)
40
+ if not subscriber:
41
+ if usage_metadata.get("subscriber_id"):
42
+ subscriber["id"] = usage_metadata["subscriber_id"]
43
+ if usage_metadata.get("subscriber_email"):
44
+ subscriber["email"] = usage_metadata["subscriber_email"]
45
+ if usage_metadata.get("subscriber_credential_name"):
46
+ subscriber["credential"] = {
47
+ "name": usage_metadata["subscriber_credential_name"],
48
+ "value": usage_metadata.get("subscriber_credential"),
49
+ }
50
+
51
+ return subscriber