Main context for PhoenixKit AI system.
Provides AI endpoint management and usage tracking for AI API requests.
Architecture
Each Endpoint is a unified configuration that combines:
- Provider credentials (api_key, base_url, provider_settings)
- Model selection (single model per endpoint)
- Generation parameters (temperature, max_tokens, etc.)
Users create as many endpoints as needed, each representing one complete AI configuration ready for making API requests.
Core Functions
System Management
enabled?/0- Check if AI module is enabledenable_system/0- Enable the AI moduledisable_system/0- Disable the AI moduleget_config/0- Get module configuration with statistics
Endpoint CRUD
list_endpoints/1- List all endpoints with filtersget_endpoint!/1- Get endpoint by UUID (raises)get_endpoint/1- Get endpoint by UUIDcreate_endpoint/1- Create new endpointupdate_endpoint/2- Update existing endpointdelete_endpoint/1- Delete endpoint
Completion API
ask/3- Simple single-turn completioncomplete/3- Multi-turn chat completionembed/3- Generate embeddingsspeak/3- Synthesize speech from textgenerate_image/3,edit_image/4,process_image/4- Imagesdescribe_image/3,extract_text/3,compare_images/4- Vision
Options
Taken by every verb, except capture_request_content: (see its entry):
user_uuid:- a PhoenixKit user uuid recorded on the usage row and selecting the per-user spend cap. Host-supplied identity: the module has no session of its own. An unknown uuid is logged as a warning and the row is written without it, so that call is not attributed or capped.source:,attribution:,idempotency_key:- tracking only; never part of a cache key.capture_request_content: false- keeps this call's message, prompt and response content out of its usage row(s); source, user, tokens, cost and the other base metadata are written either way. It only narrows: the globalconfig :phoenix_kit_ai, capture_request_content(below) is the operator's switch, andtrueon a call never turns capture on while it isfalse. Honoured bycomplete/3and the image verbs (describe_image/3,extract_text/3,compare_images/4,generate_image/3,edit_image/4,process_image/4);speak/3andembed/3ignore it and follow the global setting only.cache:-true,[ttl: seconds | :infinity, key: term]or:refresh; seePhoenixKitAI.RequestCache. Entries are shared across users.schema:/json: trueonask/3,complete/3,describe_image/3- a JSON answer; seePhoenixKitAI.StructuredOutput.- Every verb returns
{:error, {:budget_exceeded, scope}}once a spend cap is reached (PhoenixKitAI.Budget), cached answers included, and every usage row emits[:phoenix_kit_ai, :request].
Usage Tracking
list_requests/1- List requests with pagination/filterscreate_request/1- Log a new requestget_usage_stats/1- Get aggregated statisticsget_dashboard_stats/0- Get stats for dashboard display
Usage Examples
# Enable the module
PhoenixKitAI.enable_system()
# Create an endpoint
{:ok, endpoint} = PhoenixKitAI.create_endpoint(%{
name: "Claude Fast",
provider: "openrouter",
api_key: "sk-or-v1-...",
model: "anthropic/claude-3-haiku",
temperature: 0.7
})
# Use the endpoint
{:ok, response} = PhoenixKitAI.ask(endpoint.uuid, "Hello!")
# Extract the response text
{:ok, text} = PhoenixKitAI.extract_content(response)Configuration
# Persist message + response content in request metadata (default: true).
# Disable for deployments with PII / data-retention obligations — token
# counts, latency, model, and cost are still recorded.
config :phoenix_kit_ai, capture_request_content: false
# Capture process memory in request caller_context (default: false).
config :phoenix_kit_ai, capture_request_memory: true
# Allow endpoint base_url to point at private/loopback IPs (default: false).
# Required for self-hosted Ollama / intranet inference.
config :phoenix_kit_ai, allow_internal_endpoint_urls: true
Summary
Functions
The AI tasks this module runs itself, found by PhoenixKitAI.Tasks like
any module's ai_tasks/0: the translation pipeline and the Playground.
Neither needs more than text in and text out. The Playground takes free
text, so it shares the free_text block group with other free-text
tasks.
Simple helper for single-turn chat completion.
Makes an AI completion using a prompt template.
Returns an endpoint changeset for use in forms.
Returns a prompt changeset for use in forms.
Checks an edited image against its original with a vision endpoint:
same subject, text and logos intact, unwanted changes. See
PhoenixKitAI.Images.compare/4. Logged as a "vision" request.
Makes a chat completion request using a configured endpoint.
Makes an AI completion with a prompt template as the system message.
Counts the number of enabled endpoints.
Counts the number of enabled prompts.
Counts the total number of endpoints.
Counts the total number of prompts.
Counts the total number of requests.
Creates a new AI endpoint.
Creates a new AI prompt.
Creates a new AI request record.
Deletes an AI endpoint.
Deletes an AI prompt.
Asks a vision-capable endpoint about images: free text, or a parsed
JSON object with schema: / json: true. See PhoenixKitAI.Images.describe/3.
Logged as a "vision" request.
Disables a prompt. opts takes :actor_uuid, as update_prompt/3 does.
Disables the AI module.
Usage sinks: every discovered module exporting handle_ai_usage/1
receives each persisted %Request{} — the zero-coupling observer seam
(the Translatables discovery pattern), first consumer being the
projects work ledger's AI attribution. Best-effort per sink: a raising
sink is logged and never breaks request logging.
Duplicates a prompt with a new name.
Edits or restyles images via Completion.edit_image/4 and logs the call
as an "image_edit" request: tokens and cost when the provider reports
them, latency, and input/output image counts and byte sizes. The image
bytes themselves are never persisted in the log.
Makes an embeddings request using a configured endpoint.
Enables a prompt. opts takes :actor_uuid, as update_prompt/3 does.
Enables the AI module.
Checks if the AI module is enabled.
Returns the PubSub topic for AI endpoints. Subscribe to this topic to receive real-time updates.
Extracts the text content from a completion response.
Reads the text in one or more images with a vision endpoint — a label,
a receipt, a sign, a document page — and returns it transcribed in
reading order with typed blocks, the language, a legibility score and
any named fields: the caller asked for. See
PhoenixKitAI.Images.extract_text/3 for the options. Logged as a
"vision" request; cache: and the spend caps apply.
The tool calls the model asked for in a chat completion response, as the
provider sent them; [] when there are none. See
PhoenixKitAI.Completion.extract_tool_calls/1.
Extracts usage information from a response.
Generates image(s) from a text prompt via Completion.generate_image/3.
Gets the AI module configuration with statistics.
Gets dashboard statistics for display.
Gets a single endpoint by UUID.
Gets a single endpoint by UUID.
Gets an endpoint by its name — case-insensitive, and with surrounding
spaces ignored on both sides (a name saved as " Label reader" is still
found), so an app can refer to "Label reader" instead of carrying a uuid
through config. The unique index is on lower(name) alone, so two names
differing only in surrounding spaces can both exist; the exact one wins.
Returns usage statistics for each endpoint.
Gets a single prompt by UUID.
Gets a single prompt by UUID.
Gets a prompt by slug.
Gets usage statistics for all prompts.
Gets the variables defined in a prompt.
Finds all prompts that use a specific variable.
Gets a single request by UUID.
Gets a single request by UUID.
Returns filter options for requests (distinct endpoints, models, and sources).
Gets request counts grouped by day.
Gets token usage grouped by model.
Gets aggregated usage statistics.
The endpoint's own model's capabilities, or nil when the provider publishes none.
The image models the endpoint's provider offers, with what each accepts
(PhoenixKitAI.Images.ImageModel). {:error, :not_supported} when the
provider publishes no listing. refresh: true bypasses the cache.
Every image operation process_image/4 understands (built-ins plus the host's).
The option names an endpoint's adapter can send when no model listing exists.
Increments the usage count for a prompt and updates last_used_at.
Lists only enabled prompts.
Lists all AI endpoints.
Lists all AI prompts.
Lists AI requests with pagination and filters.
Marks an endpoint as validated by updating its last_validated_at timestamp.
Combined boot-time migration entry point.
The module's own migration chain (PhoenixKitAI.Migrations), run by
mix phoenix_kit.update after core's. It holds only tables core does not
create; the endpoint, prompt and request tables stay in core's chain.
The notification type of the module: "ai_guard", what the watchers of
the AI task guard get when a task's limit is near or reached, a spending
cap refuses a call, a task's model stops fitting it, a person's request is
flagged as off-purpose (once a day), a task's anomaly rule is broken, or a
person is blocked or unblocked (PhoenixKitAI.GuardEvents). A watcher can
switch it off for themselves.
The module's scheduled job: the daily pruning of the guard's events older
than 180 days (PhoenixKitAI.GuardEventsPruneWorker, core default
queue).
Previews a rendered prompt without making an AI call.
Edits images with named operations — the generic, provider-neutral
entry point for image processing. See PhoenixKitAI.Images for the
operations, options and the fitting of options to the model.
Returns the PubSub topic for AI prompts.
Increments the usage count for a prompt and updates last_used_at.
Renders a prompt by replacing variables with provided values.
Reorders endpoints based on a list of UUIDs in their new display order.
Reorders prompts based on a list of UUIDs in their new display order.
Whether calls on endpoint get a cost in the usage journal: its provider
reports one (OpenRouter), or the operator's catalogue, the endpoint's
manual price (provider_settings["pricing"]) or the built-in Anthropic
rates price its model. Where it is false, money limits and the daily
budget cannot count the endpoint's calls. The order the sources are tried
in is PhoenixKitAI.CallCost's. It speaks for chat, embeddings and the
vision verbs; making or editing images counts only the provider's own
cost.
Returns the PubSub topic for AI requests/usage.
Resets the usage statistics for a prompt, logging a prompt.usage_reset
activity row. opts takes :actor_uuid, as update_prompt/3 does.
Resolves an endpoint from an ID (UUID string) or Endpoint struct.
get_endpoint_by_name/1 as {:ok, endpoint} or {:error, :endpoint_not_found},
for with chains.
Resolves a prompt from various input types.
One-shot auto-migrator for legacy endpoint.api_key values into
PhoenixKit.Integrations connections.
Searches prompts by name, description, or content. LIKE metacharacters in
query (%, _, \) are matched literally.
Synthesizes speech from text through a configured AI endpoint.
Subscribes the current process to AI endpoint changes.
Subscribes the current process to AI prompt changes.
Subscribes the current process to AI request/usage changes.
Sums the total tokens used across all requests.
Pins every label this module declares as a gettext msgid.
Updates an existing AI endpoint.
Updates an existing AI prompt.
Validates that a prompt is ready for use.
Validates that the content has valid variable syntax.
Validates that all required variables are provided for a prompt.
Functions
@spec ai_tasks() :: [PhoenixKitAI.TaskSpec.t()]
The AI tasks this module runs itself, found by PhoenixKitAI.Tasks like
any module's ai_tasks/0: the translation pipeline and the Playground.
Neither needs more than text in and text out. The Playground takes free
text, so it shares the free_text block group with other free-text
tasks.
@spec ask(String.t() | PhoenixKitAI.Endpoint.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
Simple helper for single-turn chat completion.
Parameters
endpoint_uuid- Endpoint UUID string or Endpoint structprompt- User prompt stringopts- Optional parameter overrides and system message
Options
All options from complete/3 plus:
:system- System message string:schema/:json- Ask for a machine-readable answer, seecomplete/3:source- Override auto-detected source for request tracking
Examples
# Simple question
{:ok, response} = PhoenixKitAI.ask(endpoint_uuid, "What is the capital of France?")
# With system message
{:ok, response} = PhoenixKitAI.ask(endpoint_uuid, "Translate: Hello",
system: "You are a translator. Translate to French."
)
# With custom source for tracking
{:ok, response} = PhoenixKitAI.ask(endpoint_uuid, "Hello!",
source: "Languages"
)
# Extract just the text content
{:ok, response} = PhoenixKitAI.ask(endpoint_uuid, "Hello!")
{:ok, text} = PhoenixKitAI.extract_content(response)Returns
Same as complete/3
@spec ask_with_prompt( String.t() | PhoenixKitAI.Endpoint.t(), String.t() | PhoenixKitAI.Prompt.t(), map(), keyword() ) :: {:ok, map()} | {:error, term()}
Makes an AI completion using a prompt template.
The prompt content is rendered with the provided variables and sent as the user message.
@spec change_endpoint(PhoenixKitAI.Endpoint.t(), map()) :: Ecto.Changeset.t()
Returns an endpoint changeset for use in forms.
@spec change_prompt(PhoenixKitAI.Prompt.t(), map()) :: Ecto.Changeset.t()
Returns a prompt changeset for use in forms.
@spec compare_images( String.t() | PhoenixKitAI.Endpoint.t(), PhoenixKitAI.Images.input(), PhoenixKitAI.Images.input(), keyword() ) :: {:ok, map()} | {:error, term()}
Checks an edited image against its original with a vision endpoint:
same subject, text and logos intact, unwanted changes. See
PhoenixKitAI.Images.compare/4. Logged as a "vision" request.
@spec complete(String.t() | PhoenixKitAI.Endpoint.t(), [map()], keyword()) :: {:ok, map()} | {:error, term()}
Makes a chat completion request using a configured endpoint.
Parameters
endpoint_uuid- Endpoint UUID string or Endpoint structmessages- List of message maps with:roleand:contentopts- Optional parameter overrides
Options
All standard completion parameters plus:
:source- Override auto-detected source for request tracking:capture_request_content-falsekeeps this call's content out of its usage row. It only narrows:truedoes not override a globalconfig :phoenix_kit_ai, capture_request_content: false.:model- send this model instead of the endpoint's. It is the model the guard checks, the cache keys and the usage row names and prices.:models- OpenRouter only: fallback models tried in order when the first is down, rate-limited or refuses (OpenRouter's model routing); the model that answered is recorded as the row'sanswered_model. Each is checked by the guard like:model; one that does not fit refuses the call.
Delivery options
How the call is made, never part of the request cache key (also on
embed/3 and speak/3):
:timeout- receive/connect timeout in ms (default the app env:timeout, else 120 000).:retry-trueor[max_attempts:, base_delay:, max_delay:, retry_timeouts:]retries HTTP 408, 429 (not one about money: an exhausted balance or quota), 500, 502, 503, 504 and transport errors with exponential backoff, honouringRetry-Afterup tomax_delay. Off unless asked for (or set in the app env:retry).:concurrency-[limit:, key:, wait:, max_waiting:, notify:]orfalse; seePhoenixKitAI.Limiter.:log_cache_hit-falsewrites no usage row for a cache hit.
Examples
{:ok, response} = PhoenixKitAI.complete(endpoint_uuid, [
%{role: "user", content: "Hello!"}
])
# With system message
{:ok, response} = PhoenixKitAI.complete(endpoint_uuid, [
%{role: "system", content: "You are a helpful assistant."},
%{role: "user", content: "What is 2+2?"}
])
# With parameter overrides
{:ok, response} = PhoenixKitAI.complete(endpoint_uuid, messages,
temperature: 0.5,
max_tokens: 500
)
# With custom source for tracking
{:ok, response} = PhoenixKitAI.complete(endpoint_uuid, messages,
source: "MyModule"
)Returns
{:ok, response}- Full API response including usage stats{:error, reason}- Error atom or tagged tuple. SeePhoenixKitAI.Errorsfor the vocabulary and translation.
Tool calling
tools: (OpenAI function-tools), tool_choice: and parallel_tool_calls:
go into the request when given. Messages keep tool_calls (on assistant;
content may be nil) and tool_call_id / name (on tool). Read the
model's calls with extract_tool_calls/1. A call with tools: is never
cached, whatever cache: says.
Structured output
schema: %{"type" => "object", …} requests the answer as that JSON
object (the provider's response_format where supported, the schema in
the prompt everywhere) and returns it parsed under response["json"];
json: true accepts any JSON object. See PhoenixKitAI.StructuredOutput.
@spec complete_with_system_prompt( String.t() | PhoenixKitAI.Endpoint.t(), String.t() | PhoenixKitAI.Prompt.t(), map(), String.t(), keyword() ) :: {:ok, map()} | {:error, term()}
Makes an AI completion with a prompt template as the system message.
The prompt is rendered and used as the system message, with the user_message as the user message.
@spec count_enabled_endpoints() :: non_neg_integer()
Counts the number of enabled endpoints.
@spec count_enabled_prompts() :: non_neg_integer()
Counts the number of enabled prompts.
@spec count_endpoints() :: non_neg_integer()
Counts the total number of endpoints.
@spec count_prompts() :: non_neg_integer()
Counts the total number of prompts.
@spec count_requests() :: non_neg_integer()
Counts the total number of requests.
@spec create_endpoint(map(), keyword()) :: {:ok, PhoenixKitAI.Endpoint.t()} | {:error, Ecto.Changeset.t()}
Creates a new AI endpoint.
A manual price in provider_settings["pricing"] (PhoenixKitAI.ManualPrice:
%{"input" => µ$, "output" => µ$} per 1M tokens, both halves, stamped with
the endpoint's model) steers the money limits, so setting one needs the
:scope option of a holder of ai.manage (an Owner always); without it
the call is {:error, changeset} with an error on :provider_settings.
A capability declaration needs the same while only verified models are
allowed.
Examples
{:ok, endpoint} = PhoenixKitAI.create_endpoint(%{
name: "Claude Fast",
provider: "openrouter",
api_key: "sk-or-v1-...",
model: "anthropic/claude-3-haiku",
temperature: 0.7
})
@spec create_prompt(map(), keyword()) :: {:ok, PhoenixKitAI.Prompt.t()} | {:error, Ecto.Changeset.t()}
Creates a new AI prompt.
Examples
{:ok, prompt} = PhoenixKitAI.create_prompt(%{
name: "Translator",
content: "Translate the following text to {{Language}}:\n\n{{Text}}"
})
@spec create_request(map()) :: {:ok, PhoenixKitAI.Request.t()} | {:error, term()}
Creates a new AI request record.
Used to log every AI API call for tracking and statistics.
A call that has happened is written even when one of its references does
not resolve. user_uuid, endpoint_uuid and prompt_uuid are foreign
keys, so an id that names no row — a caller passing a stale or foreign user
id, an endpoint deleted mid-call — used to fail the insert, and the usage
row of a call already made (and paid for) vanished from the log and every
spend cap. Such a reference is now left empty, the submitted id is kept in
metadata["unresolved_refs"], and a warning is logged. The row still counts
toward every cap it can be attributed to (a row without a user still counts
toward its endpoint's and the global cap). Several dangling references are
all dropped, and inside a caller's transaction the refused insert is
confined to a savepoint so the retry — and the caller's transaction — go on.
Any other validation failure still returns {:error, changeset}.
@spec delete_endpoint(PhoenixKitAI.Endpoint.t(), keyword()) :: {:ok, PhoenixKitAI.Endpoint.t()} | {:error, Ecto.Changeset.t()}
Deletes an AI endpoint.
@spec delete_prompt(PhoenixKitAI.Prompt.t(), keyword()) :: {:ok, PhoenixKitAI.Prompt.t()} | {:error, Ecto.Changeset.t()}
Deletes an AI prompt.
@spec describe_image( String.t() | PhoenixKitAI.Endpoint.t(), [PhoenixKitAI.Images.input()] | PhoenixKitAI.Images.input(), keyword() ) :: {:ok, %{text: String.t() | nil, json: map() | nil, model: String.t() | nil}} | {:error, term()}
Asks a vision-capable endpoint about images: free text, or a parsed
JSON object with schema: / json: true. See PhoenixKitAI.Images.describe/3.
Logged as a "vision" request.
@spec disable_prompt(String.t(), keyword()) :: {:ok, PhoenixKitAI.Prompt.t()} | {:error, term()}
Disables a prompt. opts takes :actor_uuid, as update_prompt/3 does.
Disables the AI module.
@spec dispatch_usage_sinks(PhoenixKitAI.Request.t()) :: :ok
Usage sinks: every discovered module exporting handle_ai_usage/1
receives each persisted %Request{} — the zero-coupling observer seam
(the Translatables discovery pattern), first consumer being the
projects work ledger's AI attribution. Best-effort per sink: a raising
sink is logged and never breaks request logging.
A row of a call the guard refused before any HTTP (metadata.refused)
is not dispatched: nothing was spent, and a sink such as a work ledger
would book it as work all the same.
@spec duplicate_prompt(String.t(), String.t(), keyword()) :: {:ok, PhoenixKitAI.Prompt.t()} | {:error, term()}
Duplicates a prompt with a new name.
Everything the operator wrote is copied (description, system prompt,
content, enabled, position, metadata); the slug and variables are derived
afresh and the usage counters start at zero. opts takes :actor_uuid,
as create_prompt/2 does.
@spec edit_image( String.t() | PhoenixKitAI.Endpoint.t(), String.t(), [map() | String.t()], keyword() ) :: {:ok, %{ images: [map()], text: String.t() | nil, model: String.t() | nil, warnings: [term()] }} | {:error, term()}
Edits or restyles images via Completion.edit_image/4 and logs the call
as an "image_edit" request: tokens and cost when the provider reports
them, latency, and input/output image counts and byte sizes. The image
bytes themselves are never persisted in the log.
See Completion.edit_image/4 for the accepted input shapes and the
options; the transport is the endpoint's PhoenixKitAI.Provider
adapter. Options are sent as given — process_image/4 is the verb that
fits them to the model.
{:ok, %{images: [%{data: png, content_type: "image/png"}]}} =
PhoenixKitAI.edit_image(endpoint.uuid,
"Restyle the first photo in the style of the second.",
[%{data: room_jpeg, content_type: "image/jpeg"},
%{data: reference_jpeg, content_type: "image/jpeg"}])
@spec embed( String.t() | PhoenixKitAI.Endpoint.t(), String.t() | [String.t()], keyword() ) :: {:ok, map()} | {:error, term()}
Makes an embeddings request using a configured endpoint.
Parameters
endpoint_uuid- Endpoint UUID string or Endpoint structinput- Text or list of texts to embedopts- Optional parameter overrides
Options
:dimensions- Override embedding dimensions:source- Override auto-detected source for request tracking
Examples
# Single text
{:ok, response} = PhoenixKitAI.embed(endpoint_uuid, "Hello, world!")
# Multiple texts
{:ok, response} = PhoenixKitAI.embed(endpoint_uuid, ["Hello", "World"])
# With dimension override
{:ok, response} = PhoenixKitAI.embed(endpoint_uuid, "Hello", dimensions: 512)
# With custom source for tracking
{:ok, response} = PhoenixKitAI.embed(endpoint_uuid, "Hello",
source: "SemanticSearch"
)Returns
{:ok, response}- Response with embeddings{:error, reason}- Error atom or tagged tuple.
@spec enable_prompt(String.t(), keyword()) :: {:ok, PhoenixKitAI.Prompt.t()} | {:error, term()}
Enables a prompt. opts takes :actor_uuid, as update_prompt/3 does.
Enables the AI module.
@spec enabled?() :: boolean()
Checks if the AI module is enabled.
@spec endpoints_topic() :: String.t()
Returns the PubSub topic for AI endpoints. Subscribe to this topic to receive real-time updates.
Extracts the text content from a completion response.
Examples
{:ok, response} = PhoenixKitAI.ask(endpoint_uuid, "Hello!")
{:ok, text} = PhoenixKitAI.extract_content(response)
# => "Hello! How can I help you today?"
@spec extract_text( String.t() | PhoenixKitAI.Endpoint.t(), [PhoenixKitAI.Images.input()] | PhoenixKitAI.Images.input(), keyword() ) :: {:ok, %{ text: String.t(), blocks: [map()], language: String.t() | nil, confidence: number() | nil, has_illegible_text: boolean(), fields: map(), json: map(), model: String.t() | nil }} | {:error, term()}
Reads the text in one or more images with a vision endpoint — a label,
a receipt, a sign, a document page — and returns it transcribed in
reading order with typed blocks, the language, a legibility score and
any named fields: the caller asked for. See
PhoenixKitAI.Images.extract_text/3 for the options. Logged as a
"vision" request; cache: and the spend caps apply.
{:ok, %{text: text, fields: %{"ean" => ean}}} =
PhoenixKitAI.extract_text(endpoint_uuid, label_jpeg,
fields: %{"ean" => "the barcode digits", "best_before" => "expiry date as YYYY-MM-DD"})
The tool calls the model asked for in a chat completion response, as the
provider sent them; [] when there are none. See
PhoenixKitAI.Completion.extract_tool_calls/1.
Extracts usage information from a response.
Examples
{:ok, response} = PhoenixKitAI.complete(endpoint_uuid, messages)
usage = PhoenixKitAI.extract_usage(response)
# => %{prompt_tokens: 10, completion_tokens: 15, total_tokens: 25}
@spec generate_image(String.t() | PhoenixKitAI.Endpoint.t(), String.t(), keyword()) :: {:ok, %{images: [%{url: String.t() | nil, data: binary() | nil}]}} | {:error, term()}
Generates image(s) from a text prompt via Completion.generate_image/3.
Falls back to the endpoint's stored defaults when the caller omits
them — same pattern speak/3 uses for its stored default voice:
- OpenAI / OpenRouter:
image_size/image_qualitycolumns →:size/:quality - xAI:
provider_settings["aspect_ratio"]/["resolution"]→:aspect_ratio/:resolution(xAI does not accept OpenAI's size/quality fields)
Options
See Completion.generate_image/3 for the full option list
(:n, :response_format, :size, :quality, :style, :background,
:output_format, :aspect_ratio, :resolution).
Returns
{:ok, %{images: [%{url: String.t() | nil, data: binary() | nil}]}}{:error, reason}- Error atom or tagged tuple.
@spec get_config() :: %{ enabled: boolean(), endpoints_count: non_neg_integer(), total_requests: non_neg_integer(), total_tokens: non_neg_integer() }
Gets the AI module configuration with statistics.
Stat queries are wrapped in a try/rescue so that environments without a
live Repo connection (early boot, test cases without sandbox checkout)
still get a well-formed map — matching the defensive pattern in
enabled?/0.
@spec get_dashboard_stats() :: map()
Gets dashboard statistics for display.
Returns stats for the last 30 days plus all-time totals.
@spec get_endpoint(term()) :: PhoenixKitAI.Endpoint.t() | nil
Gets a single endpoint by UUID.
Accepts a UUID string (e.g., "550e8400-e29b-41d4-a716-446655440000").
Returns nil if the endpoint does not exist.
@spec get_endpoint!(String.t()) :: PhoenixKitAI.Endpoint.t()
Gets a single endpoint by UUID.
Raises Ecto.NoResultsError if the endpoint does not exist.
@spec get_endpoint_by_name(term()) :: PhoenixKitAI.Endpoint.t() | nil
Gets an endpoint by its name — case-insensitive, and with surrounding
spaces ignored on both sides (a name saved as " Label reader" is still
found), so an app can refer to "Label reader" instead of carrying a uuid
through config. The unique index is on lower(name) alone, so two names
differing only in surrounding spaces can both exist; the exact one wins.
Disabled endpoints are returned too; calling one is refused at call time
({:error, :endpoint_disabled}). Returns nil when there is none.
Returns usage statistics for each endpoint.
Returns a map of endpoint_uuid => %{request_count, total_tokens, total_cost,
last_used_at}; total_cost is US dollars, a Decimal.
@spec get_prompt(term()) :: PhoenixKitAI.Prompt.t() | nil
Gets a single prompt by UUID.
Accepts a UUID string (e.g., "550e8400-e29b-41d4-a716-446655440000").
Returns nil if the prompt does not exist.
@spec get_prompt!(String.t()) :: PhoenixKitAI.Prompt.t()
Gets a single prompt by UUID.
Raises Ecto.NoResultsError if the prompt does not exist.
@spec get_prompt_by_slug(String.t()) :: PhoenixKitAI.Prompt.t() | nil
Gets a prompt by slug.
Returns nil if the prompt does not exist.
Gets usage statistics for all prompts.
@spec get_prompt_variables(String.t() | PhoenixKitAI.Prompt.t()) :: {:ok, [String.t()]} | {:error, term()}
Gets the variables defined in a prompt.
@spec get_prompts_with_variable(String.t()) :: [PhoenixKitAI.Prompt.t()]
Finds all prompts that use a specific variable.
@spec get_request(term()) :: PhoenixKitAI.Request.t() | nil
Gets a single request by UUID.
Accepts a UUID string (e.g., "550e8400-e29b-41d4-a716-446655440000").
Returns nil if the request does not exist.
@spec get_request!(String.t()) :: PhoenixKitAI.Request.t()
Gets a single request by UUID.
@spec get_request_filter_options() :: map()
Returns filter options for requests (distinct endpoints, models, and sources).
Gets request counts grouped by day.
Gets token usage grouped by model.
Gets aggregated usage statistics.
This is the read side of attribution and the spend caps: what an endpoint, a user or a source spent since a point in time.
Options
:since/:until- rows withinserted_atin[since, until):endpoint_uuid,:user_uuid,:status,:model,:source,:source_prefix- filters
Returns
Map with statistics including total_requests, total_tokens, success_rate, etc.
@spec image_model(String.t() | PhoenixKitAI.Endpoint.t()) :: PhoenixKitAI.Images.ImageModel.t() | nil
The endpoint's own model's capabilities, or nil when the provider publishes none.
@spec image_models(String.t() | PhoenixKitAI.Endpoint.t(), keyword()) :: {:ok, [PhoenixKitAI.Images.ImageModel.t()]} | {:error, term()}
The image models the endpoint's provider offers, with what each accepts
(PhoenixKitAI.Images.ImageModel). {:error, :not_supported} when the
provider publishes no listing. refresh: true bypasses the cache.
Every image operation process_image/4 understands (built-ins plus the host's).
@spec image_options(String.t() | PhoenixKitAI.Endpoint.t()) :: [atom()]
The option names an endpoint's adapter can send when no model listing exists.
@spec increment_prompt_usage(String.t() | PhoenixKitAI.Prompt.t()) :: {:ok, PhoenixKitAI.Prompt.t()} | {:error, term()}
Increments the usage count for a prompt and updates last_used_at.
@spec list_enabled_prompts() :: [PhoenixKitAI.Prompt.t()] | {[PhoenixKitAI.Prompt.t()], non_neg_integer()}
Lists only enabled prompts.
Convenience wrapper for list_prompts(enabled: true).
Examples
PhoenixKitAI.list_enabled_prompts()
@spec list_endpoints(keyword()) :: {[PhoenixKitAI.Endpoint.t()], non_neg_integer()}
Lists all AI endpoints.
Options
:provider- Filter by provider type:enabled- Filter by enabled status:preload- Associations to preload
Examples
PhoenixKitAI.list_endpoints()
PhoenixKitAI.list_endpoints(provider: "openrouter", enabled: true)
@spec list_prompts(keyword()) :: [PhoenixKitAI.Prompt.t()] | {[PhoenixKitAI.Prompt.t()], non_neg_integer()}
Lists all AI prompts.
Options
:sort_by- Field to sort by (default: :sort_order):sort_dir- Sort direction, :asc or :desc (default: :asc):enabled- Filter by enabled status
Examples
PhoenixKitAI.list_prompts()
PhoenixKitAI.list_prompts(sort_by: :name, sort_dir: :asc)
PhoenixKitAI.list_prompts(enabled: true)
@spec list_requests(keyword()) :: {[PhoenixKitAI.Request.t()], non_neg_integer()}
Lists AI requests with pagination and filters.
Options
:page- Page number (default: 1):page_size- Results per page (default: 20):endpoint_uuid- Filter by endpoint:user_uuid- Filter by user:status- Filter by status:model- Filter by model:source- Filter by source (from metadata):source_prefix- Filter by sources starting with this prefix:since- Filter by date (requests after this date):preload- Associations to preload
Returns
{requests, total_count}
@spec mark_endpoint_validated(PhoenixKitAI.Endpoint.t()) :: {:ok, PhoenixKitAI.Endpoint.t()} | {:error, Ecto.Changeset.t()}
Marks an endpoint as validated by updating its last_validated_at timestamp.
Combined boot-time migration entry point.
Runs both legacy data transitions for AI:
Local api_key → Integrations row + integration_uuid (delegates to
run_legacy_api_key_migration/0). Endpoints with bareprovider == "openrouter"and a non-emptyapi_keyget grouped, get an Integration row created, and have bothproviderANDintegration_uuidupdated to point at it.provider-string →integration_uuid(sweep). Endpoints withintegration_uuid IS NULLwhoseproviderfield resolves to a real integration uuid (either bare provider,provider:nameshape, or a uuid stuffed in the string column from pre-V107 form saves) get theirintegration_uuidpopulated. V107's migration backfilled most of these at install time; this pass catches stragglers (e.g., endpoints created post-form-update but pre-V107).
Both kinds log to PhoenixKit.Activity per migrated record / group
with mode: "auto" and module "ai". PII-safe: never logs
api_key values.
Idempotent — run on every host-app boot. Designed to be invoked via
the orchestrator (PhoenixKit.ModuleRegistry.run_all_legacy_migrations/0),
but can be called directly for ad-hoc migration runs.
@spec migration_module() :: module()
The module's own migration chain (PhoenixKitAI.Migrations), run by
mix phoenix_kit.update after core's. It holds only tables core does not
create; the endpoint, prompt and request tables stay in core's chain.
@spec notification_types() :: [map()]
The notification type of the module: "ai_guard", what the watchers of
the AI task guard get when a task's limit is near or reached, a spending
cap refuses a call, a task's model stops fitting it, a person's request is
flagged as off-purpose (once a day), a task's anomaly rule is broken, or a
person is blocked or unblocked (PhoenixKitAI.GuardEvents). A watcher can
switch it off for themselves.
The module's scheduled job: the daily pruning of the guard's events older
than 180 days (PhoenixKitAI.GuardEventsPruneWorker, core default
queue).
@spec preview_prompt(String.t() | PhoenixKitAI.Prompt.t(), map()) :: {:ok, String.t()} | {:error, term()}
Previews a rendered prompt without making an AI call.
@spec process_image( String.t() | PhoenixKitAI.Endpoint.t(), [PhoenixKitAI.Images.input()], [term()], keyword() ) :: {:ok, map()} | {:error, term()}
Edits images with named operations — the generic, provider-neutral
entry point for image processing. See PhoenixKitAI.Images for the
operations, options and the fitting of options to the model.
{:ok, %{images: [%{data: png}], warnings: []}} =
PhoenixKitAI.process_image(endpoint_uuid, [photo],
[:remove_reflections, {:clean_background, color: "white"}])Logged as an "image_edit" request with the operations, the model
used and any option warnings in the metadata. With verify: the result
is checked against the original by compare_images/4 and the verdict
comes back under :verification without changing the outcome — give it
a vision-capable endpoint uuid (verify: text_endpoint_uuid); true
uses the same endpoint, which works only when its model also answers
in text (an image-only model returns a :verification error instead).
@spec prompts_topic() :: String.t()
Returns the PubSub topic for AI prompts.
@spec record_prompt_usage(PhoenixKitAI.Prompt.t()) :: {:ok, PhoenixKitAI.Prompt.t()} | {:error, Ecto.Changeset.t()}
Increments the usage count for a prompt and updates last_used_at.
@spec render_prompt(String.t() | PhoenixKitAI.Prompt.t(), map()) :: {:ok, String.t()} | {:error, term()}
Renders a prompt by replacing variables with provided values.
Returns {:ok, rendered_text} or {:error, reason}.
Reorders endpoints based on a list of UUIDs in their new display order.
Delegates to PhoenixKit.Utils.Reorder.reorder/4 for the shared
two-phase index-rewrite primitive. Returns :ok on success or
{:error, :too_many_uuids} when the payload exceeds the cap.
On success, logs one endpoint.reordered activity row with the
actual updated count and, as the resource, the first uuid in the new
order that names an existing row (Reorder skips ids it does not
know) so the audit feed records the drag-to-reorder action without
per-row noise. opts is forwarded
to log_activity/5 so callers can thread actor_uuid / mode.
Reorders prompts based on a list of UUIDs in their new display order.
Delegates to PhoenixKit.Utils.Reorder.reorder/4 — the same shared
two-phase index-rewrite primitive reorder_endpoints/2 uses, so the
two reorder paths share the malformed-uuid filtering, dedup, and
payload-cap guard. Returns :ok on success or
{:error, :too_many_uuids} when the payload exceeds the cap.
On success, logs one prompt.reordered activity row with the actual
updated count and, as the resource, the first uuid in the new order that
names an existing row (Reorder skips ids it does not know) so the audit
feed records the drag-to-reorder action without per-row noise. opts is forwarded to
log_activity/5 so callers can thread actor_uuid / actor_role.
@spec reports_cost?(PhoenixKitAI.Endpoint.t()) :: boolean()
Whether calls on endpoint get a cost in the usage journal: its provider
reports one (OpenRouter), or the operator's catalogue, the endpoint's
manual price (provider_settings["pricing"]) or the built-in Anthropic
rates price its model. Where it is false, money limits and the daily
budget cannot count the endpoint's calls. The order the sources are tried
in is PhoenixKitAI.CallCost's. It speaks for chat, embeddings and the
vision verbs; making or editing images counts only the provider's own
cost.
@spec requests_topic() :: String.t()
Returns the PubSub topic for AI requests/usage.
@spec reset_prompt_usage(String.t(), keyword()) :: {:ok, PhoenixKitAI.Prompt.t()} | {:error, term()}
Resets the usage statistics for a prompt, logging a prompt.usage_reset
activity row. opts takes :actor_uuid, as update_prompt/3 does.
@spec resolve_endpoint(term()) :: {:ok, PhoenixKitAI.Endpoint.t()} | {:error, :endpoint_not_found | :invalid_endpoint_identifier}
Resolves an endpoint from an ID (UUID string) or Endpoint struct.
Examples
{:ok, endpoint} = PhoenixKitAI.resolve_endpoint("019abc12-3456-7def-8901-234567890abc")
{:ok, endpoint} = PhoenixKitAI.resolve_endpoint(endpoint)
@spec resolve_endpoint_by_name(term()) :: {:ok, PhoenixKitAI.Endpoint.t()} | {:error, :endpoint_not_found}
get_endpoint_by_name/1 as {:ok, endpoint} or {:error, :endpoint_not_found},
for with chains.
@spec resolve_prompt(term()) :: {:ok, PhoenixKitAI.Prompt.t()} | {:error, {:prompt_error, atom()}}
Resolves a prompt from various input types.
Accepts:
- UUID string (e.g., "019abc12-3456-7def-8901-234567890abc")
- String slug (e.g., "my-prompt")
- Prompt struct (returned as-is)
Returns {:ok, prompt} or {:error, reason}.
@spec run_legacy_api_key_migration() :: :ok
One-shot auto-migrator for legacy endpoint.api_key values into
PhoenixKit.Integrations connections.
Mirrors the pattern of PhoenixKit.Integrations.run_legacy_migrations/0
— call it at host-app boot to fold pre-Integrations endpoint api_keys
into the named-connection model. Safe to call multiple times:
multiple idempotency guards short-circuit on already-migrated state.
What it does
For each phoenix_kit_ai_endpoints row whose provider is the bare
string "openrouter" (i.e., NOT already pointing at a named
Integrations connection like "openrouter:my-key") AND whose
api_key is non-empty:
- Group by api_key value — endpoints sharing a key share one connection (dedup).
- Create a
PhoenixKit.Integrationsconnection per distinct key. Naming:"openrouter:default"if there's exactly one key in the deployment;"openrouter:imported-1","openrouter:imported-2"(1-indexed by first-seen order) if there are multiple. - Update each endpoint's
providerfield to point at the new connection key (e.g.,"openrouter:default").
The legacy api_key column is cleared to "" in the same UPDATE
that sets provider and integration_uuid, so the Integrations row
is the only credential source and the two cannot drift apart.
OpenRouterClient.resolve_api_key/2 still falls back to the column
for endpoints this run did not reach.
Idempotency guards (any one short-circuits)
- The
ai_legacy_api_key_migration_completed_atsetting is set → already ran, skip. - ANY OpenRouter integration already exists in
phoenix_kit_settings(operator already set up Integrations manually) → mark completed and skip. - NO endpoints have
provider == "openrouter"with a non-emptyapi_key→ nothing to migrate, mark completed.
Failure modes
Top-level try/rescue/catch :exit so DB outages, race conditions,
or any unexpected exception NEVER crashes the host app's boot.
Per-key-group operations are isolated — one bad group doesn't abort
the others. Partial migration is safe because un-migrated endpoints
still resolve via the legacy fallback path.
Configuration
No options. Disable by simply not calling the function.
@spec search_prompts(String.t(), keyword()) :: [PhoenixKitAI.Prompt.t()]
Searches prompts by name, description, or content. LIKE metacharacters in
query (%, _, \) are matched literally.
@spec speak(String.t() | PhoenixKitAI.Endpoint.t(), String.t(), keyword()) :: {:ok, %{ audio: binary(), format: String.t(), timestamps: [%{char: String.t(), start: float(), end: float()}] | nil }} | {:error, term()}
Synthesizes speech from text through a configured AI endpoint.
Returns the raw audio bytes alongside the format so the caller knows
the file extension / MIME type without re-deriving it. Mirrors the
embed/3 path: endpoint resolution, credential validation, error
mapping, and request logging are all shared.
Options
:voice- Preset voice identifier (e.g."casual_male"):voice_id- Saved/cloned voice id (Mistral hosted):instructions- Steering text for models that support it (e.g.gpt-4o-mini-tts's accent/tone/pacing/language control). Only sent to models known to support it; silently dropped otherwise so a provider that hard-rejects an unrecognized field (Mistral) never sees it:with_timestamps- xAI only, ignored elsewhere (no other provider offers anything equivalent). Whentrue, the result includes character-level timing data — seeReturnsbelow. No price difference; adds latency for xAI's post-synthesis alignment pass.:response_format- Audio format (default"mp3"):source- Request-tracking label (defaults to auto-detected caller)
Only the voice option that is present is sent to the provider.
Examples
{:ok, %{audio: bytes, format: "mp3"}} =
PhoenixKitAI.speak(endpoint_uuid, "Bonjour, comment ça va ?",
voice: "casual_male",
source: "SentenceAudio"
)Returns
{:ok, %{audio: binary(), format: String.t(), timestamps: [%{char: String.t(), start: float(), end: float()}] | nil}}-timestampsisnilunless:with_timestampswas passed to an xAI endpoint and the response actually included them. Word boundaries aren't given directly — split on whitespace to derive them.{:error, reason}- Error atom or tagged tuple.
@spec subscribe_endpoints() :: :ok | {:error, term()}
Subscribes the current process to AI endpoint changes.
Messages are {:endpoint_created | :endpoint_updated | :endpoint_deleted, uuid} — the uuid only, never the record.
@spec subscribe_prompts() :: :ok | {:error, term()}
Subscribes the current process to AI prompt changes.
Messages are {:prompt_created | :prompt_updated | :prompt_deleted, uuid} — the uuid only, never the record.
@spec subscribe_requests() :: :ok | {:error, term()}
Subscribes the current process to AI request/usage changes.
Messages are {:request_created, uuid} — the uuid only, never the record.
@spec sum_tokens() :: non_neg_integer()
Sums the total tokens used across all requests.
@spec translatable_labels() :: [String.t()]
Pins every label this module declares as a gettext msgid.
permission_metadata/0 and admin_tabs/0 declare their labels as plain
string literals, so mix gettext.extract never sees this file — it scans
for gettext macro calls, not struct fields. PhoenixKit translates them at
render time through each declaration's gettext_backend/gettext_domain,
but only if the msgid exists in this module's own catalogue.
Without this function, "AI", "Endpoints", "Prompts", and
"Playground" would depend on mix gettext.extract never running, since
mix gettext.merge deletes any .po entry absent from a freshly built
.pot (on_obsolete: :delete by default) — and "Usage" would stay in
the .pot only by coincidence, because gettext("Usage") happens to
appear verbatim in web/prompts.ex. Reword that string and this tab would
silently revert to English with no compile error and no failing test.
dgettext_noop/2 registers the msgid and returns it unchanged — nothing
here runs at request time.
@spec update_endpoint(PhoenixKitAI.Endpoint.t(), map(), keyword()) :: {:ok, PhoenixKitAI.Endpoint.t()} | {:error, Ecto.Changeset.t()}
Updates an existing AI endpoint.
Accepts an :actor_uuid option so the mutation can be attributed in
the activity feed. If the change toggles the enabled flag an
additional endpoint.enabled / endpoint.disabled entry is logged.
Setting, changing or removing the manual price
(provider_settings["pricing"], PhoenixKitAI.ManualPrice) needs the
:scope option of a holder of ai.manage (an Owner always); without it
the call is {:error, changeset} with an error on :provider_settings.
That includes replacing provider_settings wholesale without
"pricing" on an endpoint that has a price — merge the stored settings
instead. A model change drops the old model's price and needs nothing;
an update that leaves the price as it is needs nothing either.
@spec update_prompt(PhoenixKitAI.Prompt.t(), map(), keyword()) :: {:ok, PhoenixKitAI.Prompt.t()} | {:error, Ecto.Changeset.t()}
Updates an existing AI prompt.
Accepts an :actor_uuid option so the mutation can be attributed in
the activity feed. If the change toggles the enabled flag an
additional prompt.enabled / prompt.disabled entry is logged.
@spec validate_prompt(PhoenixKitAI.Prompt.t()) :: {:ok, PhoenixKitAI.Prompt.t()} | {:error, {:prompt_error, atom()}}
Validates that a prompt is ready for use.
Returns {:ok, prompt} if valid, or {:error, reason} if not.
Validates that the content has valid variable syntax.
@spec validate_prompt_variables(String.t() | PhoenixKitAI.Prompt.t(), term()) :: :ok | {:error, term()}
Validates that all required variables are provided for a prompt.