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.
- revenium_middleware/__init__.py +184 -0
- revenium_middleware/_core/__init__.py +65 -0
- revenium_middleware/_core/config.py +165 -0
- revenium_middleware/_core/context.py +109 -0
- revenium_middleware/_core/decorators.py +202 -0
- revenium_middleware/_core/metering.py +207 -0
- revenium_middleware/_core/prompt_extraction.py +55 -0
- revenium_middleware/_core/subscriber.py +51 -0
- revenium_middleware/_core/trace_fields.py +265 -0
- revenium_middleware/anthropic/__init__.py +108 -0
- revenium_middleware/anthropic/bedrock_adapter.py +753 -0
- revenium_middleware/anthropic/config.py +29 -0
- revenium_middleware/anthropic/middleware.py +1070 -0
- revenium_middleware/anthropic/prompt_extractor.py +178 -0
- revenium_middleware/anthropic/provider.py +141 -0
- revenium_middleware/anthropic/summary_printer.py +286 -0
- revenium_middleware/anthropic/trace_fields.py +158 -0
- revenium_middleware/google/__init__.py +114 -0
- revenium_middleware/google/common/__init__.py +127 -0
- revenium_middleware/google/common/exceptions.py +137 -0
- revenium_middleware/google/common/protocols.py +192 -0
- revenium_middleware/google/common/summary_printer.py +271 -0
- revenium_middleware/google/common/trace_fields.py +205 -0
- revenium_middleware/google/common/types.py +208 -0
- revenium_middleware/google/common/utils.py +1111 -0
- revenium_middleware/google/config.py +64 -0
- revenium_middleware/google/google_ai/__init__.py +53 -0
- revenium_middleware/google/google_ai/middleware.py +667 -0
- revenium_middleware/google/google_ai/provider.py +135 -0
- revenium_middleware/google/prompt_extractor.py +396 -0
- revenium_middleware/google/vertex_ai/__init__.py +56 -0
- revenium_middleware/google/vertex_ai/middleware.py +1162 -0
- revenium_middleware/google/vertex_ai/provider.py +99 -0
- revenium_middleware/litellm/__init__.py +25 -0
- revenium_middleware/litellm/client/__init__.py +81 -0
- revenium_middleware/litellm/client/config.py +53 -0
- revenium_middleware/litellm/client/context.py +198 -0
- revenium_middleware/litellm/client/decorators.py +912 -0
- revenium_middleware/litellm/client/hooks.py +192 -0
- revenium_middleware/litellm/client/integrations/__init__.py +26 -0
- revenium_middleware/litellm/client/integrations/crewai.py +446 -0
- revenium_middleware/litellm/client/middleware.py +321 -0
- revenium_middleware/litellm/client/summary_printer.py +314 -0
- revenium_middleware/litellm/client/trace_fields.py +51 -0
- revenium_middleware/litellm/client/validation.py +207 -0
- revenium_middleware/litellm/proxy/__init__.py +25 -0
- revenium_middleware/litellm/proxy/middleware.py +217 -0
- revenium_middleware/ollama/__init__.py +28 -0
- revenium_middleware/ollama/middleware.py +569 -0
- revenium_middleware/ollama/trace_fields.py +63 -0
- revenium_middleware/openai/__init__.py +23 -0
- revenium_middleware/openai/azure_config.py +169 -0
- revenium_middleware/openai/azure_model_resolver.py +219 -0
- revenium_middleware/openai/config.py +45 -0
- revenium_middleware/openai/exceptions.py +115 -0
- revenium_middleware/openai/langchain/__init__.py +114 -0
- revenium_middleware/openai/langchain/_utils.py +129 -0
- revenium_middleware/openai/langchain/unified_handler.py +526 -0
- revenium_middleware/openai/middleware.py +1451 -0
- revenium_middleware/openai/prompt_extractor.py +173 -0
- revenium_middleware/openai/provider.py +170 -0
- revenium_middleware/openai/summary_printer.py +292 -0
- revenium_middleware/openai/trace_fields.py +98 -0
- revenium_middleware/perplexity/__init__.py +97 -0
- revenium_middleware/perplexity/middleware.py +379 -0
- revenium_middleware/perplexity/perplexity_sdk.py +256 -0
- revenium_middleware/perplexity/provider.py +84 -0
- revenium_middleware/perplexity/trace_fields.py +25 -0
- revenium_python_sdk-0.1.0.dist-info/METADATA +252 -0
- revenium_python_sdk-0.1.0.dist-info/RECORD +73 -0
- revenium_python_sdk-0.1.0.dist-info/WHEEL +5 -0
- revenium_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|