PhoenixKitAI.Guard (PhoenixKitAI v0.26.1)

Copy Markdown View Source

The call-time check every provider-calling verb passes before its HTTP request: does the model behind the endpoint (or the call's model: override) have what the call and its task need.

The needs of a call are its task's declared needs together with what the call itself shows (implied_needs/2):

verbimplied
complete/3, ask/3, ask_with_prompt/4, complete_with_system_prompt/5image_url parts in the messages → :vision; file parts → :file_input (string or atom keys either way); tools: → :tools; schema: / json: → :json
describe_image/3:vision; :json with schema: / json:
extract_text/3, compare_images/4:vision, :json
generate_image/3, edit_image/4, process_image/4:image_output (images given to a generation model are not vision)
embed/3, speak/3nothing

The model checked is the one the verb sends: a model: override of the vision and image verbs, the endpoint's model otherwise.

A hard need (:vision, :file_input, :image_output, :tools, :json_schema) the model says no to refuses the call with {:error, {:model_unfit, missing}}. A need nobody knows about lets the call through, unless the module setting ai_guard_verified_models_only is on — then it refuses with {:error, {:model_unverified, unclear}}. :json is soft: a model without it gets the call (the schema is written into the prompt), and the usage row notes guard_warnings: ["json"].

With a hard :tools / :json_schema need, a call to OpenRouter asks it to route only to upstream providers that support every parameter sent (provider: %{require_parameters: true}): the catalogue's supported_parameters are a union over providers, so without it a fit model could still land on a provider that ignores the schema. The request parameters the model's catalogue entry does not list are then left out (the row notes them as stripped_params), since one of them would leave no provider at all. Without the model's parameter list in the cache (before the first catalogue read after a restart, or a model the catalogue lacks) the request goes as it did before: no flag, nothing left out.

Admission for a whole turn

A caller that makes several calls for one piece of work (an assistant's turn) asks once, with admit/4: the task's limits are checked then, and the answer is an admission token kept on this node for ttl_s seconds. Its calls pass guard_admission: token and skip the limit check — so the work that started under the limits runs to its end — but each is still checked for fitness, and for PhoenixKitAI.Budget. A token is honoured only for the task and user it was issued for, and only before it expires; any other token is ignored and the call is checked as usual.

A blocked person's turn is refused at the admission ({:error, {:task_blocked, group}}); a turn admitted before the block runs to its end.

Off-purpose use

The consumer flags it; the guard never edits a prompt or a schema. A consumer asks self_flag?/1 before it builds its prompt and schema, puts the text of self_flag_instruction/1 into its system prompt (with its own way to flag: a field of its JSON schema, a marker in the answer), and calls report_off_purpose/4 when the model flags a request. The flag is an event per turn or call; the watchers are told once a day per person and task, without the reason (PhoenixKitAI.GuardEvents).

A task may also declare norms that need no content (sources, max_calls_per_hour, max_tokens_per_call): after a call's journal row is written, check_anomalies/1 compares the row with them and signals a broken one once a day (anomaly). It never blocks.

A person is blocked by a person (block/5, unblock/3, see PhoenixKitAI.GuardBlocks); there is no automatic block.

Who may change the guard

ai.manage (a sub-permission of ai) — or an Owner, always: may_manage?/1. Every function that changes the guard's settings takes the scope of the person doing it and checks it (authorize_manage/1). The reasons of a private task's flags and blocks are shown only to them (reasons_visible?/2).

Summary

Functions

Admits user_uuid (nil for none) to task for ttl_s seconds: checks the task's blocks and limits once and answers {:ok, token} for the calls of this piece of work to pass as guard_admission: (see the moduledoc), or the refusal ({:error, {:task_blocked, group}}, or {:error, {:task_limit_exceeded, scope, unit, period}}, which is signalled like a refused call). turn_ref is kept with the token.

Whether token is an admission this node issued for task and user_uuid that has not expired.

Whether user_uuid is blocked from task_key now (for a page to show it).

The fitness part of the call-time check: {:ok, admission} when the model may take the call, {:error, reason} when it may not. task is the call's declaration (or nil); implied what the call shows.

Compares a call's journal row with its task's anomaly rules and signals each broken one, once a day per task, person and rule (PhoenixKitAI.GuardEvents.anomaly/6)

What a call shows it needs, by verb (see the table above). input is the messages for :complete; other verbs ignore it.

The permission that changes the guard.

Whether scope may change the guard: an Owner, or a holder of ai.manage.

Whether scope sees the reason (and the journal row) of a flag or a block: every viewer of the page for a task that is not private, a holder of ai.manage or an Owner for a private one.

Reports that the model flagged a request of task by user_uuid (nil for none) as off-purpose. ref is the call's journal row uuid ("request_uuid" of a chat verb's answer, :request_uuid of an image verb's), or a map with turn_ref (one flag per turn of several calls). A string that is not a uuid is taken as a turn reference. reason is the model's, cut to 500 characters.

The call's request options for an admission that asks OpenRouter to require its parameters: :require_parameters set, and every option the model's catalogue entry does not list left out. Returns the options and the names left out. Without the flag (which is set only with a known parameter list) the options are returned as they are.

What the guard adds to a call's usage row: the task, and the warnings and stripped parameters when there are any.

Whether the consumer of task (a key or a declaration) should have its model flag off-purpose requests: the task's guard mode when an admin set one (self_flag or self_flag+classifier), else the declaration's self_flag. An unknown task is false. Costs no query once the task's settings are cached.

An English instruction a consumer can put into its system prompt: the task's purpose (an admin's override when set), to refuse an off-topic request politely and flag it, to judge only the user's own message, and to give the reason without quoting the user. The consumer adds how to flag (its schema's field, a marker). nil for an unknown task.

