Repository navigation
feat(aio): honest per-tool spend attribution and cost-by-context-size #73367
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
03d096e
feat(aio): honest per-tool spend attribution and cost-by-context-size
pauldambra ba098cf
Merge remote-tracking branch 'origin/master' into posthog-code/aio-sp…
pauldambra a00ad9f
chore(aio): extract _share_of helper for scoped-cost shares
pauldambra 61d5978
Merge remote-tracking branch 'origin/master' into pastori/73367
pauldambra 25c6f40
test(mcp): update unit test snapshots
tests-posthog[bot] fd7238d
chore: update OpenAPI generated types
tests-posthog[bot] cb49604
fix(aio): address review feedback on personal spend
pauldambra c7ba7b4
fix(aio): avoid hogql f-string in personal spend input-size query
pauldambra b2effc8
test(backend): update query snapshots
tests-posthog[bot] 5992fa0
Merge remote-tracking branch 'origin/master' into pastori/73367
pauldambra 6cbc5c3
test(backend): update query snapshots
tests-posthog[bot] 89c978d
Merge remote-tracking branch 'origin/master' into pastori/73367
pauldambra File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
347 changes: 284 additions & 63 deletions
347
products/ai_observability/backend/api/personal_spend.py
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -278,9 +278,10 @@ def test_empty_result_when_no_events(self) -> None: | |
| assert body["by_product"] == {"items": [], "truncated": False} | ||
| assert body["by_tool"] == {"items": [], "truncated": False} | ||
| assert body["by_model"] == {"items": [], "truncated": False} | ||
| assert body["by_input_size"] == {"items": [], "truncated": False} | ||
| assert body["by_day"] == {"items": [], "truncated": False} | ||
| assert body["by_day_model"] == [] | ||
| assert body["top_traces"] == {"items": [], "truncated": False} | ||
| assert "top_traces" not in body | ||
|
|
||
| def test_summary_reports_cross_product_totals_alongside_scoped(self) -> None: | ||
| self._create_generation(ai_product="posthog_code", cost=2.0) | ||
|
|
@@ -358,16 +359,97 @@ def test_by_tool_splits_comma_separated_tools(self) -> None: | |
| # Scoped total stays $3.50 — the multi-tool generation is only counted once there. | ||
| assert response.json()["summary"]["scoped_cost_usd"] == 3.5 | ||
|
|
||
| def test_top_traces_always_empty(self) -> None: | ||
| # `top_traces` is deprecated — kept in the response shape so existing consumers don't | ||
| # crash, but always returns empty. Trace IDs are opaque strings that aren't actionable | ||
| # in the UI. | ||
| def test_by_tool_cost_attributed_splits_evenly_across_distinct_tools(self) -> None: | ||
| # Same fixture as test_by_tool_splits_comma_separated_tools: a multi-tool generation | ||
| # plus two single-tool generations. Unlike `cost_usd` (which double-counts the | ||
| # multi-tool generation into both rows), `cost_attributed_usd` must split its $2.0 | ||
| # evenly across Bash and Read so the per-tool sum reconciles to scoped_cost_usd | ||
| # instead of overcounting -- this is the regression the whole PR exists to fix. | ||
| self._create_generation(tool="Bash,Read", cost=2.0, trace_id="multi") | ||
| self._create_generation(tool="Bash", cost=1.0, trace_id="bash-only") | ||
| self._create_generation(tool="Read", cost=0.5, trace_id="read-only") | ||
| flush_persons_and_events() | ||
|
|
||
| response = self.client.get(f"{ENDPOINT}?product=posthog_code") | ||
| body = response.json() | ||
| rows = {r["tool"]: r for r in body["by_tool"]["items"]} | ||
|
|
||
| # Bash: $1.0 (bash-only) + $1.0 (half of the $2.0 multi-tool generation) = $2.0. | ||
| assert rows["Bash"]["cost_attributed_usd"] == 2.0 | ||
| # Read: $0.5 (read-only) + $1.0 (half of the $2.0 multi-tool generation) = $1.5. | ||
| assert rows["Read"]["cost_attributed_usd"] == 1.5 | ||
| # `cost_usd` still double-counts (unchanged, existing behavior) -- the two fields | ||
| # must genuinely differ, not just both exist. | ||
| assert rows["Bash"]["cost_usd"] == 3.0 | ||
| assert rows["Read"]["cost_usd"] == 2.5 | ||
|
|
||
| # Attributed cost reconciles to scoped_cost_usd; cost_usd does not ($5.5 > $3.5). | ||
| scoped = body["summary"]["scoped_cost_usd"] | ||
| assert scoped == 3.5 | ||
| assert sum(r["cost_attributed_usd"] for r in rows.values()) == scoped | ||
| assert sum(r["cost_usd"] for r in rows.values()) != scoped | ||
|
|
||
| # share_attributed is cost_attributed_usd normalized the same way, and reconciles too. | ||
| assert rows["Bash"]["share_attributed"] == 2.0 / 3.5 | ||
| assert rows["Read"]["share_attributed"] == 1.5 / 3.5 | ||
| assert sum(r["share_attributed"] for r in rows.values()) == 1.0 | ||
|
|
||
| def test_by_tool_cost_attributed_falls_back_to_null_tool_for_no_tools(self) -> None: | ||
| # A generation with no tools attributes its full cost to the same NULL `tool` | ||
| # bucket the existing (unattributed) rows already use for "no tool called". | ||
| self._create_generation(tool=None, cost=1.0) | ||
| flush_persons_and_events() | ||
|
|
||
| response = self.client.get(f"{ENDPOINT}?product=posthog_code") | ||
| rows = {r["tool"]: r for r in response.json()["by_tool"]["items"]} | ||
| assert rows.keys() == {None} | ||
| assert rows[None]["cost_attributed_usd"] == 1.0 | ||
|
|
||
| def test_by_input_size_buckets_generations_by_input_tokens(self) -> None: | ||
| # One generation per bucket boundary, each with a distinguishing cost so the | ||
| # buckets can't be confused for one another. | ||
| self._create_generation(cost=1.0, input_tokens=10_000, trace_id="under-50k") | ||
| self._create_generation(cost=2.0, input_tokens=75_000, trace_id="50k-100k") | ||
| self._create_generation(cost=4.0, input_tokens=350_000, trace_id="over-300k") | ||
| # An embedding counts too -- by_input_size uses the same event set as by_model. | ||
| self._create_generation(cost=0.5, input_tokens=5_000, event_name="$ai_embedding", trace_id="embedding") | ||
| flush_persons_and_events() | ||
|
|
||
| response = self.client.get(ENDPOINT_OK) | ||
| body = response.json() | ||
| by_bucket = {r["bucket"]: r for r in body["by_input_size"]["items"]} | ||
|
|
||
| assert by_bucket["<50k"]["generation_count"] == 2 # the 10k generation + the 5k embedding | ||
| assert by_bucket["<50k"]["cost_usd"] == 1.5 | ||
| assert by_bucket["<50k"]["min_input_tokens"] == 0 | ||
| assert by_bucket["50k-100k"]["generation_count"] == 1 | ||
| assert by_bucket["50k-100k"]["cost_usd"] == 2.0 | ||
| assert by_bucket["50k-100k"]["min_input_tokens"] == 50_000 | ||
| assert by_bucket[">300k"]["generation_count"] == 1 | ||
| assert by_bucket[">300k"]["cost_usd"] == 4.0 | ||
| assert by_bucket[">300k"]["min_input_tokens"] == 300_000 | ||
| # Buckets with no events are omitted, not zero-filled. | ||
| assert set(by_bucket) == {"<50k", "50k-100k", ">300k"} | ||
|
|
||
| # Ordered ascending by min_input_tokens, not by cost. | ||
| assert [r["bucket"] for r in body["by_input_size"]["items"]] == ["<50k", "50k-100k", ">300k"] | ||
|
|
||
| # cost_usd across buckets reconciles to scoped_cost_usd. | ||
| scoped = body["summary"]["scoped_cost_usd"] | ||
| assert scoped == 7.5 | ||
| assert sum(r["cost_usd"] for r in body["by_input_size"]["items"]) == scoped | ||
| assert sum(r["share_of_scoped"] for r in body["by_input_size"]["items"]) == 1.0 | ||
| assert by_bucket["<50k"]["avg_cost_per_generation"] == 0.75 | ||
|
|
||
| def test_top_traces_removed_from_response(self) -> None: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| # `top_traces` was dropped entirely -- confirmed unused by the PostHog/code client, | ||
| # which already stopped rendering it. A stale client reading the field just sees it | ||
| # missing rather than the old always-empty placeholder. | ||
| self._create_generation(trace_id="cheap", cost=0.5) | ||
| self._create_generation(trace_id="expensive", cost=5.0) | ||
| flush_persons_and_events() | ||
|
|
||
| response = self.client.get(ENDPOINT_OK) | ||
| assert response.json()["top_traces"] == {"items": [], "truncated": False} | ||
| assert "top_traces" not in response.json() | ||
|
|
||
| def test_other_users_spend_is_not_visible(self) -> None: | ||
| other_email = "other-user@example.com" | ||
|
|
@@ -396,10 +478,10 @@ def test_date_window_passes_through_to_query_layer(self) -> None: | |
| response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from=-7d") | ||
|
|
||
| assert response.status_code == status.HTTP_200_OK | ||
| # 7 fetchers run once (summary, by_product, by_tool, by_model, by_day, and the two | ||
| # by_day_model queries). top_traces | ||
| # is deprecated and returned empty without a query. | ||
| assert mock_exec.call_count == 7 | ||
| # 9 queries run once: summary, by_product, by_tool (+ its cost-attribution | ||
| # sub-query), by_model, by_input_size, by_day, and the two by_day_model queries. | ||
| # top_traces is deprecated and returned empty without a query. | ||
| assert mock_exec.call_count == 9 | ||
|
|
||
| def test_second_call_serves_from_cache(self) -> None: | ||
| with patch("products.ai_observability.backend.api.personal_spend.execute_hogql_query") as mock_exec: | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This fixture is
Bash,Read, wherearrayDistinctis a no-op, so nothing in the suite exercises the repeat case the docstring was written for. DeletearrayDistinctfrom the query and everything still passes. One more generation withtool="Bash,Read,Bash", asserting Bash gets half rather than two thirds, would pin it down.