# `PhoenixKitAI.TaskLimits`
[🔗](https://github.com/BeamLabEU/phoenix_kit_ai/blob/v0.26.1/lib/phoenix_kit_ai/task_limits.ex#L1)

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"]` 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.

# `amount`

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

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

# `limits`

```elixir
@type limits() :: %{
  optional(unit()) =&gt; %{optional(period()) =&gt; amount() | :unmigrated}
}
```

# `period`

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

# `scope`

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

# `status`

```elixir
@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`

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

# `window`

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

# `check`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

The personal exceptions of a task, newest first.

# `money_counted?`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `set_guard_mode`

```elixir
@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`

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

The settings row of a task, or nil.

# `statuses`

```elixir
@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`

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

The PubSub topic of `{:task_limits_changed, task_key}`.

# `update_settings`

```elixir
@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`

```elixir
@spec usage(String.t(), String.t() | nil, DateTime.t()) :: %{
  task: %{required(unit()) =&gt; %{required(period()) =&gt; amount()}},
  user: %{required(unit()) =&gt; %{required(period()) =&gt; 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`

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

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

# `windows`

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

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
