feat(insights): persist LLM prompt + completion token counts

Track DeepSeek token spend per enriched insight so cost can be rolled
up by SQL — `SELECT date_trunc('day', llm_enriched_at), organization_id,
SUM(llm_prompt_tokens), SUM(llm_completion_tokens) FROM preseem_insights
GROUP BY 1, 2`.

- Migration adds llm_prompt_tokens + llm_completion_tokens to
  preseem_insights
- DeepSeek client surfaces the API's `usage` block (prompt/completion/
  total tokens) on the success response
- apply_llm_enrichment/4 takes a usage map and persists the two int
  columns
- Worker plumbs the usage from the response through to the context
This commit is contained in:
Graham McIntire 2026-05-10 11:50:54 -05:00
parent 67d8be6b89
commit e18d877695
7 changed files with 95 additions and 7 deletions

View file

@ -77,7 +77,16 @@ defmodule Towerops.LLM.DeepSeek do
end
defp parse_response(%{"choices" => [%{"message" => %{"content" => content}} | _]} = body) do
{:ok, %{content: content, model: body["model"] || @default_model}}
usage = body["usage"] || %{}
{:ok,
%{
content: content,
model: body["model"] || @default_model,
prompt_tokens: usage["prompt_tokens"],
completion_tokens: usage["completion_tokens"],
total_tokens: usage["total_tokens"]
}}
end
defp parse_response(body) do

View file

@ -30,6 +30,8 @@ defmodule Towerops.Preseem.Insight do
field :recommended_action, :string
field :llm_model, :string
field :llm_enriched_at, :utc_datetime
field :llm_prompt_tokens, :integer
field :llm_completion_tokens, :integer
belongs_to :organization, Towerops.Organizations.Organization
belongs_to :preseem_access_point, Towerops.Preseem.AccessPoint
@ -60,7 +62,9 @@ defmodule Towerops.Preseem.Insight do
:llm_summary,
:recommended_action,
:llm_model,
:llm_enriched_at
:llm_enriched_at,
:llm_prompt_tokens,
:llm_completion_tokens
])
|> validate_required([:organization_id, :type, :urgency, :channel, :title])
|> validate_inclusion(:type, @valid_types)

View file

@ -106,16 +106,19 @@ defmodule Towerops.Preseem.Insights do
@doc """
Apply LLM-generated enrichment to an insight. Sets `llm_summary`,
`recommended_action`, `llm_model`, and `llm_enriched_at` (now).
`recommended_action`, `llm_model`, `llm_enriched_at` (now), and the
prompt/completion token counts when the LLM client surfaces them.
"""
def apply_llm_enrichment(%Insight{} = insight, %{summary: summary} = parsed, model)
def apply_llm_enrichment(%Insight{} = insight, %{summary: summary} = parsed, model, usage \\ %{})
when is_binary(summary) and is_binary(model) do
insight
|> Insight.changeset(%{
llm_summary: summary,
recommended_action: Map.get(parsed, :action),
llm_model: model,
llm_enriched_at: Towerops.Time.now()
llm_enriched_at: Towerops.Time.now(),
llm_prompt_tokens: Map.get(usage, :prompt_tokens),
llm_completion_tokens: Map.get(usage, :completion_tokens)
})
|> Repo.update()
end

View file

@ -31,9 +31,15 @@ defmodule Towerops.Workers.InsightLlmEnrichmentWorker do
messages = InsightPrompt.build(insight)
case LLM.complete(messages, max_tokens: 400, temperature: 0.2) do
{:ok, %{content: content, model: model}} ->
{:ok, %{content: content, model: model} = response} ->
{:ok, parsed} = InsightPrompt.parse(content)
Insights.apply_llm_enrichment(insight, parsed, model)
usage = %{
prompt_tokens: Map.get(response, :prompt_tokens),
completion_tokens: Map.get(response, :completion_tokens)
}
Insights.apply_llm_enrichment(insight, parsed, model, usage)
{:error, reason} ->
Logger.warning("LLM enrichment failed for insight #{insight.id}: #{inspect(reason)}")

View file

@ -0,0 +1,10 @@
defmodule Towerops.Repo.Migrations.AddLlmTokenUsageToPreseemInsights do
use Ecto.Migration
def change do
alter table(:preseem_insights) do
add :llm_prompt_tokens, :integer
add :llm_completion_tokens, :integer
end
end
end

View file

@ -32,6 +32,39 @@ defmodule Towerops.LLM.DeepSeekTest do
DeepSeek.complete([%{role: "user", content: "hello"}], api_key: "test-key")
end
test "surfaces prompt/completion/total tokens from the API usage block" do
Req.Test.stub(DeepSeek, fn conn ->
Req.Test.json(conn, %{
"model" => "deepseek-v4-pro",
"choices" => [%{"message" => %{"content" => "hello"}}],
"usage" => %{"prompt_tokens" => 42, "completion_tokens" => 17, "total_tokens" => 59}
})
end)
assert {:ok, response} =
DeepSeek.complete([%{role: "user", content: "x"}], api_key: "test-key")
assert response.prompt_tokens == 42
assert response.completion_tokens == 17
assert response.total_tokens == 59
end
test "returns nil token counts when the API omits the usage block" do
Req.Test.stub(DeepSeek, fn conn ->
Req.Test.json(conn, %{
"model" => "deepseek-v4-pro",
"choices" => [%{"message" => %{"content" => "hello"}}]
})
end)
assert {:ok, response} =
DeepSeek.complete([%{role: "user", content: "x"}], api_key: "test-key")
assert is_nil(response.prompt_tokens)
assert is_nil(response.completion_tokens)
assert is_nil(response.total_tokens)
end
test "returns {:error, :missing_api_key} when api_key is nil" do
assert {:error, :missing_api_key} =
DeepSeek.complete([%{role: "user", content: "x"}], api_key: nil)

View file

@ -72,6 +72,29 @@ defmodule Towerops.Workers.InsightLlmEnrichmentWorkerTest do
assert updated.llm_enriched_at
end
test "perform/1 persists prompt + completion token counts when the LLM returns usage",
%{organization: org, access_point: ap} do
insight = insert_active_insight(org, ap)
Process.put(
:llm_mock_response,
{:ok,
%{
content: ~s|{"summary":"x","action":"y"}|,
model: "deepseek-v4-pro",
prompt_tokens: 123,
completion_tokens: 45,
total_tokens: 168
}}
)
assert :ok = InsightLlmEnrichmentWorker.perform(%Oban.Job{})
updated = Repo.get!(Insight, insight.id)
assert updated.llm_prompt_tokens == 123
assert updated.llm_completion_tokens == 45
end
test "perform/1 leaves insight unenriched on LLM error and does not crash", %{organization: org, access_point: ap} do
insight = insert_active_insight(org, ap)