Compare commits

...
4 Commits
14 changed files with 1053 additions and 128 deletions
+38 -33
View File
@@ -1,38 +1,43 @@
name: 'Release'
name: "Release"
on:
- workflow_dispatch
push:
branches:
- master
paths:
- "posthog/version.py"
workflow_dispatch:
jobs:
release:
name: Publish release
runs-on: ubuntu-latest
release:
name: Publish release
runs-on: ubuntu-latest
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
steps:
- name: Checkout the repository
uses: actions/checkout@v2
with:
fetch-depth: 0
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v2
- name: Detect version
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
- name: Prepare for building release
run: pip install -U pip setuptools wheel twine
- name: Push release to PyPI
run: make release && make release_analytics
- name: Create GitHub release
uses: actions/create-release@v1
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
steps:
- name: Checkout the repository
uses: actions/checkout@v2
with:
fetch-depth: 0
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v2
- name: Detect version
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
- name: Prepare for building release
run: pip install -U pip setuptools wheel twine
- name: Push release to PyPI
run: make release && make release_analytics
- name: Create GitHub release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
with:
tag_name: v${{ env.REPO_VERSION }}
release_name: ${{ env.REPO_VERSION }}
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
with:
tag_name: v${{ env.REPO_VERSION }}
release_name: ${{ env.REPO_VERSION }}
+13
View File
@@ -1,3 +1,16 @@
## 4.2.0 - 2025-05-22
Add support for google gemini
## 4.1.0 - 2025-05-22
Moved ai openai package to a composition approach over inheritance.
## 4.0.1 2025-04-29
1. Remove deprecated `monotonic` library. Use Python's core `time.monotonic` function instead
2. Clarify Python 3.9+ is required
## 4.0.0 - 2025-04-24
1. Added new method `get_feature_flag_result` which returns a `FeatureFlagResult` object. This object breaks down the result of a feature flag into its enabled state, variant, and payload. The benefit of this method is it allows you to retrieve the result of a feature flag and its payload in a single API call. You can call `get_value` on the result to get the value of the feature flag, which is the same value returned by `get_feature_flag` (aka the string `variant` if the flag is a multivariate flag or the `boolean` value if the flag is a boolean flag).
+11
View File
@@ -0,0 +1,11 @@
from .gemini import Client
# Create a genai-like module for perfect drop-in replacement
class _GenAI:
Client = Client
genai = _GenAI()
__all__ = ["Client", "genai"]
+336
View File
@@ -0,0 +1,336 @@
import os
import time
import uuid
from typing import Any, Dict, Optional
try:
from google import genai
except ImportError:
raise ModuleNotFoundError("Please install the Google Gemini SDK to use this feature: 'pip install google-genai'")
from posthog.ai.utils import call_llm_and_track_usage, get_model_params, with_privacy_mode
from posthog.client import Client as PostHogClient
class Client:
"""
A drop-in replacement for genai.Client that automatically sends LLM usage events to PostHog.
Usage:
client = Client(
api_key="your_api_key",
posthog_client=posthog_client,
posthog_distinct_id="default_user", # Optional defaults
posthog_properties={"team": "ai"} # Optional defaults
)
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello world"],
posthog_distinct_id="specific_user" # Override default
)
"""
def __init__(
self,
api_key: Optional[str] = None,
posthog_client: Optional[PostHogClient] = None,
posthog_distinct_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs,
):
"""
Args:
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable
posthog_client: PostHog client for tracking usage
posthog_distinct_id: Default distinct ID for all calls (can be overridden per call)
posthog_properties: Default properties for all calls (can be overridden per call)
posthog_privacy_mode: Default privacy mode for all calls (can be overridden per call)
posthog_groups: Default groups for all calls (can be overridden per call)
**kwargs: Additional arguments (for future compatibility)
"""
if posthog_client is None:
raise ValueError("posthog_client is required for PostHog tracking")
self.models = Models(
api_key=api_key,
posthog_client=posthog_client,
posthog_distinct_id=posthog_distinct_id,
posthog_properties=posthog_properties,
posthog_privacy_mode=posthog_privacy_mode,
posthog_groups=posthog_groups,
**kwargs,
)
class Models:
"""
Models interface that mimics genai.Client().models with PostHog tracking.
"""
_ph_client: PostHogClient # Not None after __init__ validation
def __init__(
self,
api_key: Optional[str] = None,
posthog_client: Optional[PostHogClient] = None,
posthog_distinct_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs,
):
"""
Args:
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable
posthog_client: PostHog client for tracking usage
posthog_distinct_id: Default distinct ID for all calls
posthog_properties: Default properties for all calls
posthog_privacy_mode: Default privacy mode for all calls
posthog_groups: Default groups for all calls
**kwargs: Additional arguments (for future compatibility)
"""
if posthog_client is None:
raise ValueError("posthog_client is required for PostHog tracking")
self._ph_client = posthog_client
# Store default PostHog settings
self._default_distinct_id = posthog_distinct_id
self._default_properties = posthog_properties or {}
self._default_privacy_mode = posthog_privacy_mode
self._default_groups = posthog_groups
# Handle API key - try parameter first, then environment variables
if api_key is None:
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("API_KEY")
if api_key is None:
raise ValueError(
"API key must be provided either as parameter or via GOOGLE_API_KEY/API_KEY environment variable"
)
self._client = genai.Client(api_key=api_key)
self._base_url = "https://generativelanguage.googleapis.com"
def _merge_posthog_params(
self,
call_distinct_id: Optional[str],
call_trace_id: Optional[str],
call_properties: Optional[Dict[str, Any]],
call_privacy_mode: Optional[bool],
call_groups: Optional[Dict[str, Any]],
):
"""Merge call-level PostHog parameters with client defaults."""
# Use call-level values if provided, otherwise fall back to defaults
distinct_id = call_distinct_id if call_distinct_id is not None else self._default_distinct_id
privacy_mode = call_privacy_mode if call_privacy_mode is not None else self._default_privacy_mode
groups = call_groups if call_groups is not None else self._default_groups
# Merge properties: default properties + call properties (call properties override)
properties = dict(self._default_properties)
if call_properties:
properties.update(call_properties)
if call_trace_id is None:
call_trace_id = str(uuid.uuid4())
return distinct_id, call_trace_id, properties, privacy_mode, groups
def generate_content(
self,
model: str,
contents,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: Optional[bool] = None,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Generate content using Gemini's API while tracking usage in PostHog.
This method signature exactly matches genai.Client().models.generate_content()
with additional PostHog tracking parameters.
Args:
model: The model to use (e.g., 'gemini-2.0-flash')
contents: The input content for generation
posthog_distinct_id: ID to associate with the usage event (overrides client default)
posthog_trace_id: Trace UUID for linking events (auto-generated if not provided)
posthog_properties: Extra properties to include in the event (merged with client defaults)
posthog_privacy_mode: Whether to redact sensitive information (overrides client default)
posthog_groups: Group analytics properties (overrides client default)
**kwargs: Arguments passed to Gemini's generate_content
"""
# Merge PostHog parameters
distinct_id, trace_id, properties, privacy_mode, groups = self._merge_posthog_params(
posthog_distinct_id, posthog_trace_id, posthog_properties, posthog_privacy_mode, posthog_groups
)
kwargs_with_contents = {"model": model, "contents": contents, **kwargs}
return call_llm_and_track_usage(
distinct_id,
self._ph_client,
"gemini",
trace_id,
properties,
privacy_mode,
groups,
self._base_url,
self._client.models.generate_content,
**kwargs_with_contents,
)
def _generate_content_streaming(
self,
model: str,
contents,
distinct_id: Optional[str],
trace_id: Optional[str],
properties: Optional[Dict[str, Any]],
privacy_mode: bool,
groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
usage_stats: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
accumulated_content = []
kwargs_without_stream = {"model": model, "contents": contents, **kwargs}
response = self._client.models.generate_content_stream(**kwargs_without_stream)
def generator():
nonlocal usage_stats
nonlocal accumulated_content # noqa: F824
try:
for chunk in response:
if hasattr(chunk, "usage_metadata") and chunk.usage_metadata:
usage_stats = {
"input_tokens": getattr(chunk.usage_metadata, "prompt_token_count", 0),
"output_tokens": getattr(chunk.usage_metadata, "candidates_token_count", 0),
}
if hasattr(chunk, "text") and chunk.text:
accumulated_content.append(chunk.text)
yield chunk
finally:
end_time = time.time()
latency = end_time - start_time
output = "".join(accumulated_content)
self._capture_streaming_event(
model,
contents,
distinct_id,
trace_id,
properties,
privacy_mode,
groups,
kwargs,
usage_stats,
latency,
output,
)
return generator()
def _capture_streaming_event(
self,
model: str,
contents,
distinct_id: Optional[str],
trace_id: Optional[str],
properties: Optional[Dict[str, Any]],
privacy_mode: bool,
groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: Dict[str, int],
latency: float,
output: str,
):
if trace_id is None:
trace_id = str(uuid.uuid4())
event_properties = {
"$ai_provider": "gemini",
"$ai_model": model,
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._ph_client,
privacy_mode,
self._format_input(contents),
),
"$ai_output_choices": with_privacy_mode(
self._ph_client,
privacy_mode,
[{"content": output, "role": "assistant"}],
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": trace_id,
"$ai_base_url": self._base_url,
**(properties or {}),
}
if distinct_id is None:
event_properties["$process_person_profile"] = False
if hasattr(self._ph_client, "capture"):
self._ph_client.capture(
distinct_id=distinct_id,
event="$ai_generation",
properties=event_properties,
groups=groups,
)
def _format_input(self, contents):
"""Format input contents for PostHog tracking"""
if isinstance(contents, str):
return [{"role": "user", "content": contents}]
elif isinstance(contents, list):
formatted = []
for item in contents:
if isinstance(item, str):
formatted.append({"role": "user", "content": item})
elif hasattr(item, "text"):
formatted.append({"role": "user", "content": item.text})
else:
formatted.append({"role": "user", "content": str(item)})
return formatted
else:
return [{"role": "user", "content": str(contents)}]
def generate_content_stream(
self,
model: str,
contents,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: Optional[bool] = None,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
# Merge PostHog parameters
distinct_id, trace_id, properties, privacy_mode, groups = self._merge_posthog_params(
posthog_distinct_id, posthog_trace_id, posthog_properties, posthog_privacy_mode, posthog_groups
)
return self._generate_content_streaming(
model,
contents,
distinct_id,
trace_id,
properties,
privacy_mode,
groups,
**kwargs,
)
+100 -28
View File
@@ -4,7 +4,6 @@ from typing import Any, Dict, List, Optional
try:
import openai
import openai.resources
except ImportError:
raise ModuleNotFoundError("Please install the OpenAI SDK to use this feature: 'pip install openai'")
@@ -29,14 +28,37 @@ class OpenAI(openai.OpenAI):
"""
super().__init__(**kwargs)
self._ph_client = posthog_client
self.chat = WrappedChat(self)
self.embeddings = WrappedEmbeddings(self)
self.beta = WrappedBeta(self)
self.responses = WrappedResponses(self)
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
self._original_embeddings = getattr(self, "embeddings", None)
self._original_beta = getattr(self, "beta", None)
self._original_responses = getattr(self, "responses", None)
# Replace with wrapped versions (only if originals exist)
if self._original_chat is not None:
self.chat = WrappedChat(self, self._original_chat)
if self._original_embeddings is not None:
self.embeddings = WrappedEmbeddings(self, self._original_embeddings)
if self._original_beta is not None:
self.beta = WrappedBeta(self, self._original_beta)
if self._original_responses is not None:
self.responses = WrappedResponses(self, self._original_responses)
class WrappedResponses(openai.resources.responses.Responses):
_client: OpenAI
class WrappedResponses:
"""Wrapper for OpenAI responses that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_responses):
self._client = client
self._original = original_responses
def __getattr__(self, name):
"""Fallback to original responses object for any methods we don't explicitly handle."""
return getattr(self._original, name)
def create(
self,
@@ -69,7 +91,7 @@ class WrappedResponses(openai.resources.responses.Responses):
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().create,
self._original.create,
**kwargs,
)
@@ -85,7 +107,7 @@ class WrappedResponses(openai.resources.responses.Responses):
start_time = time.time()
usage_stats: Dict[str, int] = {}
final_content = []
response = super().create(**kwargs)
response = self._original.create(**kwargs)
def generator():
nonlocal usage_stats
@@ -195,16 +217,32 @@ class WrappedResponses(openai.resources.responses.Responses):
)
class WrappedChat(openai.resources.chat.Chat):
_client: OpenAI
class WrappedChat:
"""Wrapper for OpenAI chat that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_chat):
self._client = client
self._original = original_chat
def __getattr__(self, name):
"""Fallback to original chat object for any methods we don't explicitly handle."""
return getattr(self._original, name)
@property
def completions(self):
return WrappedCompletions(self._client)
return WrappedCompletions(self._client, self._original.completions)
class WrappedCompletions(openai.resources.chat.completions.Completions):
_client: OpenAI
class WrappedCompletions:
"""Wrapper for OpenAI chat completions that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_completions):
self._client = client
self._original = original_completions
def __getattr__(self, name):
"""Fallback to original completions object for any methods we don't explicitly handle."""
return getattr(self._original, name)
def create(
self,
@@ -237,7 +275,7 @@ class WrappedCompletions(openai.resources.chat.completions.Completions):
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().create,
self._original.create,
**kwargs,
)
@@ -257,7 +295,7 @@ class WrappedCompletions(openai.resources.chat.completions.Completions):
if "stream_options" not in kwargs:
kwargs["stream_options"] = {}
kwargs["stream_options"]["include_usage"] = True
response = super().create(**kwargs)
response = self._original.create(**kwargs)
def generator():
nonlocal usage_stats
@@ -383,8 +421,16 @@ class WrappedCompletions(openai.resources.chat.completions.Completions):
)
class WrappedEmbeddings(openai.resources.embeddings.Embeddings):
_client: OpenAI
class WrappedEmbeddings:
"""Wrapper for OpenAI embeddings that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_embeddings):
self._client = client
self._original = original_embeddings
def __getattr__(self, name):
"""Fallback to original embeddings object for any methods we don't explicitly handle."""
return getattr(self._original, name)
def create(
self,
@@ -402,6 +448,8 @@ class WrappedEmbeddings(openai.resources.embeddings.Embeddings):
posthog_distinct_id: Optional ID to associate with the usage event.
posthog_trace_id: Optional trace UUID for linking events.
posthog_properties: Optional dictionary of extra properties to include in the event.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Embeddings API.
Returns:
@@ -411,7 +459,7 @@ class WrappedEmbeddings(openai.resources.embeddings.Embeddings):
posthog_trace_id = str(uuid.uuid4())
start_time = time.time()
response = super().create(**kwargs)
response = self._original.create(**kwargs)
end_time = time.time()
# Extract usage statistics if available
@@ -452,24 +500,48 @@ class WrappedEmbeddings(openai.resources.embeddings.Embeddings):
return response
class WrappedBeta(openai.resources.beta.Beta):
_client: OpenAI
class WrappedBeta:
"""Wrapper for OpenAI beta features that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_beta):
self._client = client
self._original = original_beta
def __getattr__(self, name):
"""Fallback to original beta object for any methods we don't explicitly handle."""
return getattr(self._original, name)
@property
def chat(self):
return WrappedBetaChat(self._client)
return WrappedBetaChat(self._client, self._original.chat)
class WrappedBetaChat(openai.resources.beta.chat.Chat):
_client: OpenAI
class WrappedBetaChat:
"""Wrapper for OpenAI beta chat that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_beta_chat):
self._client = client
self._original = original_beta_chat
def __getattr__(self, name):
"""Fallback to original beta chat object for any methods we don't explicitly handle."""
return getattr(self._original, name)
@property
def completions(self):
return WrappedBetaCompletions(self._client)
return WrappedBetaCompletions(self._client, self._original.completions)
class WrappedBetaCompletions(openai.resources.beta.chat.completions.Completions):
_client: OpenAI
class WrappedBetaCompletions:
"""Wrapper for OpenAI beta chat completions that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_beta_completions):
self._client = client
self._original = original_beta_completions
def __getattr__(self, name):
"""Fallback to original beta completions object for any methods we don't explicitly handle."""
return getattr(self._original, name)
def parse(
self,
@@ -489,6 +561,6 @@ class WrappedBetaCompletions(openai.resources.beta.chat.completions.Completions)
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().parse,
self._original.parse,
**kwargs,
)
+118 -37
View File
@@ -4,7 +4,6 @@ from typing import Any, Dict, List, Optional
try:
import openai
import openai.resources
except ImportError:
raise ModuleNotFoundError("Please install the OpenAI SDK to use this feature: 'pip install openai'")
@@ -23,19 +22,43 @@ class AsyncOpenAI(openai.AsyncOpenAI):
"""
Args:
api_key: OpenAI API key.
posthog_client: If provided, events will be captured via this client instance.
**openai_config: Additional keyword args (e.g. organization="xxx").
posthog_client: If provided, events will be captured via this client instead
of the global posthog.
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
"""
super().__init__(**kwargs)
self._ph_client = posthog_client
self.chat = WrappedChat(self)
self.embeddings = WrappedEmbeddings(self)
self.beta = WrappedBeta(self)
self.responses = WrappedResponses(self)
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
self._original_embeddings = getattr(self, "embeddings", None)
self._original_beta = getattr(self, "beta", None)
self._original_responses = getattr(self, "responses", None)
# Replace with wrapped versions (only if originals exist)
if self._original_chat is not None:
self.chat = WrappedChat(self, self._original_chat)
if self._original_embeddings is not None:
self.embeddings = WrappedEmbeddings(self, self._original_embeddings)
if self._original_beta is not None:
self.beta = WrappedBeta(self, self._original_beta)
if self._original_responses is not None:
self.responses = WrappedResponses(self, self._original_responses)
class WrappedResponses(openai.resources.responses.Responses):
_client: AsyncOpenAI
class WrappedResponses:
"""Async wrapper for OpenAI responses that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_responses):
self._client = client
self._original = original_responses
def __getattr__(self, name):
"""Fallback to original responses object for any methods we don't explicitly handle."""
return getattr(self._original, name)
async def create(
self,
@@ -68,7 +91,7 @@ class WrappedResponses(openai.resources.responses.Responses):
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().create,
self._original.create,
**kwargs,
)
@@ -84,7 +107,7 @@ class WrappedResponses(openai.resources.responses.Responses):
start_time = time.time()
usage_stats: Dict[str, int] = {}
final_content = []
response = await super().create(**kwargs)
response = await self._original.create(**kwargs)
async def async_generator():
nonlocal usage_stats
@@ -186,7 +209,7 @@ class WrappedResponses(openai.resources.responses.Responses):
event_properties["$process_person_profile"] = False
if hasattr(self._client._ph_client, "capture"):
await self._client._ph_client.capture(
self._client._ph_client.capture(
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
@@ -194,16 +217,32 @@ class WrappedResponses(openai.resources.responses.Responses):
)
class WrappedChat(openai.resources.chat.AsyncChat):
_client: AsyncOpenAI
class WrappedChat:
"""Async wrapper for OpenAI chat that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_chat):
self._client = client
self._original = original_chat
def __getattr__(self, name):
"""Fallback to original chat object for any methods we don't explicitly handle."""
return getattr(self._original, name)
@property
def completions(self):
return WrappedCompletions(self._client)
return WrappedCompletions(self._client, self._original.completions)
class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
_client: AsyncOpenAI
class WrappedCompletions:
"""Async wrapper for OpenAI chat completions that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_completions):
self._client = client
self._original = original_completions
def __getattr__(self, name):
"""Fallback to original completions object for any methods we don't explicitly handle."""
return getattr(self._original, name)
async def create(
self,
@@ -237,7 +276,7 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().create,
self._original.create,
**kwargs,
)
return response
@@ -247,21 +286,25 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
usage_stats: Dict[str, int] = {}
accumulated_content = []
accumulated_tools = {}
if "stream_options" not in kwargs:
kwargs["stream_options"] = {}
kwargs["stream_options"]["include_usage"] = True
response = await super().create(**kwargs)
response = await self._original.create(**kwargs)
async def async_generator():
nonlocal usage_stats, accumulated_content, accumulated_tools # noqa: F824
nonlocal usage_stats
nonlocal accumulated_content # noqa: F824
nonlocal accumulated_tools # noqa: F824
try:
async for chunk in response:
if hasattr(chunk, "usage") and chunk.usage:
@@ -280,6 +323,11 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
):
usage_stats["cache_read_input_tokens"] = chunk.usage.prompt_tokens_details.cached_tokens
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
chunk.usage.output_tokens_details, "reasoning_tokens"
):
usage_stats["reasoning_tokens"] = chunk.usage.output_tokens_details.reasoning_tokens
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
if chunk.choices[0].delta and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
@@ -350,6 +398,7 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
@@ -367,7 +416,7 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
event_properties["$process_person_profile"] = False
if hasattr(self._client._ph_client, "capture"):
await self._client._ph_client.capture(
self._client._ph_client.capture(
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
@@ -375,8 +424,16 @@ class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
)
class WrappedEmbeddings(openai.resources.embeddings.AsyncEmbeddings):
_client: AsyncOpenAI
class WrappedEmbeddings:
"""Async wrapper for OpenAI embeddings that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_embeddings):
self._client = client
self._original = original_embeddings
def __getattr__(self, name):
"""Fallback to original embeddings object for any methods we don't explicitly handle."""
return getattr(self._original, name)
async def create(
self,
@@ -394,8 +451,8 @@ class WrappedEmbeddings(openai.resources.embeddings.AsyncEmbeddings):
posthog_distinct_id: Optional ID to associate with the usage event.
posthog_trace_id: Optional trace UUID for linking events.
posthog_properties: Optional dictionary of extra properties to include in the event.
posthog_privacy_mode: Whether to store input and output in PostHog.
posthog_groups: Optional dictionary of groups to include in the event.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Embeddings API.
Returns:
@@ -405,7 +462,7 @@ class WrappedEmbeddings(openai.resources.embeddings.AsyncEmbeddings):
posthog_trace_id = str(uuid.uuid4())
start_time = time.time()
response = await super().create(**kwargs)
response = await self._original.create(**kwargs)
end_time = time.time()
# Extract usage statistics if available
@@ -446,24 +503,48 @@ class WrappedEmbeddings(openai.resources.embeddings.AsyncEmbeddings):
return response
class WrappedBeta(openai.resources.beta.AsyncBeta):
_client: AsyncOpenAI
class WrappedBeta:
"""Async wrapper for OpenAI beta features that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_beta):
self._client = client
self._original = original_beta
def __getattr__(self, name):
"""Fallback to original beta object for any methods we don't explicitly handle."""
return getattr(self._original, name)
@property
def chat(self):
return WrappedBetaChat(self._client)
return WrappedBetaChat(self._client, self._original.chat)
class WrappedBetaChat(openai.resources.beta.chat.AsyncChat):
_client: AsyncOpenAI
class WrappedBetaChat:
"""Async wrapper for OpenAI beta chat that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_beta_chat):
self._client = client
self._original = original_beta_chat
def __getattr__(self, name):
"""Fallback to original beta chat object for any methods we don't explicitly handle."""
return getattr(self._original, name)
@property
def completions(self):
return WrappedBetaCompletions(self._client)
return WrappedBetaCompletions(self._client, self._original.completions)
class WrappedBetaCompletions(openai.resources.beta.chat.completions.AsyncCompletions):
_client: AsyncOpenAI
class WrappedBetaCompletions:
"""Async wrapper for OpenAI beta chat completions that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_beta_completions):
self._client = client
self._original = original_beta_completions
def __getattr__(self, name):
"""Fallback to original beta completions object for any methods we don't explicitly handle."""
return getattr(self._original, name)
async def parse(
self,
@@ -483,6 +564,6 @@ class WrappedBetaCompletions(openai.resources.beta.chat.completions.AsyncComplet
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().parse,
self._original.parse,
**kwargs,
)
+56 -9
View File
@@ -1,13 +1,13 @@
try:
import openai
import openai.resources
except ImportError:
raise ModuleNotFoundError("Please install the Open AI SDK to use this feature: 'pip install openai'")
from posthog.ai.openai.openai import WrappedBeta, WrappedChat, WrappedEmbeddings
from posthog.ai.openai.openai import WrappedBeta, WrappedChat, WrappedEmbeddings, WrappedResponses
from posthog.ai.openai.openai_async import WrappedBeta as AsyncWrappedBeta
from posthog.ai.openai.openai_async import WrappedChat as AsyncWrappedChat
from posthog.ai.openai.openai_async import WrappedEmbeddings as AsyncWrappedEmbeddings
from posthog.ai.openai.openai_async import WrappedResponses as AsyncWrappedResponses
from posthog.client import Client as PostHogClient
@@ -19,23 +19,70 @@ class AzureOpenAI(openai.AzureOpenAI):
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
"""
Args:
api_key: Azure OpenAI API key.
posthog_client: If provided, events will be captured via this client instead
of the global posthog.
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
"""
super().__init__(**kwargs)
self._ph_client = posthog_client
self.chat = WrappedChat(self)
self.embeddings = WrappedEmbeddings(self)
self.beta = WrappedBeta(self)
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
self._original_embeddings = getattr(self, "embeddings", None)
self._original_beta = getattr(self, "beta", None)
self._original_responses = getattr(self, "responses", None)
# Replace with wrapped versions (only if originals exist)
if self._original_chat is not None:
self.chat = WrappedChat(self, self._original_chat)
if self._original_embeddings is not None:
self.embeddings = WrappedEmbeddings(self, self._original_embeddings)
if self._original_beta is not None:
self.beta = WrappedBeta(self, self._original_beta)
if self._original_responses is not None:
self.responses = WrappedResponses(self, self._original_responses)
class AsyncAzureOpenAI(openai.AsyncAzureOpenAI):
"""
A wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog.
An async wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
"""
Args:
api_key: Azure OpenAI API key.
posthog_client: If provided, events will be captured via this client instead
of the global posthog.
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
"""
super().__init__(**kwargs)
self._ph_client = posthog_client
self.chat = AsyncWrappedChat(self)
self.embeddings = AsyncWrappedEmbeddings(self)
self.beta = AsyncWrappedBeta(self)
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
self._original_embeddings = getattr(self, "embeddings", None)
self._original_beta = getattr(self, "beta", None)
self._original_responses = getattr(self, "responses", None)
# Replace with wrapped versions (only if originals exist)
if self._original_chat is not None:
self.chat = AsyncWrappedChat(self, self._original_chat)
if self._original_embeddings is not None:
self.embeddings = AsyncWrappedEmbeddings(self, self._original_embeddings)
if self._original_beta is not None:
self.beta = AsyncWrappedBeta(self, self._original_beta)
# Only add responses if available (newer OpenAI versions)
if self._original_responses is not None:
self.responses = AsyncWrappedResponses(self, self._original_responses)
+69 -2
View File
@@ -73,6 +73,21 @@ def get_usage(response, provider: str) -> Dict[str, Any]:
"cache_read_input_tokens": cached_tokens,
"reasoning_tokens": reasoning_tokens,
}
elif provider == "gemini":
input_tokens = 0
output_tokens = 0
if hasattr(response, "usage_metadata") and response.usage_metadata:
input_tokens = getattr(response.usage_metadata, "prompt_token_count", 0)
output_tokens = getattr(response.usage_metadata, "candidates_token_count", 0)
return {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
"reasoning_tokens": 0,
}
return {
"input_tokens": 0,
"output_tokens": 0,
@@ -93,6 +108,8 @@ def format_response(response, provider: str):
return format_response_anthropic(response)
elif provider == "openai":
return format_response_openai(response)
elif provider == "gemini":
return format_response_gemini(response)
return output
@@ -170,6 +187,40 @@ def format_response_openai(response):
return output
def format_response_gemini(response):
output = []
if hasattr(response, "candidates") and response.candidates:
for candidate in response.candidates:
if hasattr(candidate, "content") and candidate.content:
content_text = ""
if hasattr(candidate.content, "parts") and candidate.content.parts:
for part in candidate.content.parts:
if hasattr(part, "text") and part.text:
content_text += part.text
if content_text:
output.append(
{
"role": "assistant",
"content": content_text,
}
)
elif hasattr(candidate, "text") and candidate.text:
output.append(
{
"role": "assistant",
"content": candidate.text,
}
)
elif hasattr(response, "text") and response.text:
output.append(
{
"role": "assistant",
"content": response.text,
}
)
return output
def format_tool_calls(response, provider: str):
if provider == "anthropic":
if hasattr(response, "tools") and response.tools and len(response.tools) > 0:
@@ -198,6 +249,22 @@ def merge_system_prompt(kwargs: Dict[str, Any], provider: str):
if kwargs.get("system") is None:
return messages
return [{"role": "system", "content": kwargs.get("system")}] + messages
elif provider == "gemini":
contents = kwargs.get("contents", [])
if isinstance(contents, str):
return [{"role": "user", "content": contents}]
elif isinstance(contents, list):
formatted = []
for item in contents:
if isinstance(item, str):
formatted.append({"role": "user", "content": item})
elif hasattr(item, "text"):
formatted.append({"role": "user", "content": item.text})
else:
formatted.append({"role": "user", "content": str(item)})
return formatted
else:
return [{"role": "user", "content": str(contents)}]
# For OpenAI, handle both Chat Completions and Responses API
if kwargs.get("messages") is not None:
@@ -271,7 +338,7 @@ def call_llm_and_track_usage(
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if response and hasattr(response, "usage"):
if response and (hasattr(response, "usage") or (provider == "gemini" and hasattr(response, "usage_metadata"))):
usage = get_usage(response, provider)
messages = merge_system_prompt(kwargs, provider)
@@ -366,7 +433,7 @@ async def call_llm_and_track_usage_async(
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if response and hasattr(response, "usage"):
if response and (hasattr(response, "usage") or (provider == "gemini" and hasattr(response, "usage_metadata"))):
usage = get_usage(response, provider)
messages = merge_system_prompt(kwargs, provider)
+3 -3
View File
@@ -1,9 +1,9 @@
import json
import logging
import time
from threading import Thread
import backoff
import monotonic
from posthog.request import APIError, DatetimeSerializer, batch_post
@@ -96,11 +96,11 @@ class Consumer(Thread):
queue = self.queue
items = []
start_time = monotonic.monotonic()
start_time = time.monotonic()
total_size = 0
while len(items) < self.flush_at:
elapsed = monotonic.monotonic() - start_time
elapsed = time.monotonic() - start_time
if elapsed >= self.flush_interval:
break
try:
+296
View File
@@ -0,0 +1,296 @@
from unittest.mock import MagicMock, patch
import pytest
try:
from google import genai as google_genai
from posthog.ai.gemini import Client
GEMINI_AVAILABLE = True
except ImportError:
GEMINI_AVAILABLE = False
pytestmark = pytest.mark.skipif(not GEMINI_AVAILABLE, reason="Google Gemini package is not available")
@pytest.fixture
def mock_client():
with patch("posthog.client.Client") as mock_client:
mock_client.privacy_mode = False
yield mock_client
@pytest.fixture
def mock_gemini_response():
mock_response = MagicMock()
mock_response.text = "Test response from Gemini"
mock_usage = MagicMock()
mock_usage.prompt_token_count = 20
mock_usage.candidates_token_count = 10
mock_response.usage_metadata = mock_usage
mock_candidate = MagicMock()
mock_candidate.text = "Test response from Gemini"
mock_content = MagicMock()
mock_part = MagicMock()
mock_part.text = "Test response from Gemini"
mock_content.parts = [mock_part]
mock_candidate.content = mock_content
mock_response.candidates = [mock_candidate]
return mock_response
@pytest.fixture
def mock_google_genai_client():
"""Mock for the new google-genai Client"""
with patch.object(google_genai, "Client") as mock_client_class:
mock_client_instance = MagicMock()
mock_models = MagicMock()
mock_client_instance.models = mock_models
mock_client_class.return_value = mock_client_instance
yield mock_client_instance
def test_new_client_basic_generation(mock_client, mock_google_genai_client, mock_gemini_response):
"""Test the new Client/Models API structure"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=["Tell me a fun fact about hedgehogs"],
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_gemini_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "gemini"
assert props["$ai_model"] == "gemini-2.0-flash"
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["foo"] == "bar"
assert "$ai_trace_id" in props
assert props["$ai_latency"] > 0
def test_new_client_streaming_with_generate_content_stream(mock_client, mock_google_genai_client):
"""Test the new generate_content_stream method"""
def mock_streaming_response():
mock_chunk1 = MagicMock()
mock_chunk1.text = "Hello "
mock_usage1 = MagicMock()
mock_usage1.prompt_token_count = 10
mock_usage1.candidates_token_count = 5
mock_chunk1.usage_metadata = mock_usage1
mock_chunk2 = MagicMock()
mock_chunk2.text = "world!"
mock_usage2 = MagicMock()
mock_usage2.prompt_token_count = 10
mock_usage2.candidates_token_count = 10
mock_chunk2.usage_metadata = mock_usage2
yield mock_chunk1
yield mock_chunk2
# Mock the generate_content_stream method
mock_google_genai_client.models.generate_content_stream.return_value = mock_streaming_response()
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content_stream(
model="gemini-2.0-flash",
contents=["Write a short story"],
posthog_distinct_id="test-id",
posthog_properties={"feature": "streaming"},
)
chunks = list(response)
assert len(chunks) == 2
assert chunks[0].text == "Hello "
assert chunks[1].text == "world!"
# Check that the streaming event was captured
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "gemini"
assert props["$ai_model"] == "gemini-2.0-flash"
assert props["$ai_input_tokens"] == 10
assert props["$ai_output_tokens"] == 10
assert props["feature"] == "streaming"
assert isinstance(props["$ai_latency"], float)
def test_new_client_groups(mock_client, mock_google_genai_client, mock_gemini_response):
"""Test groups functionality with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", posthog_client=mock_client)
client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
posthog_distinct_id="test-id",
posthog_groups={"company": "company_123"},
)
call_args = mock_client.capture.call_args[1]
assert call_args["groups"] == {"company": "company_123"}
def test_new_client_privacy_mode_local(mock_client, mock_google_genai_client, mock_gemini_response):
"""Test local privacy mode with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", posthog_client=mock_client)
client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
posthog_distinct_id="test-id",
posthog_privacy_mode=True,
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] is None
assert props["$ai_output_choices"] is None
def test_new_client_privacy_mode_global(mock_client, mock_google_genai_client, mock_gemini_response):
"""Test global privacy mode with new Client API"""
mock_client.privacy_mode = True
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", posthog_client=mock_client)
client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] is None
assert props["$ai_output_choices"] is None
def test_new_client_different_input_formats(mock_client, mock_google_genai_client, mock_gemini_response):
"""Test different input formats with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", posthog_client=mock_client)
# Test string input
client.models.generate_content(model="gemini-2.0-flash", contents="Hello", posthog_distinct_id="test-id")
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
# Test list input
mock_client.capture.reset_mock()
mock_part = MagicMock()
mock_part.text = "List item"
client.models.generate_content(model="gemini-2.0-flash", contents=[mock_part], posthog_distinct_id="test-id")
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] == [{"role": "user", "content": "List item"}]
def test_new_client_model_parameters(mock_client, mock_google_genai_client, mock_gemini_response):
"""Test model parameters with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", posthog_client=mock_client)
client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
posthog_distinct_id="test-id",
temperature=0.7,
max_tokens=100,
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_model_parameters"]["temperature"] == 0.7
assert props["$ai_model_parameters"]["max_tokens"] == 100
def test_new_client_default_settings(mock_client, mock_google_genai_client, mock_gemini_response):
"""Test client with default PostHog settings"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(
api_key="test-key",
posthog_client=mock_client,
posthog_distinct_id="default_user",
posthog_properties={"team": "ai"},
posthog_privacy_mode=False,
posthog_groups={"company": "acme_corp"},
)
# Call without overriding defaults
client.models.generate_content(model="gemini-2.0-flash", contents=["Hello"])
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "default_user"
assert call_args["groups"] == {"company": "acme_corp"}
assert props["team"] == "ai"
def test_new_client_override_defaults(mock_client, mock_google_genai_client, mock_gemini_response):
"""Test overriding client defaults per call"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(
api_key="test-key",
posthog_client=mock_client,
posthog_distinct_id="default_user",
posthog_properties={"team": "ai"},
posthog_privacy_mode=False,
posthog_groups={"company": "acme_corp"},
)
# Override defaults in call
client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
posthog_distinct_id="specific_user",
posthog_properties={"feature": "chat", "urgent": True},
posthog_privacy_mode=True,
posthog_groups={"organization": "special_org"},
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Check overrides
assert call_args["distinct_id"] == "specific_user"
assert call_args["groups"] == {"organization": "special_org"}
assert props["$ai_input"] is None # privacy mode was overridden
# Check merged properties (defaults + call-specific)
assert props["team"] == "ai" # from defaults
assert props["feature"] == "chat" # from call
assert props["urgent"] is True # from call
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "4.0.0"
VERSION = "4.2.0"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
+1
View File
@@ -3,3 +3,4 @@ universal = 1
[tool:pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
+5 -2
View File
@@ -12,14 +12,15 @@ from version import VERSION
long_description = """
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
This package requires Python 3.9 or higher.
"""
install_requires = [
"requests>=2.7,<3.0",
"six>=1.5",
"monotonic>=1.5",
"python-dateutil>=2.2",
"backoff>=1.10.0",
"python-dateutil>2.1",
"distro>=1.5.0", # Required for Linux OS detection in Python 3.9+
]
@@ -57,6 +58,7 @@ extras_require = {
"langchain-community>=0.2.0",
"langchain-openai>=0.2.0",
"langchain-anthropic>=0.2.0",
"google-genai",
"pydantic",
"parameterized>=0.8.1",
],
@@ -79,6 +81,7 @@ setup(
"posthog.ai.langchain",
"posthog.ai.openai",
"posthog.ai.anthropic",
"posthog.ai.gemini",
"posthog.test",
"posthog.sentry",
"posthog.exception_integrations",
+6 -13
View File
@@ -12,14 +12,15 @@ from version import VERSION
long_description = """
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
This package requires Python 3.9 or higher.
"""
install_requires = [
"requests>=2.7,<3.0",
"six>=1.5",
"monotonic>=1.5",
"python-dateutil>=2.2",
"backoff>=1.10.0",
"python-dateutil>2.1",
"distro>=1.5.0", # Required for Linux OS detection in Python 3.9+
]
@@ -40,6 +41,7 @@ setup(
"posthoganalytics.ai.langchain",
"posthoganalytics.ai.openai",
"posthoganalytics.ai.anthropic",
"posthoganalytics.ai.gemini",
"posthoganalytics.test",
"posthoganalytics.sentry",
"posthoganalytics.exception_integrations",
@@ -58,19 +60,10 @@ setup(
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 2",
"Programming Language :: Python :: 2.6",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.2",
"Programming Language :: Python :: 3.3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
],
)