PhoenixKitAI.TaskLimits (PhoenixKitAI v0.26.1)

Copy Markdown View Source

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

scopecounts
:taskevery call of the task, with a user or without
:userthe 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"] of phoenix_kit_ai_task_settings), then the declaration's default_limits.user;
  • :task — the task's own (limits["task"]), then the declaration's default_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

Types

Dollars (a Decimal) for :money, a whole number for requests and tokens.

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

amount()

@type amount() :: Decimal.t() | non_neg_integer()

Dollars (a Decimal) for :money, a whole number for requests and tokens.

limits()

@type limits() :: %{
  optional(unit()) => %{optional(period()) => amount() | :unmigrated}
}

period()

@type period() :: :day | :month

scope()

@type scope() :: :task | :user

status()

@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
}

unit()

@type unit() :: :money | :requests | :tokens

window()

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

Functions

check(spec, opts, now \\ DateTime.utc_now())

@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.

delete_user_limits(scope, task_key, user_uuid)

@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.

effective(spec, user_uuid)

@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.

forgive(scope, task_key, limit_scope, period, user_uuid, now \\ DateTime.utc_now())

@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.

layers(spec)

@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.

list_user_limits(task_key)

@spec list_user_limits(String.t()) :: [PhoenixKitAI.TaskUserLimit.t()]

The personal exceptions of a task, newest first.

money_counted?(endpoint, needs \\ [])

@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.

period_keys(now \\ DateTime.utc_now())

@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"}.

put_user_limits(scope, task_key, user_uuid, limits)

@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.

scopes()

@spec scopes() :: [scope()]

The scopes, units and periods, in the order they are checked.

set_guard_mode(scope, task_key, mode)

@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.

settings(task_key)

@spec settings(String.t()) :: PhoenixKitAI.TaskSetting.t() | nil

The settings row of a task, or nil.

statuses(spec, user_uuid, now \\ DateTime.utc_now())

@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.

topic()

@spec topic() :: String.t()

The PubSub topic of {:task_limits_changed, task_key}.

update_settings(scope, task_key, attrs)

@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.

usage(task_key, user_uuid, now \\ DateTime.utc_now())

@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.

warn_percent(task_key)

@spec warn_percent(PhoenixKitAI.TaskSetting.t() | String.t() | nil) :: pos_integer()

The warn percent of a task (its setting, else 80).

windows(now \\ DateTime.utc_now())

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

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