Compare commits

...
2 Commits
Author SHA1 Message Date
Andrew MaguireGitHubClaude Opus 4.5greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
14d1d0b99c fix(llma): extract model from response for OpenAI stored prompts (#395)
* fix: extract model from response for OpenAI stored prompts

When using OpenAI stored prompts, the model is defined in the OpenAI
dashboard rather than passed in the API request. This change adds a
fallback to extract the model from the response object when not
provided in kwargs.

Fixes PostHog/posthog#42861

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Apply suggestion from @greptile-apps[bot]

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Apply suggestion from @greptile-apps[bot]

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* test: add tests for model extraction fallback and bump to 7.4.1

- Add 8 tests covering model extraction from response for stored prompts
- Fix utils.py to add 'unknown' fallback for consistency
- Bump version to 7.4.1
- Update CHANGELOG.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: format utils.py with ruff

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove 'unknown' fallback from non-streaming to match original behavior

Non-streaming originally returned None when model wasn't in kwargs.
Streaming keeps "unknown" fallback as that was the original behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add test for None model fallback in non-streaming

Verifies that non-streaming returns None (not "unknown") when model
is not available in kwargs or response, matching original behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-12-21 21:17:32 +00:00
Dustin ByrneandGitHub 80e6e432b4 fix: Respect send_feature_flags setting and deprecate send_feature_flag_events in get_feature_flag_payload (#391)
* fix: Respect `send_feature_flags` when local eval is enabled

* fix: Deprecate `send_feature_flag_events` in `get_feature_flag_payload`
2025-12-17 13:04:10 -05:00
9 changed files with 625 additions and 74 deletions
+6
View File
@@ -1,3 +1,9 @@
# 7.4.1 - 2025-12-19
fix: extract model from response for OpenAI stored prompts
When using OpenAI stored prompts, the model is defined in the OpenAI dashboard rather than passed in the API request. This fix adds a fallback to extract the model from the response object when not provided in kwargs, ensuring generations show up with the correct model and enabling cost calculations.
# 7.4.0 - 2025-12-16
feat: Add automatic retries for feature flag requests
+27 -2
View File
@@ -124,14 +124,23 @@ class WrappedResponses:
start_time = time.time()
usage_stats: TokenUsage = TokenUsage()
final_content = []
model_from_response: Optional[str] = None
response = self._original.create(**kwargs)
def generator():
nonlocal usage_stats
nonlocal final_content # noqa: F824
nonlocal model_from_response
try:
for chunk in response:
# Extract model from response object in chunk (for stored prompts)
if hasattr(chunk, "response") and chunk.response:
if model_from_response is None and hasattr(
chunk.response, "model"
):
model_from_response = chunk.response.model
# Extract usage stats from chunk
chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
@@ -161,6 +170,7 @@ class WrappedResponses:
latency,
output,
None, # Responses API doesn't have tools
model_from_response,
)
return generator()
@@ -177,6 +187,7 @@ class WrappedResponses:
latency: float,
output: Any,
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
model_from_response: Optional[str] = None,
):
from posthog.ai.types import StreamingEventData
from posthog.ai.openai.openai_converter import (
@@ -189,9 +200,12 @@ class WrappedResponses:
formatted_input = format_openai_streaming_input(kwargs, "responses")
sanitized_input = sanitize_openai_response(formatted_input)
# Use model from kwargs, fallback to model from response
model = kwargs.get("model") or model_from_response or "unknown"
event_data = StreamingEventData(
provider="openai",
model=kwargs.get("model", "unknown"),
model=model,
base_url=str(self._client.base_url),
kwargs=kwargs,
formatted_input=sanitized_input,
@@ -320,6 +334,7 @@ class WrappedCompletions:
usage_stats: TokenUsage = TokenUsage()
accumulated_content = []
accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
model_from_response: Optional[str] = None
if "stream_options" not in kwargs:
kwargs["stream_options"] = {}
kwargs["stream_options"]["include_usage"] = True
@@ -329,9 +344,14 @@ class WrappedCompletions:
nonlocal usage_stats
nonlocal accumulated_content # noqa: F824
nonlocal accumulated_tool_calls
nonlocal model_from_response
try:
for chunk in response:
# Extract model from chunk (Chat Completions chunks have model field)
if model_from_response is None and hasattr(chunk, "model"):
model_from_response = chunk.model
# Extract usage stats from chunk
chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
@@ -376,6 +396,7 @@ class WrappedCompletions:
accumulated_content,
tool_calls_list,
extract_available_tool_calls("openai", kwargs),
model_from_response,
)
return generator()
@@ -393,6 +414,7 @@ class WrappedCompletions:
output: Any,
tool_calls: Optional[List[Dict[str, Any]]] = None,
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
model_from_response: Optional[str] = None,
):
from posthog.ai.types import StreamingEventData
from posthog.ai.openai.openai_converter import (
@@ -405,9 +427,12 @@ class WrappedCompletions:
formatted_input = format_openai_streaming_input(kwargs, "chat")
sanitized_input = sanitize_openai(formatted_input)
# Use model from kwargs, fallback to model from response
model = kwargs.get("model") or model_from_response or "unknown"
event_data = StreamingEventData(
provider="openai",
model=kwargs.get("model", "unknown"),
model=model,
base_url=str(self._client.base_url),
kwargs=kwargs,
formatted_input=sanitized_input,
+27 -2
View File
@@ -128,14 +128,23 @@ class WrappedResponses:
start_time = time.time()
usage_stats: TokenUsage = TokenUsage()
final_content = []
model_from_response: Optional[str] = None
response = await self._original.create(**kwargs)
async def async_generator():
nonlocal usage_stats
nonlocal final_content # noqa: F824
nonlocal model_from_response
try:
async for chunk in response:
# Extract model from response object in chunk (for stored prompts)
if hasattr(chunk, "response") and chunk.response:
if model_from_response is None and hasattr(
chunk.response, "model"
):
model_from_response = chunk.response.model
# Extract usage stats from chunk
chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
@@ -166,6 +175,7 @@ class WrappedResponses:
latency,
output,
extract_available_tool_calls("openai", kwargs),
model_from_response,
)
return async_generator()
@@ -182,13 +192,17 @@ class WrappedResponses:
latency: float,
output: Any,
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
model_from_response: Optional[str] = None,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
# Use model from kwargs, fallback to model from response
model = kwargs.get("model") or model_from_response or "unknown"
event_properties = {
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_model": model,
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client,
@@ -350,6 +364,7 @@ class WrappedCompletions:
usage_stats: TokenUsage = TokenUsage()
accumulated_content = []
accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
model_from_response: Optional[str] = None
if "stream_options" not in kwargs:
kwargs["stream_options"] = {}
@@ -360,9 +375,14 @@ class WrappedCompletions:
nonlocal usage_stats
nonlocal accumulated_content # noqa: F824
nonlocal accumulated_tool_calls
nonlocal model_from_response
try:
async for chunk in response:
# Extract model from chunk (Chat Completions chunks have model field)
if model_from_response is None and hasattr(chunk, "model"):
model_from_response = chunk.model
# Extract usage stats from chunk
chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
if chunk_usage:
@@ -405,6 +425,7 @@ class WrappedCompletions:
accumulated_content,
tool_calls_list,
extract_available_tool_calls("openai", kwargs),
model_from_response,
)
return async_generator()
@@ -422,13 +443,17 @@ class WrappedCompletions:
output: Any,
tool_calls: Optional[List[Dict[str, Any]]] = None,
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
model_from_response: Optional[str] = None,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
# Use model from kwargs, fallback to model from response
model = kwargs.get("model") or model_from_response or "unknown"
event_properties = {
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_model": model,
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client,
+2 -2
View File
@@ -285,7 +285,7 @@ def call_llm_and_track_usage(
event_properties = {
"$ai_provider": provider,
"$ai_model": kwargs.get("model"),
"$ai_model": kwargs.get("model") or getattr(response, "model", None),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
ph_client, posthog_privacy_mode, sanitized_messages
@@ -396,7 +396,7 @@ async def call_llm_and_track_usage_async(
event_properties = {
"$ai_provider": provider,
"$ai_model": kwargs.get("model"),
"$ai_model": kwargs.get("model") or getattr(response, "model", None),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
ph_client, posthog_privacy_mode, sanitized_messages
+11 -11
View File
@@ -2,6 +2,7 @@ import atexit
import logging
import os
import sys
import warnings
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, Union
from typing_extensions import Unpack
@@ -679,15 +680,6 @@ class Client(object):
f"[FEATURE FLAGS] Unable to get feature variants: {e}"
)
elif self.feature_flags and event != "$feature_flag_called":
# Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
feature_variants = self.get_all_flags(
distinct_id,
groups=(groups or {}),
disable_geoip=disable_geoip,
only_evaluate_locally=True,
)
for feature, variant in (feature_variants or {}).items():
extra_properties[f"$feature/{feature}"] = variant
@@ -1797,7 +1789,7 @@ class Client(object):
person_properties=None,
group_properties=None,
only_evaluate_locally=False,
send_feature_flag_events=True,
send_feature_flag_events=False,
disable_geoip=None,
):
"""
@@ -1811,7 +1803,7 @@ class Client(object):
person_properties: A dictionary of person properties.
group_properties: A dictionary of group properties.
only_evaluate_locally: Whether to only evaluate locally.
send_feature_flag_events: Whether to send feature flag events.
send_feature_flag_events: Deprecated. Use get_feature_flag() instead if you need events.
disable_geoip: Whether to disable GeoIP for this request.
Examples:
@@ -1827,6 +1819,14 @@ class Client(object):
Category:
Feature flags
"""
if send_feature_flag_events:
warnings.warn(
"send_feature_flag_events is deprecated in get_feature_flag_payload() and will be removed "
"in a future version. Use get_feature_flag() if you want to send $feature_flag_called events.",
DeprecationWarning,
stacklevel=2,
)
feature_flag_result = self._get_feature_flag_result(
key,
distinct_id,
+456
View File
@@ -1676,3 +1676,459 @@ async def test_async_chat_streaming_with_web_search(
assert props["$ai_web_search_count"] == 1
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 15
# Tests for model extraction fallback (stored prompts support)
def test_streaming_chat_extracts_model_from_chunk_when_not_in_kwargs(mock_client):
"""Test that model is extracted from streaming chunks when not provided in kwargs (stored prompts)."""
# Create streaming chunks with model field but we won't pass model in kwargs
chunks = [
ChatCompletionChunk(
id="chunk1",
model="gpt-4o-stored-prompt", # Model comes from response, not request
object="chat.completion.chunk",
created=1234567890,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(role="assistant", content="Hello"),
finish_reason=None,
)
],
),
ChatCompletionChunk(
id="chunk2",
model="gpt-4o-stored-prompt",
object="chat.completion.chunk",
created=1234567891,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(content=" world"),
finish_reason="stop",
)
],
usage=CompletionUsage(
prompt_tokens=10,
completion_tokens=5,
total_tokens=15,
),
),
]
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = chunks
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs - simulates stored prompt usage
response_generator = client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
stream=True,
posthog_distinct_id="test-id",
)
# Consume the generator
list(response_generator)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Model should be extracted from chunk, not kwargs
assert props["$ai_model"] == "gpt-4o-stored-prompt"
def test_streaming_chat_prefers_kwargs_model_over_chunk_model(mock_client):
"""Test that model from kwargs takes precedence over model from chunk."""
chunks = [
ChatCompletionChunk(
id="chunk1",
model="gpt-4o-from-response",
object="chat.completion.chunk",
created=1234567890,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(role="assistant", content="Hello"),
finish_reason="stop",
)
],
usage=CompletionUsage(
prompt_tokens=10,
completion_tokens=5,
total_tokens=15,
),
),
]
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = chunks
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response_generator = client.chat.completions.create(
model="gpt-4o-from-kwargs", # Explicitly passed model
messages=[{"role": "user", "content": "Hello"}],
stream=True,
posthog_distinct_id="test-id",
)
list(response_generator)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# kwargs model should take precedence
assert props["$ai_model"] == "gpt-4o-from-kwargs"
def test_streaming_responses_api_extracts_model_from_response_object(mock_client):
"""Test that Responses API streaming extracts model from chunk.response.model (stored prompts)."""
from unittest.mock import MagicMock
from openai.types.responses import ResponseUsage
chunks = []
# Content chunk
chunk1 = MagicMock()
chunk1.type = "response.text.delta"
chunk1.text = "Test response"
# No response attribute on content chunks
del chunk1.response
chunks.append(chunk1)
# Final chunk with response object containing model
chunk2 = MagicMock()
chunk2.type = "response.completed"
chunk2.response = MagicMock()
chunk2.response.model = "gpt-4o-mini-stored" # Model from stored prompt
chunk2.response.usage = ResponseUsage(
input_tokens=20,
output_tokens=10,
total_tokens=30,
input_tokens_details={"prompt_tokens": 20, "cached_tokens": 0},
output_tokens_details={"reasoning_tokens": 0},
)
chunk2.response.output = ["Test response"]
chunks.append(chunk2)
with patch("openai.resources.responses.Responses.create") as mock_create:
mock_create.return_value = iter(chunks)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model - simulates stored prompt
response_generator = client.responses.create(
input=[{"role": "user", "content": "Hello"}],
stream=True,
posthog_distinct_id="test-id",
)
list(response_generator)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Model should be extracted from chunk.response.model
assert props["$ai_model"] == "gpt-4o-mini-stored"
def test_non_streaming_extracts_model_from_response(mock_client):
"""Test that non-streaming calls extract model from response when not in kwargs."""
# Create a response with model but we won't pass model in kwargs
mock_response = ChatCompletion(
id="test",
model="gpt-4o-stored-prompt",
object="chat.completion",
created=int(time.time()),
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content="Test response",
role="assistant",
),
)
],
usage=CompletionUsage(
completion_tokens=10,
prompt_tokens=20,
total_tokens=30,
),
)
with patch(
"openai.resources.chat.completions.Completions.create",
return_value=mock_response,
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs
response = client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
)
assert response == mock_response
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Model should be extracted from response.model
assert props["$ai_model"] == "gpt-4o-stored-prompt"
def test_non_streaming_responses_api_extracts_model_from_response(mock_client):
"""Test that non-streaming Responses API extracts model from response when not in kwargs."""
mock_response = Response(
id="test",
model="gpt-4o-mini-stored",
object="response",
created_at=1741476542,
status="completed",
error=None,
incomplete_details=None,
instructions=None,
max_output_tokens=None,
tools=[],
tool_choice="auto",
output=[
ResponseOutputMessage(
id="msg_123",
type="message",
role="assistant",
status="completed",
content=[
ResponseOutputText(
type="output_text",
text="Test response",
annotations=[],
)
],
)
],
parallel_tool_calls=True,
previous_response_id=None,
usage=ResponseUsage(
input_tokens=10,
output_tokens=10,
input_tokens_details={"prompt_tokens": 10, "cached_tokens": 0},
output_tokens_details={"reasoning_tokens": 0},
total_tokens=20,
),
user=None,
metadata={},
)
with patch(
"openai.resources.responses.Responses.create",
return_value=mock_response,
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs
response = client.responses.create(
input="Hello",
posthog_distinct_id="test-id",
)
assert response == mock_response
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Model should be extracted from response.model
assert props["$ai_model"] == "gpt-4o-mini-stored"
def test_non_streaming_returns_none_when_no_model(mock_client):
"""Test that non-streaming returns None (not 'unknown') when model is not available anywhere."""
# Create a response without model attribute using real OpenAI types
mock_response = ChatCompletion(
id="test",
model="", # Will be removed below
object="chat.completion",
created=int(time.time()),
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content="Test response",
role="assistant",
),
)
],
usage=CompletionUsage(
completion_tokens=5,
prompt_tokens=10,
total_tokens=15,
),
)
# Remove model attribute to simulate missing model
object.__delattr__(mock_response, "model")
with patch(
"openai.resources.chat.completions.Completions.create",
return_value=mock_response,
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs and response has no model
client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Should be None, NOT "unknown" (to avoid incorrect cost matching)
assert props["$ai_model"] is None
def test_streaming_falls_back_to_unknown_when_no_model(mock_client):
"""Test that streaming falls back to 'unknown' when model is not available anywhere."""
from unittest.mock import MagicMock
# Create a chunk without model attribute
chunk = MagicMock()
chunk.choices = [MagicMock()]
chunk.choices[0].delta = MagicMock()
chunk.choices[0].delta.content = "Hello"
chunk.choices[0].delta.role = "assistant"
chunk.choices[0].delta.tool_calls = None
chunk.usage = CompletionUsage(
prompt_tokens=10,
completion_tokens=5,
total_tokens=15,
)
# Explicitly remove model attribute
del chunk.model
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = [chunk]
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response_generator = client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
stream=True,
posthog_distinct_id="test-id",
)
list(response_generator)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Should fall back to "unknown"
assert props["$ai_model"] == "unknown"
@pytest.mark.asyncio
async def test_async_streaming_chat_extracts_model_from_chunk(mock_client):
"""Test async streaming extracts model from chunk when not in kwargs."""
chunks = [
ChatCompletionChunk(
id="chunk1",
model="gpt-4o-async-stored",
object="chat.completion.chunk",
created=1234567890,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(role="assistant", content="Hello"),
finish_reason="stop",
)
],
usage=CompletionUsage(
prompt_tokens=10,
completion_tokens=5,
total_tokens=15,
),
),
]
async def mock_create(self, **kwargs):
async def chunk_iterable():
for chunk in chunks:
yield chunk
return chunk_iterable()
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new=mock_create
):
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model
response_stream = await client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
stream=True,
posthog_distinct_id="test-id",
)
async for _ in response_stream:
pass
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_model"] == "gpt-4o-async-stored"
@pytest.mark.asyncio
async def test_async_streaming_responses_extracts_model_from_response(mock_client):
"""Test async Responses API streaming extracts model from chunk.response.model."""
from unittest.mock import MagicMock
from openai.types.responses import ResponseUsage
chunks = []
chunk1 = MagicMock()
chunk1.type = "response.text.delta"
chunk1.text = "Test"
del chunk1.response
chunks.append(chunk1)
chunk2 = MagicMock()
chunk2.type = "response.completed"
chunk2.response = MagicMock()
chunk2.response.model = "gpt-4o-mini-async-stored"
chunk2.response.usage = ResponseUsage(
input_tokens=20,
output_tokens=10,
total_tokens=30,
input_tokens_details={"prompt_tokens": 20, "cached_tokens": 0},
output_tokens_details={"reasoning_tokens": 0},
)
chunk2.response.output = ["Test"]
chunks.append(chunk2)
async def mock_create(self, **kwargs):
async def chunk_iterable():
for chunk in chunks:
yield chunk
return chunk_iterable()
with patch("openai.resources.responses.AsyncResponses.create", new=mock_create):
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response_stream = await client.responses.create(
input=[{"role": "user", "content": "Hello"}],
stream=True,
posthog_distinct_id="test-id",
)
async for _ in response_stream:
pass
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_model"] == "gpt-4o-mini-async-stored"
+86 -1
View File
@@ -409,7 +409,9 @@ class TestClient(unittest.TestCase):
)
client.feature_flags = [multivariate_flag, basic_flag, false_flag]
msg_uuid = client.capture("python test event", distinct_id="distinct_id")
msg_uuid = client.capture(
"python test event", distinct_id="distinct_id", send_feature_flags=True
)
self.assertIsNotNone(msg_uuid)
self.assertFalse(self.failed)
@@ -565,6 +567,7 @@ class TestClient(unittest.TestCase):
"python test event",
distinct_id="distinct_id",
properties={"$feature/beta-feature-local": "my-custom-variant"},
send_feature_flags=True,
)
self.assertIsNotNone(msg_uuid)
self.assertFalse(self.failed)
@@ -746,6 +749,88 @@ class TestClient(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_capture_with_send_feature_flags_false_and_local_evaluation_doesnt_send_flags(
self, patch_flags
):
"""Test that send_feature_flags=False with local evaluation enabled does NOT send flags"""
patch_flags.return_value = {"featureFlags": {"beta-feature": "remote-variant"}}
multivariate_flag = {
"id": 1,
"name": "Beta Feature",
"key": "beta-feature-local",
"active": True,
"rollout_percentage": 100,
"filters": {
"groups": [
{
"rollout_percentage": 100,
},
],
"multivariate": {
"variants": [
{
"key": "first-variant",
"name": "First Variant",
"rollout_percentage": 50,
},
{
"key": "second-variant",
"name": "Second Variant",
"rollout_percentage": 50,
},
]
},
},
}
simple_flag = {
"id": 2,
"name": "Simple Flag",
"key": "simple-flag",
"active": True,
"filters": {
"groups": [
{
"rollout_percentage": 100,
}
],
},
}
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
personal_api_key=FAKE_TEST_API_KEY,
sync_mode=True,
)
client.feature_flags = [multivariate_flag, simple_flag]
msg_uuid = client.capture(
"python test event",
distinct_id="distinct_id",
send_feature_flags=False,
)
self.assertIsNotNone(msg_uuid)
self.assertFalse(self.failed)
# Get the enqueued message from the mock
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
msg = batch_data[0]
self.assertEqual(msg["event"], "python test event")
self.assertEqual(msg["distinct_id"], "distinct_id")
# CRITICAL: Verify local flags are NOT included in the event
self.assertNotIn("$feature/beta-feature-local", msg["properties"])
self.assertNotIn("$feature/simple-flag", msg["properties"])
self.assertNotIn("$active_feature_flags", msg["properties"])
# CRITICAL: Verify the /flags API was NOT called
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_capture_with_send_feature_flags_true_and_local_evaluation_uses_local_flags(
self, patch_flags
+9 -55
View File
@@ -3062,6 +3062,7 @@ class TestLocalEvaluation(unittest.TestCase):
"some-distinct-id",
match_value=True,
person_properties={"region": "USA"},
send_feature_flag_events=True,
),
300,
)
@@ -4051,7 +4052,9 @@ class TestCaptureCalls(unittest.TestCase):
self.assertEqual(
client.get_feature_flag_payload(
"decide-flag-with-payload", "some-distinct-id"
"decide-flag-with-payload",
"some-distinct-id",
send_feature_flag_events=True,
),
{"foo": "bar"},
)
@@ -4127,9 +4130,10 @@ class TestCaptureCalls(unittest.TestCase):
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.flags")
def test_capture_is_called_in_get_feature_flag_payload(
def test_get_feature_flag_payload_does_not_send_feature_flag_called_events(
self, patch_flags, patch_capture
):
"""Test that get_feature_flag_payload does NOT send $feature_flag_called events"""
patch_flags.return_value = {
"featureFlags": {"person-flag": True},
"featureFlagPayloads": {"person-flag": 300},
@@ -4151,68 +4155,18 @@ class TestCaptureCalls(unittest.TestCase):
"rollout_percentage": 100,
}
],
"payloads": {"true": '"payload"'},
},
}
]
# Call get_feature_flag_payload with match_value=None to trigger get_feature_flag
client.get_feature_flag_payload(
payload = client.get_feature_flag_payload(
key="person-flag",
distinct_id="some-distinct-id",
person_properties={"region": "USA", "name": "Aloha"},
)
# Assert that capture was called once, with the correct parameters
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"$feature_flag_called",
distinct_id="some-distinct-id",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
"$feature/person-flag": True,
},
groups={},
disable_geoip=None,
)
# Reset mocks for further tests
patch_capture.reset_mock()
patch_flags.reset_mock()
# Call get_feature_flag_payload again for the same user; capture should not be called again because we've already reported an event for this distinct_id + flag
client.get_feature_flag_payload(
key="person-flag",
distinct_id="some-distinct-id",
person_properties={"region": "USA", "name": "Aloha"},
)
self.assertIsNotNone(payload)
self.assertEqual(patch_capture.call_count, 0)
patch_capture.reset_mock()
# Call get_feature_flag_payload for a different user; capture should be called
client.get_feature_flag_payload(
key="person-flag",
distinct_id="some-distinct-id2",
person_properties={"region": "USA", "name": "Aloha"},
)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"$feature_flag_called",
distinct_id="some-distinct-id2",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
"$feature/person-flag": True,
},
groups={},
disable_geoip=None,
)
patch_capture.reset_mock()
@mock.patch("posthog.client.flags")
def test_fallback_to_api_in_get_feature_flag_payload_when_flag_has_static_cohort(
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "7.4.0"
VERSION = "7.4.1"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201