Turns "only verified models" on or off. Needs ai.manage; written to the Activity log.

%{task: key} for a call that names a task, %{} otherwise — for a row written before any admission.

Whether a call with an unknown capability is refused (module setting, default off).

The module setting behind verified_only?/0.

Types

admission()

@type admission() :: %{
  task: String.t() | nil,
  needs: [PhoenixKitAI.TaskSpec.need()],
  warnings: [String.t()],
  require_parameters: boolean(),
  supported_parameters: [String.t()] | nil,
  endpoint_uuid: String.t() | nil
}

verb()

@type verb() ::
  :complete
  | :embed
  | :speak
  | :generate_image
  | :edit_image
  | :process_image
  | :describe_image
  | :extract_text
  | :compare_images

Functions

admit(task, user_uuid, turn_ref, ttl_s)

@spec admit(String.t(), String.t() | nil, term(), pos_integer()) ::
  {:ok, String.t()} | {:error, term()}

Admits user_uuid (nil for none) to task for ttl_s seconds: checks the task's blocks and limits once and answers {:ok, token} for the calls of this piece of work to pass as guard_admission: (see the moduledoc), or the refusal ({:error, {:task_blocked, group}}, or {:error, {:task_limit_exceeded, scope, unit, period}}, which is signalled like a refused call). turn_ref is kept with the token.

Without the request cache's flags table (the cache not running) the token is not kept: admitted?/3 says no to it, and each call is checked as if it had none.

admitted?(token, task, user_uuid)

@spec admitted?(term(), term(), term()) :: boolean()

Whether token is an admission this node issued for task and user_uuid that has not expired.

block(scope, task_or_group, user_uuid, reason, origin)

Blocks a person from a task's group: PhoenixKitAI.GuardBlocks.block/5.

blocked?(task_key, user_uuid)

Whether user_uuid is blocked from task_key now (for a page to show it).

check(endpoint, opts, task, implied)

@spec check(PhoenixKitAI.Endpoint.t(), keyword(), PhoenixKitAI.TaskSpec.t() | nil, [
  PhoenixKitAI.TaskSpec.need()
]) :: {:ok, admission()} | {:error, term()}

The fitness part of the call-time check: {:ok, admission} when the model may take the call, {:error, reason} when it may not. task is the call's declaration (or nil); implied what the call shows.

check_anomalies(request)

@spec check_anomalies(PhoenixKitAI.Request.t()) :: :ok

Compares a call's journal row with its task's anomaly rules and signals each broken one, once a day per task, person and rule (PhoenixKitAI.GuardEvents.anomaly/6):

  • sources — the row's source is not one of them (or a function of one: "My.Module" allows "My.Module.run");
  • max_tokens_per_call — the row has more tokens;
  • max_calls_per_hour — the person made more calls of the task in the last hour (rows with a user only; refusals are not calls).

