Skip to content

Commit edc5a47

Browse files
committed
feat(ph-ai): billing metadata support
1 parent 3e52e7f commit edc5a47

File tree

2 files changed

+179
-0
lines changed

2 files changed

+179
-0
lines changed

posthog/ai/langchain/callbacks.py

Lines changed: 5 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+
billable: bool = False
83+
"""Whether the generation is billable."""
8284

8385

8486
RunMetadata = Union[SpanMetadata, GenerationMetadata]
@@ -420,6 +422,8 @@ def _set_llm_metadata(
420422
generation.model = model
421423
if provider := metadata.get("ls_provider"):
422424
generation.provider = provider
425+
if billable := metadata.get("posthog_ai_billable"):
426+
generation.billable = billable
423427
try:
424428
base_url = serialized["kwargs"]["openai_api_base"]
425429
if base_url is not None:
@@ -564,6 +568,7 @@ def _capture_generation(
564568
"$ai_latency": run.latency,
565569
"$ai_base_url": run.base_url,
566570
"$ai_framework": "langchain",
571+
"$ai_billable": run.billable,
567572
}
568573

569574
if run.tools:

posthog/test/ai/langchain/test_callbacks.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2126,3 +2126,177 @@ def test_agent_action_and_finish_imports():
21262126
assert mock_client.capture.call_count == 1
21272127
call_args = mock_client.capture.call_args[1]
21282128
assert call_args["event"] == "$ai_span"
2129+
2130+
2131+
def test_billable_field_in_generation_metadata(mock_client):
2132+
"""Test that the billable field is properly stored in GenerationMetadata."""
2133+
callbacks = CallbackHandler(mock_client)
2134+
run_id = uuid.uuid4()
2135+
2136+
# Test with billable=True
2137+
with patch("time.time", return_value=1234567890):
2138+
callbacks._set_llm_metadata(
2139+
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
2140+
run_id,
2141+
messages=[{"role": "user", "content": "Test message"}],
2142+
invocation_params={"temperature": 0.5},
2143+
metadata={
2144+
"ls_model_name": "gpt-4o",
2145+
"ls_provider": "openai",
2146+
"posthog_ai_billable": True,
2147+
},
2148+
name="test",
2149+
)
2150+
2151+
expected = GenerationMetadata(
2152+
model="gpt-4o",
2153+
input=[{"role": "user", "content": "Test message"}],
2154+
start_time=1234567890,
2155+
model_params={"temperature": 0.5},
2156+
provider="openai",
2157+
base_url="https://api.openai.com",
2158+
name="test",
2159+
billable=True,
2160+
end_time=None,
2161+
)
2162+
assert callbacks._runs[run_id] == expected
2163+
assert callbacks._runs[run_id].billable is True
2164+
2165+
callbacks._pop_run_metadata(run_id)
2166+
2167+
# Test with billable=False (explicit)
2168+
run_id2 = uuid.uuid4()
2169+
with patch("time.time", return_value=1234567890):
2170+
callbacks._set_llm_metadata(
2171+
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
2172+
run_id2,
2173+
messages=[{"role": "user", "content": "Test message"}],
2174+
invocation_params={"temperature": 0.5},
2175+
metadata={
2176+
"ls_model_name": "gpt-4o",
2177+
"ls_provider": "openai",
2178+
"posthog_ai_billable": False,
2179+
},
2180+
name="test",
2181+
)
2182+
2183+
assert callbacks._runs[run_id2].billable is False
2184+
callbacks._pop_run_metadata(run_id2)
2185+
2186+
# Test default billable=False when not provided
2187+
run_id3 = uuid.uuid4()
2188+
with patch("time.time", return_value=1234567890):
2189+
callbacks._set_llm_metadata(
2190+
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
2191+
run_id3,
2192+
messages=[{"role": "user", "content": "Test message"}],
2193+
invocation_params={"temperature": 0.5},
2194+
metadata={"ls_model_name": "gpt-4o", "ls_provider": "openai"},
2195+
name="test",
2196+
)
2197+
2198+
assert callbacks._runs[run_id3].billable is False
2199+
2200+
2201+
def test_billable_property_in_generation_event(mock_client):
2202+
"""Test that the billable property is captured in the $ai_generation event."""
2203+
callbacks = CallbackHandler(mock_client)
2204+
2205+
# We need to test the _set_llm_metadata directly since FakeMessagesListChatModel
2206+
# doesn't support metadata in the same way as real models
2207+
run_id = uuid.uuid4()
2208+
with patch("time.time", return_value=1234567890):
2209+
callbacks._set_llm_metadata(
2210+
{},
2211+
run_id,
2212+
messages=[{"role": "user", "content": "Test"}],
2213+
metadata={"posthog_ai_billable": True, "ls_model_name": "test-model"},
2214+
invocation_params={},
2215+
)
2216+
2217+
mock_response = MagicMock()
2218+
mock_response.generations = [[MagicMock()]]
2219+
2220+
with patch("time.time", return_value=1234567891):
2221+
run = callbacks._pop_run_metadata(run_id)
2222+
2223+
callbacks._capture_generation(
2224+
trace_id=run_id,
2225+
run_id=run_id,
2226+
run=run,
2227+
output=mock_response,
2228+
parent_run_id=None,
2229+
)
2230+
2231+
assert mock_client.capture.call_count == 1
2232+
call_args = mock_client.capture.call_args[1]
2233+
props = call_args["properties"]
2234+
2235+
assert call_args["event"] == "$ai_generation"
2236+
assert props["$ai_billable"] is True
2237+
2238+
2239+
def test_billable_defaults_to_false_in_event(mock_client):
2240+
"""Test that $ai_billable defaults to False when not specified."""
2241+
prompt = ChatPromptTemplate.from_messages([("user", "Test query")])
2242+
model = FakeMessagesListChatModel(
2243+
responses=[AIMessage(content="Test response")],
2244+
)
2245+
2246+
callbacks = [CallbackHandler(mock_client)]
2247+
chain = prompt | model
2248+
chain.invoke({}, config={"callbacks": callbacks})
2249+
2250+
generation_call = None
2251+
for call in mock_client.capture.call_args_list:
2252+
if call[1]["event"] == "$ai_generation":
2253+
generation_call = call
2254+
break
2255+
2256+
assert generation_call is not None
2257+
props = generation_call[1]["properties"]
2258+
assert props["$ai_billable"] is False
2259+
2260+
2261+
def test_billable_with_real_chain(mock_client):
2262+
"""Test billable tracking through a complete chain execution with mocked metadata."""
2263+
callbacks = CallbackHandler(mock_client)
2264+
run_id = uuid.uuid4()
2265+
2266+
with patch("time.time", return_value=1000.0):
2267+
callbacks._set_llm_metadata(
2268+
{},
2269+
run_id,
2270+
messages=[{"role": "user", "content": "What's the weather?"}],
2271+
metadata={
2272+
"ls_model_name": "fake-model",
2273+
"ls_provider": "fake",
2274+
"posthog_ai_billable": True,
2275+
},
2276+
invocation_params={"temperature": 0.7},
2277+
)
2278+
2279+
assert callbacks._runs[run_id].billable is True
2280+
2281+
mock_response = MagicMock()
2282+
mock_response.generations = [[MagicMock()]]
2283+
2284+
with patch("time.time", return_value=1001.0):
2285+
run = callbacks._pop_run_metadata(run_id)
2286+
2287+
callbacks._capture_generation(
2288+
trace_id=run_id,
2289+
run_id=run_id,
2290+
run=run,
2291+
output=mock_response,
2292+
parent_run_id=None,
2293+
)
2294+
2295+
assert mock_client.capture.call_count == 1
2296+
call_args = mock_client.capture.call_args[1]
2297+
props = call_args["properties"]
2298+
2299+
assert call_args["event"] == "$ai_generation"
2300+
assert props["$ai_billable"] is True
2301+
assert props["$ai_model"] == "fake-model"
2302+
assert props["$ai_provider"] == "fake"

0 commit comments

Comments
 (0)