PhoenixKitAgent.Spend (PhoenixKitAgent v0.1.0)

Copy Markdown View Source

What users spent on the assistant (spec §7.3), counted from the AI request journal (phoenix_kit_ai_requests) rather than from the module's own messages: a row of the user (user_uuid) whose metadata.source is "agent" — every model call of the module passes both, so a call that describes an attachment is counted too. The journal is not pruned by age, so the month never loses rows. The journal keeps a cost in dollars (cost, a numeric that can be finer than a micro-dollar); the sums are in micro-dollars ($1 = 1_000_000): each window is added up in dollars and only then rounded, half up, to a whole micro-dollar (PhoenixKitAgent.Config.cost_to_micro/1) — two rows of $0.0000004 are 1, not 0. Only endpoints that report a cost add to it.

Every row with a cost counts, whatever its status: a call that failed or timed out after the provider billed it is money spent all the same. Today phoenix_kit_ai writes a cost only on its "success" rows (a failed call is logged with cost NULL, which adds nothing), so the sums equal a success-only count. For the same reason the queries do not filter on status = 'success' and cannot use core's partial index phoenix_kit_ai_requests_user_spend_idx; they go by the journal's user_uuid index and the month's window.

The windows are calendar days and months in the site time zone (Settings → time_zone; PhoenixKit.Utils.TimeZone), each from its local midnight to the next one — so a day that has 23 or 25 hours is counted right.

Turns are counted from the module's own messages (turns_today/2): a turn is one user message with a turn_ref.

Summary

Types

Half-open: from the first instant, up to but not including the second.

Functions

What each of user_uuids spent today and this month (the windows of now): %{user_uuid => %{day_micro_usd:, month_micro_usd:}}, every user in the answer, zero when there is nothing.

Micro-dollars as dollars with at least two decimals: 1_500_000 is "1.50".

The local day and month that contain now, as keys: %{day: "2026-10-19", month: "2026-10"}.

How many turns each of user_uuids started today (the local day of now), in any of their chats: %{user_uuid => count}, every user in the answer.

Everyone who used the assistant this month (the local month of now): a request in the journal or a turn. User uuids; a journal row whose user was deleted has none and is left out, and so is a call the AI guard refused (metadata.refused): it was never made.

The local day and month that contain now, as UTC windows.

Types

spent()

@type spent() :: %{
  day_micro_usd: non_neg_integer(),
  month_micro_usd: non_neg_integer()
}

window()

@type window() :: {DateTime.t(), DateTime.t()}

Half-open: from the first instant, up to but not including the second.

Functions

for_users(user_uuids, now \\ DateTime.utc_now())

@spec for_users([String.t()], DateTime.t()) :: %{required(String.t()) => spent()}

What each of user_uuids spent today and this month (the windows of now): %{user_uuid => %{day_micro_usd:, month_micro_usd:}}, every user in the answer, zero when there is nothing.

format_usd(micro)

@spec format_usd(integer()) :: String.t()

Micro-dollars as dollars with at least two decimals: 1_500_000 is "1.50".

period_keys(now \\ DateTime.utc_now())

@spec period_keys(DateTime.t()) :: %{day: String.t(), month: String.t()}

The local day and month that contain now, as keys: %{day: "2026-10-19", month: "2026-10"}.

turns_today(user_uuids, now \\ DateTime.utc_now())

@spec turns_today([String.t()], DateTime.t()) :: %{
  required(String.t()) => non_neg_integer()
}

How many turns each of user_uuids started today (the local day of now), in any of their chats: %{user_uuid => count}, every user in the answer.

users_this_month(now \\ DateTime.utc_now())

@spec users_this_month(DateTime.t()) :: [String.t()]

Everyone who used the assistant this month (the local month of now): a request in the journal or a turn. User uuids; a journal row whose user was deleted has none and is left out, and so is a call the AI guard refused (metadata.refused): it was never made.

windows(now \\ DateTime.utc_now())

@spec windows(DateTime.t()) :: %{day: window(), month: window()}

The local day and month that contain now, as UTC windows.