A refused row, a row without a task, and a task without rules cost nothing; the hourly count is skipped once its rule was signalled today. Never fails the caller.

implied_needs(verb, opts, input \\ nil)

@spec implied_needs(verb(), keyword(), term()) :: [PhoenixKitAI.TaskSpec.need()]

What a call shows it needs, by verb (see the table above). input is the messages for :complete; other verbs ignore it.

manage_permission()

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

The permission that changes the guard.

may_manage?(scope)

@spec may_manage?(term()) :: boolean()

Whether scope may change the guard: an Owner, or a holder of ai.manage.

reasons_visible?(scope, arg2)

@spec reasons_visible?(term(), boolean()) :: boolean()

Whether scope sees the reason (and the journal row) of a flag or a block: every viewer of the page for a task that is not private, a holder of ai.manage or an Owner for a private one.

report_off_purpose(task, user_uuid, reason, ref)

@spec report_off_purpose(String.t(), String.t() | nil, term(), String.t() | map()) ::
  {:ok, PhoenixKitAI.GuardEvent.t()}
  | :duplicate
  | :off
  | {:error,
     :unknown_task
     | :invalid_ref
     | :invalid_user
     | :unknown_user
     | :not_recorded}

Reports that the model flagged a request of task by user_uuid (nil for none) as off-purpose. ref is the call's journal row uuid ("request_uuid" of a chat verb's answer, :request_uuid of an image verb's), or a map with turn_ref (one flag per turn of several calls). A string that is not a uuid is taken as a turn reference. reason is the model's, cut to 500 characters.

One flag per turn or call: {:ok, event} for the first, :duplicate after. :off when the task does not self-flag now (self_flag?/1); {:error, :unknown_task | :invalid_ref | :invalid_user | :unknown_user} otherwise, and {:error, :not_recorded} when the database fails (logged as its class; the caller's work goes on). The watchers are told of a person's first flag in the task each day, without the reason (PhoenixKitAI.GuardEvents.off_purpose/5).

require_parameters(opts, admission)

@spec require_parameters(keyword(), admission()) :: {keyword(), [String.t()]}

The call's request options for an admission that asks OpenRouter to require its parameters: :require_parameters set, and every option the model's catalogue entry does not list left out. Returns the options and the names left out. Without the flag (which is set only with a known parameter list) the options are returned as they are.

row_metadata(admission, stripped \\ [])

@spec row_metadata(admission() | nil, [String.t()]) :: map()

What the guard adds to a call's usage row: the task, and the warnings and stripped parameters when there are any.

self_flag?(spec)

@spec self_flag?(String.t() | PhoenixKitAI.TaskSpec.t()) :: boolean()

Whether the consumer of task (a key or a declaration) should have its model flag off-purpose requests: the task's guard mode when an admin set one (self_flag or self_flag+classifier), else the declaration's self_flag. An unknown task is false. Costs no query once the task's settings are cached.

self_flag_instruction(spec)

@spec self_flag_instruction(String.t() | PhoenixKitAI.TaskSpec.t()) ::
  String.t() | nil

An English instruction a consumer can put into its system prompt: the task's purpose (an admin's override when set), to refuse an off-topic request politely and flag it, to judge only the user's own message, and to give the reason without quoting the user. The consumer adds how to flag (its schema's field, a marker). nil for an unknown task.

The text does not follow the locale, so the prompt stays the same.

set_verified_only(scope, on?)

@spec set_verified_only(term(), boolean()) :: {:ok, term()} | {:error, term()}

Turns "only verified models" on or off. Needs ai.manage; written to the Activity log.

task_metadata(opts)

@spec task_metadata(keyword()) :: map()

%{task: key} for a call that names a task, %{} otherwise — for a row written before any admission.

unblock(scope, block, note)

Lifts a block: PhoenixKitAI.GuardBlocks.unblock/3.

verified_only?()

@spec verified_only?() :: boolean()

Whether a call with an unknown capability is refused (module setting, default off).

verified_only_setting()

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

The module setting behind verified_only?/0.