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:
parent
67d8be6b89
commit
e18d877695
7 changed files with 95 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue