122 lines
2.8 KiB
Elixir
122 lines
2.8 KiB
Elixir
defmodule Symbiont.Ledger do
|
|
@moduledoc """
|
|
Append-only JSONL ledger for tracking all inference calls.
|
|
|
|
Every call to Claude gets logged with model, tokens, cost, timing.
|
|
This is the source of truth for cost tracking and billing analysis.
|
|
"""
|
|
use GenServer
|
|
|
|
require Logger
|
|
|
|
# -- Client API --
|
|
|
|
def start_link(opts) do
|
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
end
|
|
|
|
@doc "Append a ledger entry. Returns :ok."
|
|
def append(entry) when is_map(entry) do
|
|
GenServer.cast(__MODULE__, {:append, entry})
|
|
end
|
|
|
|
@doc "Read the last `n` ledger entries."
|
|
def recent(n \\ 50) do
|
|
GenServer.call(__MODULE__, {:recent, n})
|
|
end
|
|
|
|
@doc "Compute aggregate stats across the full ledger."
|
|
def stats do
|
|
GenServer.call(__MODULE__, :stats)
|
|
end
|
|
|
|
# -- Server Callbacks --
|
|
|
|
@impl true
|
|
def init(opts) do
|
|
data_dir = Keyword.fetch!(opts, :data_dir)
|
|
path = Path.join(data_dir, "ledger.jsonl")
|
|
|
|
unless File.exists?(path), do: File.write!(path, "")
|
|
|
|
{:ok, %{path: path}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_cast({:append, entry}, state) do
|
|
entry_with_ts =
|
|
entry
|
|
|> Map.put_new(:timestamp, DateTime.utc_now() |> DateTime.to_iso8601())
|
|
|
|
line = Jason.encode!(entry_with_ts) <> "\n"
|
|
File.write!(state.path, line, [:append])
|
|
Logger.info("Ledger entry: model=#{entry[:model]} cost=$#{entry[:estimated_cost_usd]}")
|
|
{:noreply, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:recent, n}, _from, state) do
|
|
entries =
|
|
state.path
|
|
|> File.stream!()
|
|
|> Stream.reject(&(&1 == "\n"))
|
|
|> Enum.to_list()
|
|
|> Enum.take(-n)
|
|
|> Enum.map(&Jason.decode!/1)
|
|
|
|
{:reply, entries, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:stats, _from, state) do
|
|
entries =
|
|
state.path
|
|
|> File.stream!()
|
|
|> Stream.reject(&(&1 == "\n"))
|
|
|> Enum.map(&Jason.decode!/1)
|
|
|
|
total_calls = length(entries)
|
|
total_cost = sum_costs(entries)
|
|
|
|
by_model =
|
|
entries
|
|
|> Enum.group_by(& &1["model"])
|
|
|> Map.new(fn {model, group} ->
|
|
{model, %{"calls" => length(group), "cost" => sum_costs(group)}}
|
|
end)
|
|
|
|
by_date =
|
|
entries
|
|
|> Enum.group_by(fn e ->
|
|
e["timestamp"] |> String.slice(0, 10)
|
|
end)
|
|
|> Map.new(fn {date, group} ->
|
|
{date, %{"calls" => length(group), "cost" => sum_costs(group)}}
|
|
end)
|
|
|
|
result = %{
|
|
"total_calls" => total_calls,
|
|
"total_cost_estimated_usd" => total_cost,
|
|
"by_model" => by_model,
|
|
"by_date" => by_date
|
|
}
|
|
|
|
{:reply, result, state}
|
|
end
|
|
|
|
# -- Private --
|
|
|
|
defp sum_costs(entries) do
|
|
entries
|
|
|> Enum.reduce(0.0, fn entry, acc ->
|
|
acc + to_float(entry["estimated_cost_usd"])
|
|
end)
|
|
|> Float.round(4)
|
|
end
|
|
|
|
defp to_float(nil), do: 0.0
|
|
defp to_float(n) when is_float(n), do: n
|
|
defp to_float(n) when is_integer(n), do: n * 1.0
|
|
defp to_float(_), do: 0.0
|
|
end
|