Skip to content

Commit c56b79b

Browse files
committed
feat(ph-ai): ai_billable metadata per-generation
1 parent 3e52e7f commit c56b79b

File tree

4 files changed

+99
-1
lines changed

4 files changed

+99
-1
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 6.7.15 - 2025-11-04
2+
3+
- feat(ph-ai): ai_billing metadata per-generation
4+
15
# 6.7.14 - 2025-11-03
26

37
- fix(django): Handle request.user access in async middleware context to prevent SynchronousOnlyOperation errors in Django 5+ (fixes #355)

posthog/ai/langchain/callbacks.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ class GenerationMetadata(SpanMetadata):
7979
"""Base URL of the provider's API used in the run."""
8080
tools: Optional[List[Dict[str, Any]]] = None
8181
"""Tools provided to the model."""
82+
metadata: Optional[Dict[str, Any]] = None
83+
"""Metadata from the invocation."""
8284

8385

8486
RunMetadata = Union[SpanMetadata, GenerationMetadata]
@@ -416,6 +418,7 @@ def _set_llm_metadata(
416418
if tools := invocation_params.get("tools"):
417419
generation.tools = tools
418420
if isinstance(metadata, dict):
421+
generation.metadata = metadata
419422
if model := metadata.get("ls_model_name"):
420423
generation.model = model
421424
if provider := metadata.get("ls_provider"):
@@ -603,6 +606,9 @@ def _capture_generation(
603606
if self._properties:
604607
event_properties.update(self._properties)
605608

609+
if run.metadata and "$ai_billable" in run.metadata:
610+
event_properties["$ai_billable"] = run.metadata["$ai_billable"]
611+
606612
if self._distinct_id is None:
607613
event_properties["$process_person_profile"] = False
608614

posthog/test/ai/langchain/test_callbacks.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ def test_metadata_capture(mock_client):
113113
base_url="https://us.posthog.com",
114114
name="test",
115115
end_time=None,
116+
metadata={"ls_model_name": "hog-mini", "ls_provider": "posthog"},
116117
)
117118
assert callbacks._runs[run_id] == expected
118119
with patch("time.time", return_value=1234567891):
@@ -1269,6 +1270,7 @@ def test_metadata_tools(mock_client):
12691270
name="test",
12701271
tools=tools,
12711272
end_time=None,
1273+
metadata={"ls_model_name": "hog-mini", "ls_provider": "posthog"},
12721274
)
12731275
assert callbacks._runs[run_id] == expected
12741276
with patch("time.time", return_value=1234567891):
@@ -1839,6 +1841,7 @@ def test_tool_definition(mock_client):
18391841
name="test",
18401842
tools=tools,
18411843
end_time=None,
1844+
metadata={"ls_model_name": "gpt-4o-mini", "ls_provider": "openai"},
18421845
)
18431846
assert callbacks._runs[run_id] == expected
18441847

@@ -2126,3 +2129,88 @@ def test_agent_action_and_finish_imports():
21262129
assert mock_client.capture.call_count == 1
21272130
call_args = mock_client.capture.call_args[1]
21282131
assert call_args["event"] == "$ai_span"
2132+
2133+
2134+
def test_ai_billable_metadata(mock_client):
2135+
"""Test that $ai_billable metadata is properly captured in generation events."""
2136+
prompt = ChatPromptTemplate.from_messages(
2137+
[
2138+
("user", "What is the weather?"),
2139+
]
2140+
)
2141+
model = FakeMessagesListChatModel(
2142+
responses=[
2143+
AIMessage(
2144+
content="It's sunny!",
2145+
usage_metadata={
2146+
"input_tokens": 10,
2147+
"output_tokens": 5,
2148+
"total_tokens": 15,
2149+
},
2150+
)
2151+
]
2152+
)
2153+
2154+
callbacks = [CallbackHandler(mock_client)]
2155+
chain = prompt | model
2156+
2157+
# Invoke with $ai_billable metadata set to False
2158+
result = chain.invoke(
2159+
{}, config={"callbacks": callbacks, "metadata": {"$ai_billable": False}}
2160+
)
2161+
2162+
assert result.content == "It's sunny!"
2163+
assert mock_client.capture.call_count == 3
2164+
2165+
generation_args = mock_client.capture.call_args_list[1][1]
2166+
generation_props = generation_args["properties"]
2167+
2168+
assert generation_args["event"] == "$ai_generation"
2169+
assert generation_props["$ai_billable"] is False
2170+
2171+
# Test with $ai_billable set to True
2172+
mock_client.reset_mock()
2173+
2174+
result = chain.invoke(
2175+
{}, config={"callbacks": callbacks, "metadata": {"$ai_billable": True}}
2176+
)
2177+
2178+
assert mock_client.capture.call_count == 3
2179+
generation_args = mock_client.capture.call_args_list[1][1]
2180+
generation_props = generation_args["properties"]
2181+
2182+
assert generation_args["event"] == "$ai_generation"
2183+
assert generation_props["$ai_billable"] is True
2184+
2185+
2186+
def test_ai_billable_metadata_not_present_when_not_set(mock_client):
2187+
"""Test that $ai_billable is not in event properties when not set in metadata."""
2188+
prompt = ChatPromptTemplate.from_messages([("user", "Hello")])
2189+
model = FakeMessagesListChatModel(
2190+
responses=[
2191+
AIMessage(
2192+
content="Hi!",
2193+
usage_metadata={
2194+
"input_tokens": 5,
2195+
"output_tokens": 2,
2196+
"total_tokens": 7,
2197+
},
2198+
)
2199+
]
2200+
)
2201+
2202+
callbacks = [CallbackHandler(mock_client)]
2203+
chain = prompt | model
2204+
2205+
# Invoke without $ai_billable metadata
2206+
result = chain.invoke({}, config={"callbacks": callbacks})
2207+
2208+
assert result.content == "Hi!"
2209+
assert mock_client.capture.call_count == 3
2210+
2211+
generation_args = mock_client.capture.call_args_list[1][1]
2212+
generation_props = generation_args["properties"]
2213+
2214+
assert generation_args["event"] == "$ai_generation"
2215+
# $ai_billable should not be present at all
2216+
assert "$ai_billable" not in generation_props

posthog/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
VERSION = "6.7.14"
1+
VERSION = "6.7.15"
22

33
if __name__ == "__main__":
44
print(VERSION, end="") # noqa: T201

0 commit comments

Comments
 (0)