The limits of an AI task: how much the task may use, and how much each user may use in it, in a calendar day and month of the site time zone.
Scopes, units, periods
| scope | counts |
|---|---|
:task | every call of the task, with a user or without |
:user | the calls of one user in the task |
Units: :money (US dollars, a Decimal; stored as a decimal string, read
through PhoenixKitAI.Money), :requests, :tokens. Periods: :day, :month — the
calendar day and month of the site time zone (Settings → time_zone,
PhoenixKit.Utils.TimeZone), each from its local midnight to the next.
A limit is an amount per (unit, period). An empty cell inherits the next
layer's default; with no default there is no limit. 0 allows nothing.
Which limit is in force
Cell by cell (unit and period), the first that is set:
:user— the user's personal exception (phoenix_kit_ai_task_user_limits), then the task's default for its users (limits["user"]ofphoenix_kit_ai_task_settings), then the declaration'sdefault_limits.user;:task— the task's own (limits["task"]), then the declaration'sdefault_limits.task.
A task declared with user_limits: false (its module limits its users
itself) has no :user scope here: the declaration's and the stored user
values are ignored (and kept), and setting a user limit or forgiving one is
{:error, :user_limits_off}.
What is counted
The task's rows of the journal (phoenix_kit_ai_requests,
metadata->>'task'), successful ones only (status = 'success'): a failed
call costs nothing and a refusal is an error row, so neither counts. Money
is the cost sum (dollars), tokens the total_tokens sum, requests the rows —
so a cached answer (a zero-cost success row) counts as one request and no
money or tokens. Money is what a row's cost says (PhoenixKitAI.CallCost:
the provider's own cost, else a catalogue, the endpoint's manual or the
built-in Anthropic price, metadata.cost_source; the vision verbs are
priced like chat, making or editing images only by the provider's own
cost): an endpoint none of them prices (money_counted?/2) adds nothing
to it, and there requests and tokens are what limits a task.
The check
check/3 runs before every provider call that names a declared task
(after the model check, before PhoenixKitAI.Budget). A limit whose count
has reached it refuses the call with {:error, {:task_limit_exceeded, scope, unit, period}} until its window ends or the limit is raised; the
refusal is a journal row like every guard refusal. There is no reservation:
calls running at the same time can each go past a limit by their own size.
At the warn percent (the task's warn_percent, default 80) a user's limit
tells the user and the task's limit tells the watchers
(PhoenixKitAI.GuardEvents); a reached limit tells both. Each once per
window.
Forgiving
forgive/6 restarts the count of one scope and period from that moment to
the end of the window; the limit itself stays. The moment is kept only as
the limit_forgiven event.
Every function here that changes something takes the scope of the person
doing it and needs ai.manage (PhoenixKitAI.Guard.may_manage?/1); the
change goes to the Activity log.
The check fails open: a database error reads as nothing used.
The cache
A call reads a task's settings row and personal limits from a per-node
cache (the request cache's flags table), loaded on the first call of the
task: a task without limits costs no query. Every change through this
module drops the task's entry here and broadcasts
{:task_limits_changed, task_key} on topic/0, which
PhoenixKitAI.GuardWatch turns into the same drop on every node; a
restarted watch drops the whole cache, since it may have missed a
broadcast. An entry is also read again after 60 seconds, which bounds what
a lost broadcast can leave behind. A change made inside the caller's own
Repo.transaction drops the cache before that transaction commits, so a
call in between may cache the old values again; the 60 seconds bound that
too. The usage and forgiving are always read from the database, and only
when a limit is in force.
Summary
Functions
Whether a call of spec (opts[:user_uuid] the user, when any) may go on
now: :ok, or {:error, {:task_limit_exceeded, scope, unit, period}} for
the first limit reached. Tells the watchers and the user (see the
moduledoc). A money limit in force that is still in the released shape
(normalize/1) refuses with {:error, {:task_limit_unmigrated, task_key}}.
nil (no declared task) is always :ok.
Removes a user's personal limits in a task. Needs ai.manage; written to the Activity log.
The limits in force for spec (and user_uuid, when given; without one
the user scope is empty) — see the moduledoc.
Forgives the limit_scope (:task, or :user with user_uuid) limit of
period from now: its count starts again from then to the end of the
window. Kept as a limit_forgiven event. Needs ai.manage. A user's
limit is {:error, :user_limits_off} for a task declared with
user_limits: false.
What a task's settings and its declaration say, layer by layer, for a page
that edits them: stored (the settings row), declared
(default_limits) and defaults (the two merged — the task's limits and
the limits of a user without a personal exception). The user parts are
empty for a task declared with user_limits: false.
The personal exceptions of a task, newest first.
Whether the money of a task's calls counts on endpoint. For chat and for
a task that reads images (needs without :image_output; the vision
verbs are chat calls with images, priced the same way): some source prices
its calls (PhoenixKitAI.reports_cost?/1 — the provider's own cost, a
catalogue, manual or built-in price). A task that makes images
(:image_output among needs) may call the image-making verbs, priced
per image or per call, which only the provider's own cost prices
(OpenRouter): a manual or built-in price, per token, does not count there.
A caller asking for [:vision] gets the chat answer. Speech is priced by
PhoenixKitAI.TtsPricing on other providers too; an endpoint does not say
which kind it is.
The local day and month of now as keys: %{day: "2026-10-22", month: "2026-10"}.
Sets a user's personal limits in a task (%{unit => %{period => amount}});
with no amount left the exception is removed. Needs ai.manage; written to
the Activity log. {:error, :user_limits_off} for a task declared with
user_limits: false.
The scopes, units and periods, in the order they are checked.
Sets a task's guard mode: "self_flag" (its consumer has the model flag
off-purpose requests) or "off" — what PhoenixKitAI.Guard.self_flag?/1
answers from then on, in place of the declaration's self_flag. The
limits stay as they are. Needs ai.manage; written to the Activity log.
The settings row of a task, or nil.
Every limit in force for spec (and user_uuid) with what is used of it:
the task's first, then the user's; month before day; money, requests,
tokens.
The PubSub topic of {:task_limits_changed, task_key}.
Sets a task's limits and warn percent: attrs takes "limits"
(%{"task" => …, "user" => …}, see the moduledoc; amounts as numbers or
strings, blank for none) and "warn_percent" (1–100, blank for the
default). Needs ai.manage; written to the Activity log.
What the task (and user_uuid in it, when given) used in the day and month
of now, from the moment a limit was forgiven when it was:
%{task: %{unit => %{day:, month:}}, user: … | nil}. The user is counted
for any task, also one declared with user_limits: false, where that
count limits nothing.
The warn percent of a task (its setting, else 80).
The local day and month that contain now, as half-open UTC windows.
Types
@type amount() :: Decimal.t() | non_neg_integer()
Dollars (a Decimal) for :money, a whole number for requests and tokens.
@type period() :: :day | :month
@type scope() :: :task | :user
@type status() :: %{ scope: scope(), unit: unit(), period: period(), used: amount(), limit: amount() | :unmigrated, period_key: String.t(), ends_at: DateTime.t(), forgiven_at: DateTime.t() | nil }
@type unit() :: :money | :requests | :tokens
@type window() :: {DateTime.t(), DateTime.t()}
Functions
@spec check(PhoenixKitAI.TaskSpec.t() | nil, keyword(), DateTime.t()) :: :ok | {:error, {:task_limit_exceeded, scope(), unit(), period()}} | {:error, {:task_limit_unmigrated, String.t()}}
Whether a call of spec (opts[:user_uuid] the user, when any) may go on
now: :ok, or {:error, {:task_limit_exceeded, scope, unit, period}} for
the first limit reached. Tells the watchers and the user (see the
moduledoc). A money limit in force that is still in the released shape
(normalize/1) refuses with {:error, {:task_limit_unmigrated, task_key}}.
nil (no declared task) is always :ok.
@spec delete_user_limits(term(), String.t(), String.t()) :: {:ok, :deleted} | {:error, :forbidden | :invalid_user | :unknown_user}
Removes a user's personal limits in a task. Needs ai.manage; written to the Activity log.
@spec effective(PhoenixKitAI.TaskSpec.t(), String.t() | nil) :: %{ task: limits(), user: limits() }
The limits in force for spec (and user_uuid, when given; without one
the user scope is empty) — see the moduledoc.
@spec forgive(term(), String.t(), scope(), period(), String.t() | nil, DateTime.t()) :: {:ok, PhoenixKitAI.GuardEvent.t()} | {:error, term()}
Forgives the limit_scope (:task, or :user with user_uuid) limit of
period from now: its count starts again from then to the end of the
window. Kept as a limit_forgiven event. Needs ai.manage. A user's
limit is {:error, :user_limits_off} for a task declared with
user_limits: false.
@spec layers(PhoenixKitAI.TaskSpec.t()) :: %{ setting: PhoenixKitAI.TaskSetting.t() | nil, stored: %{task: limits(), user: limits()}, declared: %{task: limits(), user: limits()}, defaults: %{task: limits(), user: limits()} }
What a task's settings and its declaration say, layer by layer, for a page
that edits them: stored (the settings row), declared
(default_limits) and defaults (the two merged — the task's limits and
the limits of a user without a personal exception). The user parts are
empty for a task declared with user_limits: false.
@spec list_user_limits(String.t()) :: [PhoenixKitAI.TaskUserLimit.t()]
The personal exceptions of a task, newest first.
@spec money_counted?(PhoenixKitAI.Endpoint.t(), [PhoenixKitAI.TaskSpec.need()]) :: boolean()
Whether the money of a task's calls counts on endpoint. For chat and for
a task that reads images (needs without :image_output; the vision
verbs are chat calls with images, priced the same way): some source prices
its calls (PhoenixKitAI.reports_cost?/1 — the provider's own cost, a
catalogue, manual or built-in price). A task that makes images
(:image_output among needs) may call the image-making verbs, priced
per image or per call, which only the provider's own cost prices
(OpenRouter): a manual or built-in price, per token, does not count there.
A caller asking for [:vision] gets the chat answer. Speech is priced by
PhoenixKitAI.TtsPricing on other providers too; an endpoint does not say
which kind it is.
@spec period_keys(DateTime.t()) :: %{day: String.t(), month: String.t()}
The local day and month of now as keys: %{day: "2026-10-22", month: "2026-10"}.
@spec put_user_limits(term(), String.t(), String.t(), map()) :: {:ok, PhoenixKitAI.TaskUserLimit.t() | :deleted} | {:error, :forbidden | :unknown_task | :user_limits_off | :invalid_user | :unknown_user | Ecto.Changeset.t()}
Sets a user's personal limits in a task (%{unit => %{period => amount}});
with no amount left the exception is removed. Needs ai.manage; written to
the Activity log. {:error, :user_limits_off} for a task declared with
user_limits: false.
@spec scopes() :: [scope()]
The scopes, units and periods, in the order they are checked.
@spec set_guard_mode(term(), String.t(), String.t()) :: {:ok, PhoenixKitAI.TaskSetting.t()} | {:error, :forbidden | :unknown_task | :invalid_mode | Ecto.Changeset.t()}
Sets a task's guard mode: "self_flag" (its consumer has the model flag
off-purpose requests) or "off" — what PhoenixKitAI.Guard.self_flag?/1
answers from then on, in place of the declaration's self_flag. The
limits stay as they are. Needs ai.manage; written to the Activity log.
@spec settings(String.t()) :: PhoenixKitAI.TaskSetting.t() | nil
The settings row of a task, or nil.
@spec statuses(PhoenixKitAI.TaskSpec.t(), String.t() | nil, DateTime.t()) :: [ status() ]
Every limit in force for spec (and user_uuid) with what is used of it:
the task's first, then the user's; month before day; money, requests,
tokens.
@spec topic() :: String.t()
The PubSub topic of {:task_limits_changed, task_key}.
@spec update_settings(term(), String.t(), map()) :: {:ok, PhoenixKitAI.TaskSetting.t()} | {:error, :forbidden | :unknown_task | :user_limits_off | Ecto.Changeset.t()}
Sets a task's limits and warn percent: attrs takes "limits"
(%{"task" => …, "user" => …}, see the moduledoc; amounts as numbers or
strings, blank for none) and "warn_percent" (1–100, blank for the
default). Needs ai.manage; written to the Activity log.
For a task declared with user_limits: false a user amount is refused
({:error, :user_limits_off}, blank ones are not amounts) and the user
values stored before are kept as they are.
@spec usage(String.t(), String.t() | nil, DateTime.t()) :: %{ task: %{required(unit()) => %{required(period()) => amount()}}, user: %{required(unit()) => %{required(period()) => amount()}} | nil }
What the task (and user_uuid in it, when given) used in the day and month
of now, from the moment a limit was forgiven when it was:
%{task: %{unit => %{day:, month:}}, user: … | nil}. The user is counted
for any task, also one declared with user_limits: false, where that
count limits nothing.
@spec warn_percent(PhoenixKitAI.TaskSetting.t() | String.t() | nil) :: pos_integer()
The warn percent of a task (its setting, else 80).
@spec windows(DateTime.t()) :: %{day: window(), month: window()}
The local day and month that contain now, as half-open UTC windows.