Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14d1d0b99c | ||
|
|
80e6e432b4 |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
VERSION = "7.4.0"
|
||||
VERSION = "7.4.1"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
Reference in New Issue
Block a user