posthoganalytics 7.6.0__py3-none-any.whl → 7.8.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.
- posthoganalytics/ai/__init__.py +3 -0
- posthoganalytics/ai/anthropic/anthropic_converter.py +18 -0
- posthoganalytics/ai/gemini/gemini_converter.py +7 -0
- posthoganalytics/ai/openai/openai_converter.py +19 -0
- posthoganalytics/ai/openai_agents/__init__.py +76 -0
- posthoganalytics/ai/openai_agents/processor.py +863 -0
- posthoganalytics/ai/prompts.py +271 -0
- posthoganalytics/ai/types.py +1 -0
- posthoganalytics/ai/utils.py +78 -0
- posthoganalytics/test/ai/__init__.py +0 -0
- posthoganalytics/test/ai/openai_agents/__init__.py +1 -0
- posthoganalytics/test/ai/openai_agents/test_processor.py +810 -0
- posthoganalytics/test/ai/test_prompts.py +577 -0
- posthoganalytics/test/ai/test_sanitization.py +522 -0
- posthoganalytics/test/ai/test_system_prompts.py +363 -0
- posthoganalytics/version.py +1 -1
- {posthoganalytics-7.6.0.dist-info → posthoganalytics-7.8.0.dist-info}/METADATA +1 -1
- {posthoganalytics-7.6.0.dist-info → posthoganalytics-7.8.0.dist-info}/RECORD +21 -12
- {posthoganalytics-7.6.0.dist-info → posthoganalytics-7.8.0.dist-info}/WHEEL +0 -0
- {posthoganalytics-7.6.0.dist-info → posthoganalytics-7.8.0.dist-info}/licenses/LICENSE +0 -0
- {posthoganalytics-7.6.0.dist-info → posthoganalytics-7.8.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prompt management for PostHog AI SDK.
|
|
3
|
+
|
|
4
|
+
Fetch and compile LLM prompts from PostHog with caching and fallback support.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from typing import Any, Dict, Optional, Union
|
|
12
|
+
|
|
13
|
+
from posthoganalytics.request import DEFAULT_HOST, USER_AGENT, _get_session
|
|
14
|
+
from posthoganalytics.utils import remove_trailing_slash
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("posthog")
|
|
17
|
+
|
|
18
|
+
DEFAULT_CACHE_TTL_SECONDS = 300 # 5 minutes
|
|
19
|
+
|
|
20
|
+
PromptVariables = Dict[str, Union[str, int, float, bool]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CachedPrompt:
|
|
24
|
+
"""Cached prompt with metadata."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, prompt: str, fetched_at: float):
|
|
27
|
+
self.prompt = prompt
|
|
28
|
+
self.fetched_at = fetched_at
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_prompt_api_response(data: Any) -> bool:
|
|
32
|
+
"""Check if the response is a valid prompt API response."""
|
|
33
|
+
return (
|
|
34
|
+
isinstance(data, dict)
|
|
35
|
+
and "prompt" in data
|
|
36
|
+
and isinstance(data.get("prompt"), str)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Prompts:
|
|
41
|
+
"""
|
|
42
|
+
Fetch and compile LLM prompts from PostHog.
|
|
43
|
+
|
|
44
|
+
Can be initialized with a PostHog client or with direct options.
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
```python
|
|
48
|
+
from posthoganalytics import Posthog
|
|
49
|
+
from posthoganalytics.ai.prompts import Prompts
|
|
50
|
+
|
|
51
|
+
# With PostHog client
|
|
52
|
+
posthog = Posthog('phc_xxx', host='https://us.i.posthog.com', personal_api_key='phx_xxx')
|
|
53
|
+
prompts = Prompts(posthog)
|
|
54
|
+
|
|
55
|
+
# Or with direct options (no PostHog client needed)
|
|
56
|
+
prompts = Prompts(personal_api_key='phx_xxx', host='https://us.i.posthog.com')
|
|
57
|
+
|
|
58
|
+
# Fetch with caching and fallback
|
|
59
|
+
template = prompts.get('support-system-prompt', fallback='You are a helpful assistant.')
|
|
60
|
+
|
|
61
|
+
# Compile with variables
|
|
62
|
+
system_prompt = prompts.compile(template, {
|
|
63
|
+
'company': 'Acme Corp',
|
|
64
|
+
'tier': 'premium',
|
|
65
|
+
})
|
|
66
|
+
```
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
posthog: Optional[Any] = None,
|
|
72
|
+
*,
|
|
73
|
+
personal_api_key: Optional[str] = None,
|
|
74
|
+
host: Optional[str] = None,
|
|
75
|
+
default_cache_ttl_seconds: Optional[int] = None,
|
|
76
|
+
):
|
|
77
|
+
"""
|
|
78
|
+
Initialize Prompts.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
posthog: PostHog client instance (optional if personal_api_key provided)
|
|
82
|
+
personal_api_key: Direct API key (optional if posthog provided)
|
|
83
|
+
host: PostHog host (defaults to US ingestion endpoint)
|
|
84
|
+
default_cache_ttl_seconds: Default cache TTL (defaults to 300)
|
|
85
|
+
"""
|
|
86
|
+
self._default_cache_ttl_seconds = (
|
|
87
|
+
default_cache_ttl_seconds or DEFAULT_CACHE_TTL_SECONDS
|
|
88
|
+
)
|
|
89
|
+
self._cache: Dict[str, CachedPrompt] = {}
|
|
90
|
+
|
|
91
|
+
if posthog is not None:
|
|
92
|
+
self._personal_api_key = getattr(posthog, "personal_api_key", None) or ""
|
|
93
|
+
self._host = remove_trailing_slash(
|
|
94
|
+
getattr(posthog, "raw_host", None) or DEFAULT_HOST
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
self._personal_api_key = personal_api_key or ""
|
|
98
|
+
self._host = remove_trailing_slash(host or DEFAULT_HOST)
|
|
99
|
+
|
|
100
|
+
def get(
|
|
101
|
+
self,
|
|
102
|
+
name: str,
|
|
103
|
+
*,
|
|
104
|
+
cache_ttl_seconds: Optional[int] = None,
|
|
105
|
+
fallback: Optional[str] = None,
|
|
106
|
+
) -> str:
|
|
107
|
+
"""
|
|
108
|
+
Fetch a prompt by name from the PostHog API.
|
|
109
|
+
|
|
110
|
+
Caching behavior:
|
|
111
|
+
1. If cache is fresh, return cached value
|
|
112
|
+
2. If fetch fails and cache exists (stale), return stale cache with warning
|
|
113
|
+
3. If fetch fails and fallback provided, return fallback with warning
|
|
114
|
+
4. If fetch fails with no cache/fallback, raise exception
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
name: The name of the prompt to fetch
|
|
118
|
+
cache_ttl_seconds: Cache TTL in seconds (defaults to instance default)
|
|
119
|
+
fallback: Fallback prompt to use if fetch fails and no cache available
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The prompt string
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
Exception: If the prompt cannot be fetched and no fallback is available
|
|
126
|
+
"""
|
|
127
|
+
ttl = (
|
|
128
|
+
cache_ttl_seconds
|
|
129
|
+
if cache_ttl_seconds is not None
|
|
130
|
+
else self._default_cache_ttl_seconds
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Check cache first
|
|
134
|
+
cached = self._cache.get(name)
|
|
135
|
+
now = time.time()
|
|
136
|
+
|
|
137
|
+
if cached is not None:
|
|
138
|
+
is_fresh = (now - cached.fetched_at) < ttl
|
|
139
|
+
|
|
140
|
+
if is_fresh:
|
|
141
|
+
return cached.prompt
|
|
142
|
+
|
|
143
|
+
# Try to fetch from API
|
|
144
|
+
try:
|
|
145
|
+
prompt = self._fetch_prompt_from_api(name)
|
|
146
|
+
fetched_at = time.time()
|
|
147
|
+
|
|
148
|
+
# Update cache
|
|
149
|
+
self._cache[name] = CachedPrompt(prompt=prompt, fetched_at=fetched_at)
|
|
150
|
+
|
|
151
|
+
return prompt
|
|
152
|
+
|
|
153
|
+
except Exception as error:
|
|
154
|
+
# Fallback order:
|
|
155
|
+
# 1. Return stale cache (with warning)
|
|
156
|
+
if cached is not None:
|
|
157
|
+
log.warning(
|
|
158
|
+
'[PostHog Prompts] Failed to fetch prompt "%s", using stale cache: %s',
|
|
159
|
+
name,
|
|
160
|
+
error,
|
|
161
|
+
)
|
|
162
|
+
return cached.prompt
|
|
163
|
+
|
|
164
|
+
# 2. Return fallback (with warning)
|
|
165
|
+
if fallback is not None:
|
|
166
|
+
log.warning(
|
|
167
|
+
'[PostHog Prompts] Failed to fetch prompt "%s", using fallback: %s',
|
|
168
|
+
name,
|
|
169
|
+
error,
|
|
170
|
+
)
|
|
171
|
+
return fallback
|
|
172
|
+
|
|
173
|
+
# 3. Raise error
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
def compile(self, prompt: str, variables: PromptVariables) -> str:
|
|
177
|
+
"""
|
|
178
|
+
Replace {{variableName}} placeholders with values.
|
|
179
|
+
|
|
180
|
+
Unmatched variables are left unchanged.
|
|
181
|
+
Supports variable names with hyphens and dots (e.g., user-id, company.name).
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
prompt: The prompt template string
|
|
185
|
+
variables: Object containing variable values
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
The compiled prompt string
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def replace_variable(match: re.Match) -> str:
|
|
192
|
+
variable_name = match.group(1)
|
|
193
|
+
|
|
194
|
+
if variable_name in variables:
|
|
195
|
+
return str(variables[variable_name])
|
|
196
|
+
|
|
197
|
+
return match.group(0)
|
|
198
|
+
|
|
199
|
+
return re.sub(r"\{\{([\w.-]+)\}\}", replace_variable, prompt)
|
|
200
|
+
|
|
201
|
+
def clear_cache(self, name: Optional[str] = None) -> None:
|
|
202
|
+
"""
|
|
203
|
+
Clear cached prompts.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
name: Specific prompt to clear. If None, clears all cached prompts.
|
|
207
|
+
"""
|
|
208
|
+
if name is not None:
|
|
209
|
+
self._cache.pop(name, None)
|
|
210
|
+
else:
|
|
211
|
+
self._cache.clear()
|
|
212
|
+
|
|
213
|
+
def _fetch_prompt_from_api(self, name: str) -> str:
|
|
214
|
+
"""
|
|
215
|
+
Fetch prompt from PostHog API.
|
|
216
|
+
|
|
217
|
+
Endpoint: {host}/api/projects/@current/llm_prompts/name/{encoded_name}/
|
|
218
|
+
Auth: Bearer {personal_api_key}
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
name: The name of the prompt to fetch
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
The prompt string
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
Exception: If the prompt cannot be fetched
|
|
228
|
+
"""
|
|
229
|
+
if not self._personal_api_key:
|
|
230
|
+
raise Exception(
|
|
231
|
+
"[PostHog Prompts] personal_api_key is required to fetch prompts. "
|
|
232
|
+
"Please provide it when initializing the Prompts instance."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
encoded_name = urllib.parse.quote(name, safe="")
|
|
236
|
+
url = f"{self._host}/api/projects/@current/llm_prompts/name/{encoded_name}/"
|
|
237
|
+
|
|
238
|
+
headers = {
|
|
239
|
+
"Authorization": f"Bearer {self._personal_api_key}",
|
|
240
|
+
"User-Agent": USER_AGENT,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
response = _get_session().get(url, headers=headers, timeout=10)
|
|
244
|
+
|
|
245
|
+
if not response.ok:
|
|
246
|
+
if response.status_code == 404:
|
|
247
|
+
raise Exception(f'[PostHog Prompts] Prompt "{name}" not found')
|
|
248
|
+
|
|
249
|
+
if response.status_code == 403:
|
|
250
|
+
raise Exception(
|
|
251
|
+
f'[PostHog Prompts] Access denied for prompt "{name}". '
|
|
252
|
+
"Check that your personal_api_key has the correct permissions and the LLM prompts feature is enabled."
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
raise Exception(
|
|
256
|
+
f'[PostHog Prompts] Failed to fetch prompt "{name}": HTTP {response.status_code}'
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
data = response.json()
|
|
261
|
+
except Exception:
|
|
262
|
+
raise Exception(
|
|
263
|
+
f'[PostHog Prompts] Invalid response format for prompt "{name}"'
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if not _is_prompt_api_response(data):
|
|
267
|
+
raise Exception(
|
|
268
|
+
f'[PostHog Prompts] Invalid response format for prompt "{name}"'
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return data["prompt"]
|
posthoganalytics/ai/types.py
CHANGED
|
@@ -64,6 +64,7 @@ class TokenUsage(TypedDict, total=False):
|
|
|
64
64
|
cache_creation_input_tokens: Optional[int]
|
|
65
65
|
reasoning_tokens: Optional[int]
|
|
66
66
|
web_search_count: Optional[int]
|
|
67
|
+
raw_usage: Optional[Any] # Raw provider usage metadata for backend processing
|
|
67
68
|
|
|
68
69
|
|
|
69
70
|
class ProviderResponse(TypedDict, total=False):
|
posthoganalytics/ai/utils.py
CHANGED
|
@@ -13,6 +13,54 @@ from posthoganalytics.ai.types import FormattedMessage, StreamingEventData, Toke
|
|
|
13
13
|
from posthoganalytics.client import Client as PostHogClient
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def serialize_raw_usage(raw_usage: Any) -> Optional[Dict[str, Any]]:
|
|
17
|
+
"""
|
|
18
|
+
Convert raw provider usage objects to JSON-serializable dicts.
|
|
19
|
+
|
|
20
|
+
Handles Pydantic models (OpenAI/Anthropic) and protobuf-like objects (Gemini)
|
|
21
|
+
with a fallback chain to ensure we never pass unserializable objects to PostHog.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
raw_usage: Raw usage object from provider SDK
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Plain dict or None if conversion fails
|
|
28
|
+
"""
|
|
29
|
+
if raw_usage is None:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
# Already a dict
|
|
33
|
+
if isinstance(raw_usage, dict):
|
|
34
|
+
return raw_usage
|
|
35
|
+
|
|
36
|
+
# Try Pydantic model_dump() (OpenAI/Anthropic)
|
|
37
|
+
if hasattr(raw_usage, "model_dump") and callable(raw_usage.model_dump):
|
|
38
|
+
try:
|
|
39
|
+
return raw_usage.model_dump()
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
# Try to_dict() (some protobuf objects)
|
|
44
|
+
if hasattr(raw_usage, "to_dict") and callable(raw_usage.to_dict):
|
|
45
|
+
try:
|
|
46
|
+
return raw_usage.to_dict()
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
# Try __dict__ / vars() for simple objects
|
|
51
|
+
try:
|
|
52
|
+
return vars(raw_usage)
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
# Last resort: convert to string representation
|
|
57
|
+
# This ensures we always return something rather than failing
|
|
58
|
+
try:
|
|
59
|
+
return {"_raw": str(raw_usage)}
|
|
60
|
+
except Exception:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
16
64
|
def merge_usage_stats(
|
|
17
65
|
target: TokenUsage, source: TokenUsage, mode: str = "incremental"
|
|
18
66
|
) -> None:
|
|
@@ -60,6 +108,17 @@ def merge_usage_stats(
|
|
|
60
108
|
current = target.get("web_search_count") or 0
|
|
61
109
|
target["web_search_count"] = max(current, source_web_search)
|
|
62
110
|
|
|
111
|
+
# Merge raw_usage to avoid losing data from earlier events
|
|
112
|
+
# For Anthropic streaming: message_start has input tokens, message_delta has output
|
|
113
|
+
# Note: raw_usage is already serialized by converters, so it's a dict
|
|
114
|
+
source_raw_usage = source.get("raw_usage")
|
|
115
|
+
if source_raw_usage is not None and isinstance(source_raw_usage, dict):
|
|
116
|
+
current_raw_value = target.get("raw_usage")
|
|
117
|
+
current_raw: Dict[str, Any] = (
|
|
118
|
+
current_raw_value if isinstance(current_raw_value, dict) else {}
|
|
119
|
+
)
|
|
120
|
+
target["raw_usage"] = {**current_raw, **source_raw_usage}
|
|
121
|
+
|
|
63
122
|
elif mode == "cumulative":
|
|
64
123
|
# Replace with latest values (already cumulative)
|
|
65
124
|
if source.get("input_tokens") is not None:
|
|
@@ -76,6 +135,9 @@ def merge_usage_stats(
|
|
|
76
135
|
target["reasoning_tokens"] = source["reasoning_tokens"]
|
|
77
136
|
if source.get("web_search_count") is not None:
|
|
78
137
|
target["web_search_count"] = source["web_search_count"]
|
|
138
|
+
# Note: raw_usage is already serialized by converters, so it's a dict
|
|
139
|
+
if source.get("raw_usage") is not None:
|
|
140
|
+
target["raw_usage"] = source["raw_usage"]
|
|
79
141
|
|
|
80
142
|
else:
|
|
81
143
|
raise ValueError(f"Invalid mode: {mode}. Must be 'incremental' or 'cumulative'")
|
|
@@ -332,6 +394,11 @@ def call_llm_and_track_usage(
|
|
|
332
394
|
if web_search_count is not None and web_search_count > 0:
|
|
333
395
|
tag("$ai_web_search_count", web_search_count)
|
|
334
396
|
|
|
397
|
+
raw_usage = usage.get("raw_usage")
|
|
398
|
+
if raw_usage is not None:
|
|
399
|
+
# Already serialized by converters
|
|
400
|
+
tag("$ai_usage", raw_usage)
|
|
401
|
+
|
|
335
402
|
if posthog_distinct_id is None:
|
|
336
403
|
tag("$process_person_profile", False)
|
|
337
404
|
|
|
@@ -457,6 +524,11 @@ async def call_llm_and_track_usage_async(
|
|
|
457
524
|
if web_search_count is not None and web_search_count > 0:
|
|
458
525
|
tag("$ai_web_search_count", web_search_count)
|
|
459
526
|
|
|
527
|
+
raw_usage = usage.get("raw_usage")
|
|
528
|
+
if raw_usage is not None:
|
|
529
|
+
# Already serialized by converters
|
|
530
|
+
tag("$ai_usage", raw_usage)
|
|
531
|
+
|
|
460
532
|
if posthog_distinct_id is None:
|
|
461
533
|
tag("$process_person_profile", False)
|
|
462
534
|
|
|
@@ -594,6 +666,12 @@ def capture_streaming_event(
|
|
|
594
666
|
):
|
|
595
667
|
event_properties["$ai_web_search_count"] = web_search_count
|
|
596
668
|
|
|
669
|
+
# Add raw usage metadata if present (all providers)
|
|
670
|
+
raw_usage = event_data["usage_stats"].get("raw_usage")
|
|
671
|
+
if raw_usage is not None:
|
|
672
|
+
# Already serialized by converters
|
|
673
|
+
event_properties["$ai_usage"] = raw_usage
|
|
674
|
+
|
|
597
675
|
# Handle provider-specific fields
|
|
598
676
|
if (
|
|
599
677
|
event_data["provider"] == "openai"
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Tests for OpenAI Agents SDK integration
|