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,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prompt extraction utilities for capturing AI prompts and responses.
|
|
3
|
+
|
|
4
|
+
This module provides functions to extract and truncate prompts from OpenAI API
|
|
5
|
+
requests and responses for optional storage in Revenium analytics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Dict, Any, Optional, List
|
|
11
|
+
|
|
12
|
+
from .config import Config
|
|
13
|
+
from revenium_middleware._core.prompt_extraction import ( # noqa: F401 — re-exported
|
|
14
|
+
extract_streaming_response_content,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("revenium_middleware.extension")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def extract_prompts_from_request(kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
21
|
+
"""
|
|
22
|
+
Extract system prompt and input messages from OpenAI API request.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
kwargs: The kwargs dict passed to the OpenAI API call
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Dict containing:
|
|
29
|
+
- systemPrompt: String or None (system message content)
|
|
30
|
+
- inputMessages: JSON string or None (non-system messages)
|
|
31
|
+
- promptsTruncated: Boolean (True if any field was truncated)
|
|
32
|
+
"""
|
|
33
|
+
messages = kwargs.get('messages', [])
|
|
34
|
+
|
|
35
|
+
if not messages:
|
|
36
|
+
return {
|
|
37
|
+
'systemPrompt': None,
|
|
38
|
+
'inputMessages': None,
|
|
39
|
+
'promptsTruncated': False
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
system_prompt = None
|
|
43
|
+
user_messages = []
|
|
44
|
+
prompts_truncated = False
|
|
45
|
+
|
|
46
|
+
# Separate system message from other messages
|
|
47
|
+
for msg in messages:
|
|
48
|
+
if not isinstance(msg, dict):
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
role = msg.get('role')
|
|
52
|
+
if role == 'system':
|
|
53
|
+
# Extract system prompt (take first system message if multiple)
|
|
54
|
+
if system_prompt is None:
|
|
55
|
+
content = msg.get('content', '')
|
|
56
|
+
if isinstance(content, str):
|
|
57
|
+
system_prompt = content
|
|
58
|
+
elif isinstance(content, list):
|
|
59
|
+
# Handle content as array (multimodal)
|
|
60
|
+
system_prompt = json.dumps(content)
|
|
61
|
+
else:
|
|
62
|
+
# Collect all non-system messages
|
|
63
|
+
user_messages.append(msg)
|
|
64
|
+
|
|
65
|
+
# Apply truncation to system prompt
|
|
66
|
+
# Keep total length at MAX_PROMPT_LENGTH by subtracting marker length
|
|
67
|
+
if system_prompt and len(system_prompt) > Config.MAX_PROMPT_LENGTH:
|
|
68
|
+
marker = "...[TRUNCATED]"
|
|
69
|
+
marker_len = len(marker)
|
|
70
|
+
truncate_at = Config.MAX_PROMPT_LENGTH - marker_len
|
|
71
|
+
system_prompt = system_prompt[:truncate_at] + marker
|
|
72
|
+
prompts_truncated = True
|
|
73
|
+
logger.debug(f"System prompt truncated to {Config.MAX_PROMPT_LENGTH} characters")
|
|
74
|
+
|
|
75
|
+
# Convert user messages to JSON string
|
|
76
|
+
input_messages = None
|
|
77
|
+
if user_messages:
|
|
78
|
+
try:
|
|
79
|
+
# First, truncate individual message contents to avoid invalid JSON
|
|
80
|
+
# when the total exceeds the limit
|
|
81
|
+
marker = "...[TRUNCATED]"
|
|
82
|
+
marker_len = len(marker)
|
|
83
|
+
truncated_messages = []
|
|
84
|
+
for msg in user_messages:
|
|
85
|
+
truncated_msg = msg.copy()
|
|
86
|
+
content = msg.get('content', '')
|
|
87
|
+
if isinstance(content, str) and len(content) > Config.MAX_PROMPT_LENGTH // 2:
|
|
88
|
+
# Truncate individual messages to half the limit to be safe
|
|
89
|
+
truncate_at = (Config.MAX_PROMPT_LENGTH // 2) - marker_len
|
|
90
|
+
truncated_msg['content'] = content[:truncate_at] + marker
|
|
91
|
+
prompts_truncated = True
|
|
92
|
+
truncated_messages.append(truncated_msg)
|
|
93
|
+
|
|
94
|
+
input_messages = json.dumps(truncated_messages, ensure_ascii=False)
|
|
95
|
+
|
|
96
|
+
# Apply final truncation if still too long (keeps valid JSON structure)
|
|
97
|
+
if len(input_messages) > Config.MAX_PROMPT_LENGTH:
|
|
98
|
+
truncate_at = Config.MAX_PROMPT_LENGTH - marker_len
|
|
99
|
+
input_messages = input_messages[:truncate_at] + marker
|
|
100
|
+
prompts_truncated = True
|
|
101
|
+
logger.debug(
|
|
102
|
+
f"Input messages truncated to "
|
|
103
|
+
f"{Config.MAX_PROMPT_LENGTH} characters"
|
|
104
|
+
)
|
|
105
|
+
except (TypeError, ValueError) as e:
|
|
106
|
+
logger.warning(f"Failed to serialize input messages to JSON: {e}")
|
|
107
|
+
input_messages = None
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
'systemPrompt': system_prompt,
|
|
111
|
+
'inputMessages': input_messages,
|
|
112
|
+
'promptsTruncated': prompts_truncated
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def extract_response_content(response: Any, prompts_truncated: bool = False) -> Dict[str, Any]:
|
|
117
|
+
"""
|
|
118
|
+
Extract output response content from OpenAI API response.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
response: OpenAI API response object (ChatCompletion or similar)
|
|
122
|
+
prompts_truncated: Whether prompts were already truncated (from request)
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Dict containing:
|
|
126
|
+
- outputResponse: String or None (assistant response content)
|
|
127
|
+
- promptsTruncated: Boolean (True if any field was truncated)
|
|
128
|
+
"""
|
|
129
|
+
output_response = None
|
|
130
|
+
was_truncated = prompts_truncated
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
# Extract content from response.choices[0].message.content
|
|
134
|
+
if hasattr(response, 'choices') and response.choices and len(response.choices) > 0:
|
|
135
|
+
first_choice = response.choices[0]
|
|
136
|
+
|
|
137
|
+
if hasattr(first_choice, 'message') and hasattr(first_choice.message, 'content'):
|
|
138
|
+
content = first_choice.message.content
|
|
139
|
+
|
|
140
|
+
if content:
|
|
141
|
+
if isinstance(content, str):
|
|
142
|
+
output_response = content
|
|
143
|
+
else:
|
|
144
|
+
# Handle non-string content (e.g., structured output)
|
|
145
|
+
output_response = json.dumps(content, ensure_ascii=False)
|
|
146
|
+
|
|
147
|
+
# Apply truncation - keep total at MAX_PROMPT_LENGTH
|
|
148
|
+
if len(output_response) > Config.MAX_PROMPT_LENGTH:
|
|
149
|
+
marker = "...[TRUNCATED]"
|
|
150
|
+
marker_len = len(marker)
|
|
151
|
+
truncate_at = Config.MAX_PROMPT_LENGTH - marker_len
|
|
152
|
+
output_response = (
|
|
153
|
+
output_response[:truncate_at]
|
|
154
|
+
+ marker
|
|
155
|
+
)
|
|
156
|
+
was_truncated = True
|
|
157
|
+
logger.debug(
|
|
158
|
+
f"Output response truncated to "
|
|
159
|
+
f"{Config.MAX_PROMPT_LENGTH} characters"
|
|
160
|
+
)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.warning(f"Failed to extract response content: {e}")
|
|
163
|
+
output_response = None
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
'outputResponse': output_response,
|
|
167
|
+
'promptsTruncated': was_truncated
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# extract_streaming_response_content is imported from _core.prompt_extraction
|
|
173
|
+
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provider detection and configuration for Azure OpenAI support.
|
|
3
|
+
|
|
4
|
+
This module handles detection of Azure OpenAI vs standard OpenAI based on:
|
|
5
|
+
1. Environment variables (AZURE_OPENAI_ENDPOINT)
|
|
6
|
+
2. Base URL substring matching ("azure")
|
|
7
|
+
3. Client instance type detection
|
|
8
|
+
|
|
9
|
+
The detection is lazy-loaded and only impacts Azure users.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
from enum import Enum, auto
|
|
16
|
+
from typing import Optional, Any
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("revenium_middleware.extension")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Provider(Enum):
|
|
22
|
+
"""Supported AI providers."""
|
|
23
|
+
OPENAI = auto()
|
|
24
|
+
AZURE_OPENAI = auto()
|
|
25
|
+
OLLAMA = auto() # Existing provider support
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def detect_provider(client: Optional[Any] = None, base_url: Optional[str] = None) -> Provider:
|
|
29
|
+
"""
|
|
30
|
+
Detect which AI provider is being used based on available information.
|
|
31
|
+
|
|
32
|
+
Detection priority:
|
|
33
|
+
1. Client instance type (AzureOpenAI) - most reliable
|
|
34
|
+
2. Base URL substring matching ("azure")
|
|
35
|
+
3. Environment variables (AZURE_OPENAI_ENDPOINT) - only if client suggests Azure
|
|
36
|
+
4. Default to OpenAI
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
client: OpenAI client instance (may be AzureOpenAI)
|
|
40
|
+
base_url: Base URL for API calls
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Provider enum indicating detected provider
|
|
44
|
+
"""
|
|
45
|
+
logger.debug("Detecting AI provider...")
|
|
46
|
+
|
|
47
|
+
# 1. Check client instance type first (most reliable)
|
|
48
|
+
if client and hasattr(client, '__class__'):
|
|
49
|
+
client_class_name = client.__class__.__name__
|
|
50
|
+
if "Azure" in client_class_name:
|
|
51
|
+
logger.debug(f"Azure provider detected via client type: {client_class_name}")
|
|
52
|
+
return Provider.AZURE_OPENAI
|
|
53
|
+
|
|
54
|
+
# 2. Check base URL for Azure substring (broader than just azure.com)
|
|
55
|
+
if base_url and "azure" in str(base_url).lower():
|
|
56
|
+
logger.debug(f"Azure provider detected via base_url substring: {base_url}")
|
|
57
|
+
return Provider.AZURE_OPENAI
|
|
58
|
+
|
|
59
|
+
# 3. Check for client base_url if not provided directly
|
|
60
|
+
if client and hasattr(client, 'base_url') and client.base_url:
|
|
61
|
+
if "azure" in str(client.base_url).lower():
|
|
62
|
+
logger.debug(f"Azure provider detected via client.base_url: {client.base_url}")
|
|
63
|
+
return Provider.AZURE_OPENAI
|
|
64
|
+
|
|
65
|
+
# 4. Check for OLLAMA via base URL patterns
|
|
66
|
+
if base_url and ("localhost:11434" in str(base_url) or "ollama" in str(base_url).lower()):
|
|
67
|
+
logger.debug(f"OLLAMA provider detected via base_url: {base_url}")
|
|
68
|
+
return Provider.OLLAMA
|
|
69
|
+
|
|
70
|
+
if client and hasattr(client, 'base_url') and client.base_url:
|
|
71
|
+
client_url = str(client.base_url).lower()
|
|
72
|
+
if "localhost:11434" in client_url or "ollama" in client_url:
|
|
73
|
+
logger.debug(f"OLLAMA provider detected via client.base_url: {client.base_url}")
|
|
74
|
+
return Provider.OLLAMA
|
|
75
|
+
|
|
76
|
+
# 5. Check environment variables only if we have some indication this might be Azure
|
|
77
|
+
# (This prevents false positives when both Azure and OpenAI configs are present)
|
|
78
|
+
if client and hasattr(client, '__class__') and "Azure" in str(type(client)):
|
|
79
|
+
azure_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
|
|
80
|
+
if azure_endpoint:
|
|
81
|
+
logger.debug(f"Azure provider detected via AZURE_OPENAI_ENDPOINT: {azure_endpoint}")
|
|
82
|
+
return Provider.AZURE_OPENAI
|
|
83
|
+
|
|
84
|
+
# 6. Default to OpenAI
|
|
85
|
+
logger.debug("Defaulting to OpenAI provider")
|
|
86
|
+
return Provider.OPENAI
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_provider_metadata(provider: Provider) -> dict:
|
|
90
|
+
"""
|
|
91
|
+
Get provider-specific metadata for usage records.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
provider: Detected provider
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Dictionary with provider and model_source fields
|
|
98
|
+
"""
|
|
99
|
+
if provider == Provider.AZURE_OPENAI:
|
|
100
|
+
return {
|
|
101
|
+
"provider": "Azure",
|
|
102
|
+
"model_source": "OPENAI"
|
|
103
|
+
}
|
|
104
|
+
elif provider == Provider.OLLAMA:
|
|
105
|
+
return {
|
|
106
|
+
"provider": "OLLAMA",
|
|
107
|
+
"model_source": "OLLAMA"
|
|
108
|
+
}
|
|
109
|
+
else: # OPENAI
|
|
110
|
+
return {
|
|
111
|
+
"provider": "OPENAI",
|
|
112
|
+
"model_source": "OPENAI"
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_azure_provider(provider: Provider) -> bool:
|
|
117
|
+
"""
|
|
118
|
+
Check if the provider is Azure OpenAI.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
provider: Provider to check
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if Azure OpenAI, False otherwise
|
|
125
|
+
"""
|
|
126
|
+
return provider == Provider.AZURE_OPENAI
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Global provider cache to avoid repeated detection
|
|
130
|
+
_detected_provider: Optional[Provider] = None
|
|
131
|
+
_provider_detection_attempted: bool = False
|
|
132
|
+
_provider_lock = threading.Lock() # Thread safety for global state
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_or_detect_provider(client: Optional[Any] = None, base_url: Optional[str] = None,
|
|
136
|
+
force_redetect: bool = False) -> Provider:
|
|
137
|
+
"""
|
|
138
|
+
Get cached provider or detect if not already done.
|
|
139
|
+
|
|
140
|
+
This provides lazy loading - detection only happens when needed and is cached.
|
|
141
|
+
Thread-safe implementation prevents race conditions in multi-threaded environments.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
client: OpenAI client instance
|
|
145
|
+
base_url: Base URL for API calls
|
|
146
|
+
force_redetect: Force re-detection even if cached
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Detected provider
|
|
150
|
+
"""
|
|
151
|
+
global _detected_provider, _provider_detection_attempted
|
|
152
|
+
|
|
153
|
+
# Thread-safe provider detection with double-checked locking pattern
|
|
154
|
+
if force_redetect or not _provider_detection_attempted:
|
|
155
|
+
with _provider_lock:
|
|
156
|
+
# Double-check inside the lock to prevent race conditions
|
|
157
|
+
if force_redetect or not _provider_detection_attempted:
|
|
158
|
+
_detected_provider = detect_provider(client, base_url)
|
|
159
|
+
_provider_detection_attempted = True
|
|
160
|
+
logger.debug(f"Provider detection completed: {_detected_provider}")
|
|
161
|
+
|
|
162
|
+
return _detected_provider
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def reset_provider_cache():
|
|
166
|
+
"""Reset provider detection cache. Useful for testing. Thread-safe implementation."""
|
|
167
|
+
global _detected_provider, _provider_detection_attempted
|
|
168
|
+
with _provider_lock:
|
|
169
|
+
_detected_provider = None
|
|
170
|
+
_provider_detection_attempted = False
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal summary printer for Revenium OpenAI middleware.
|
|
3
|
+
|
|
4
|
+
This module provides functionality to print cost/metrics summaries to the terminal
|
|
5
|
+
after each API request. Supports both human-readable and JSON output formats.
|
|
6
|
+
|
|
7
|
+
Fetches cost data from Revenium's traces API and formats for console display.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from typing import Optional, Dict, Any
|
|
14
|
+
from urllib.parse import urlencode
|
|
15
|
+
from urllib.request import Request, urlopen
|
|
16
|
+
from urllib.error import URLError, HTTPError
|
|
17
|
+
|
|
18
|
+
from .config import (
|
|
19
|
+
Config,
|
|
20
|
+
SummaryFormat,
|
|
21
|
+
get_print_summary_config,
|
|
22
|
+
get_team_id,
|
|
23
|
+
get_base_url,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("revenium_middleware.summary_printer")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CompletionMetrics:
|
|
30
|
+
"""Completion metrics from Revenium API."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, total_cost: Optional[float] = None):
|
|
33
|
+
self.total_cost = total_cost
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fetch_completion_metrics(
|
|
37
|
+
transaction_id: str,
|
|
38
|
+
revenium_api_key: str,
|
|
39
|
+
) -> Optional[CompletionMetrics]:
|
|
40
|
+
"""
|
|
41
|
+
Fetch metrics from Revenium completions API.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
transaction_id: The transaction ID to fetch metrics for
|
|
45
|
+
revenium_api_key: Revenium API key for authentication
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
CompletionMetrics if successful, None otherwise
|
|
49
|
+
"""
|
|
50
|
+
team_id = get_team_id()
|
|
51
|
+
if not team_id:
|
|
52
|
+
logger.debug(
|
|
53
|
+
"Team ID not configured, skipping cost retrieval for summary"
|
|
54
|
+
)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
base_url = get_base_url().rstrip("/")
|
|
58
|
+
# Note: profitstream API uses a different path structure than the metering API
|
|
59
|
+
url = f"{base_url}/profitstream/v2/api/sources/metrics/ai/completions"
|
|
60
|
+
params = {
|
|
61
|
+
"teamId": team_id,
|
|
62
|
+
"transactionId": transaction_id,
|
|
63
|
+
}
|
|
64
|
+
url_with_params = f"{url}?{urlencode(params)}"
|
|
65
|
+
|
|
66
|
+
logger.debug(f"Fetching completion metrics from {url_with_params}")
|
|
67
|
+
|
|
68
|
+
max_retries = Config.SUMMARY_RETRY_ATTEMPTS
|
|
69
|
+
retry_delay = Config.SUMMARY_RETRY_DELAY
|
|
70
|
+
|
|
71
|
+
for attempt in range(max_retries):
|
|
72
|
+
try:
|
|
73
|
+
request = Request(url_with_params)
|
|
74
|
+
request.add_header("Authorization", revenium_api_key)
|
|
75
|
+
request.add_header("Content-Type", "application/json")
|
|
76
|
+
|
|
77
|
+
with urlopen(request, timeout=Config.SUMMARY_API_TIMEOUT) as response:
|
|
78
|
+
if response.status == 200:
|
|
79
|
+
data = json.loads(response.read().decode())
|
|
80
|
+
embedded = data.get("_embedded", {})
|
|
81
|
+
metrics_list = embedded.get("aICompletionMetricResourceList", [])
|
|
82
|
+
|
|
83
|
+
if metrics_list:
|
|
84
|
+
first_metric = metrics_list[0]
|
|
85
|
+
total_cost = first_metric.get("totalCost")
|
|
86
|
+
logger.debug(f"Retrieved cost: {total_cost}")
|
|
87
|
+
return CompletionMetrics(total_cost=total_cost)
|
|
88
|
+
|
|
89
|
+
logger.debug(
|
|
90
|
+
f"No metrics found yet (attempt {attempt + 1}/{max_retries})"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if attempt < max_retries - 1:
|
|
94
|
+
logger.debug(
|
|
95
|
+
f"Waiting for metrics to aggregate "
|
|
96
|
+
f"(attempt {attempt + 1}/{max_retries})..."
|
|
97
|
+
)
|
|
98
|
+
time.sleep(retry_delay)
|
|
99
|
+
|
|
100
|
+
except (HTTPError, URLError) as e:
|
|
101
|
+
logger.debug(
|
|
102
|
+
f"Failed to fetch trace metrics: {e} "
|
|
103
|
+
f"(attempt {attempt + 1}/{max_retries})"
|
|
104
|
+
)
|
|
105
|
+
if attempt < max_retries - 1:
|
|
106
|
+
time.sleep(retry_delay)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.debug(
|
|
109
|
+
f"Unexpected error fetching metrics: {e} "
|
|
110
|
+
f"(attempt {attempt + 1}/{max_retries})"
|
|
111
|
+
)
|
|
112
|
+
if attempt < max_retries - 1:
|
|
113
|
+
time.sleep(retry_delay)
|
|
114
|
+
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def format_and_print_json_summary(
|
|
119
|
+
model: str,
|
|
120
|
+
provider: str,
|
|
121
|
+
duration_seconds: float,
|
|
122
|
+
input_token_count: Optional[int],
|
|
123
|
+
output_token_count: Optional[int],
|
|
124
|
+
total_token_count: Optional[int],
|
|
125
|
+
trace_id: Optional[str],
|
|
126
|
+
metrics: Optional[CompletionMetrics],
|
|
127
|
+
) -> None:
|
|
128
|
+
"""
|
|
129
|
+
Format and print summary in JSON format.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
model: Model name
|
|
133
|
+
provider: Provider name
|
|
134
|
+
duration_seconds: Request duration in seconds
|
|
135
|
+
input_token_count: Input token count
|
|
136
|
+
output_token_count: Output token count
|
|
137
|
+
total_token_count: Total token count
|
|
138
|
+
trace_id: Trace ID
|
|
139
|
+
metrics: Optional completion metrics from API
|
|
140
|
+
"""
|
|
141
|
+
team_id = get_team_id()
|
|
142
|
+
|
|
143
|
+
summary: Dict[str, Any] = {
|
|
144
|
+
"model": model,
|
|
145
|
+
"provider": provider,
|
|
146
|
+
"durationSeconds": round(duration_seconds, 2),
|
|
147
|
+
"inputTokenCount": input_token_count,
|
|
148
|
+
"outputTokenCount": output_token_count,
|
|
149
|
+
"totalTokenCount": total_token_count,
|
|
150
|
+
"cost": metrics.total_cost if metrics and metrics.total_cost is not None else None,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Add cost status if cost is null
|
|
154
|
+
if summary["cost"] is None:
|
|
155
|
+
summary["costStatus"] = "pending" if team_id else "unavailable"
|
|
156
|
+
|
|
157
|
+
# Add trace ID if present
|
|
158
|
+
if trace_id:
|
|
159
|
+
summary["traceId"] = trace_id
|
|
160
|
+
|
|
161
|
+
print(json.dumps(summary))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def format_and_print_human_summary(
|
|
165
|
+
model: str,
|
|
166
|
+
provider: str,
|
|
167
|
+
duration_seconds: float,
|
|
168
|
+
input_token_count: Optional[int],
|
|
169
|
+
output_token_count: Optional[int],
|
|
170
|
+
total_token_count: Optional[int],
|
|
171
|
+
trace_id: Optional[str],
|
|
172
|
+
metrics: Optional[CompletionMetrics],
|
|
173
|
+
) -> None:
|
|
174
|
+
"""
|
|
175
|
+
Format and print summary in human-readable format.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
model: Model name
|
|
179
|
+
provider: Provider name
|
|
180
|
+
duration_seconds: Request duration in seconds
|
|
181
|
+
input_token_count: Input token count
|
|
182
|
+
output_token_count: Output token count
|
|
183
|
+
total_token_count: Total token count
|
|
184
|
+
trace_id: Trace ID
|
|
185
|
+
metrics: Optional completion metrics from API
|
|
186
|
+
"""
|
|
187
|
+
team_id = get_team_id()
|
|
188
|
+
|
|
189
|
+
print("=" * 60)
|
|
190
|
+
print("REVENIUM USAGE SUMMARY")
|
|
191
|
+
print("=" * 60)
|
|
192
|
+
print(f"Model: {model}")
|
|
193
|
+
print(f"Provider: {provider}")
|
|
194
|
+
print(f"Duration: {duration_seconds:.2f}s")
|
|
195
|
+
|
|
196
|
+
print("\nToken Usage:")
|
|
197
|
+
print(f" Input Tokens: {(input_token_count or 0):,}")
|
|
198
|
+
print(f" Output Tokens: {(output_token_count or 0):,}")
|
|
199
|
+
print(f" Total Tokens: {(total_token_count or 0):,}")
|
|
200
|
+
|
|
201
|
+
if metrics and metrics.total_cost is not None:
|
|
202
|
+
print(f"\nCost: ${metrics.total_cost:.6f}")
|
|
203
|
+
else:
|
|
204
|
+
if team_id:
|
|
205
|
+
print(
|
|
206
|
+
"\nCost: Pending (aggregating... check Revenium dashboard)"
|
|
207
|
+
)
|
|
208
|
+
else:
|
|
209
|
+
print(
|
|
210
|
+
"\nCost: Add REVENIUM_TEAM_ID to see pricing "
|
|
211
|
+
"(find your team ID in the Revenium web app)"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if trace_id:
|
|
215
|
+
print(f"\nTrace ID: {trace_id}")
|
|
216
|
+
|
|
217
|
+
print("=" * 60 + "\n")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def print_usage_summary(
|
|
221
|
+
model: str,
|
|
222
|
+
provider: str,
|
|
223
|
+
request_duration: int,
|
|
224
|
+
input_token_count: Optional[int],
|
|
225
|
+
output_token_count: Optional[int],
|
|
226
|
+
total_token_count: Optional[int],
|
|
227
|
+
transaction_id: Optional[str],
|
|
228
|
+
trace_id: Optional[str],
|
|
229
|
+
revenium_api_key: str,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""
|
|
232
|
+
Print usage summary to console (fire-and-forget).
|
|
233
|
+
|
|
234
|
+
This function is called after tracking is complete to optionally display
|
|
235
|
+
a formatted cost/metrics summary in the terminal.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
model: Model name
|
|
239
|
+
provider: Provider name
|
|
240
|
+
request_duration: Request duration in milliseconds
|
|
241
|
+
input_token_count: Input token count
|
|
242
|
+
output_token_count: Output token count
|
|
243
|
+
total_token_count: Total token count
|
|
244
|
+
transaction_id: Transaction ID for fetching metrics
|
|
245
|
+
trace_id: Trace ID for display
|
|
246
|
+
revenium_api_key: Revenium API key for authentication
|
|
247
|
+
"""
|
|
248
|
+
print_summary = get_print_summary_config()
|
|
249
|
+
if not print_summary:
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
# Determine format
|
|
253
|
+
format_type: SummaryFormat = "human" if print_summary is True else print_summary
|
|
254
|
+
|
|
255
|
+
duration_seconds = request_duration / 1000.0
|
|
256
|
+
|
|
257
|
+
# Fetch metrics if team_id and transaction_id are available
|
|
258
|
+
metrics: Optional[CompletionMetrics] = None
|
|
259
|
+
team_id = get_team_id()
|
|
260
|
+
if team_id and transaction_id:
|
|
261
|
+
try:
|
|
262
|
+
metrics = fetch_completion_metrics(transaction_id, revenium_api_key)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.debug(f"Failed to fetch metrics: {e}")
|
|
265
|
+
|
|
266
|
+
# Print summary in the appropriate format
|
|
267
|
+
try:
|
|
268
|
+
if format_type == "json":
|
|
269
|
+
format_and_print_json_summary(
|
|
270
|
+
model=model,
|
|
271
|
+
provider=provider,
|
|
272
|
+
duration_seconds=duration_seconds,
|
|
273
|
+
input_token_count=input_token_count,
|
|
274
|
+
output_token_count=output_token_count,
|
|
275
|
+
total_token_count=total_token_count,
|
|
276
|
+
trace_id=trace_id,
|
|
277
|
+
metrics=metrics,
|
|
278
|
+
)
|
|
279
|
+
else:
|
|
280
|
+
format_and_print_human_summary(
|
|
281
|
+
model=model,
|
|
282
|
+
provider=provider,
|
|
283
|
+
duration_seconds=duration_seconds,
|
|
284
|
+
input_token_count=input_token_count,
|
|
285
|
+
output_token_count=output_token_count,
|
|
286
|
+
total_token_count=total_token_count,
|
|
287
|
+
trace_id=trace_id,
|
|
288
|
+
metrics=metrics,
|
|
289
|
+
)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
logger.debug(f"Failed to format and print summary: {e}")
|
|
292
|
+
|