PhoenixKitAI (PhoenixKitAI v0.26.1)

Copy Markdown View Source

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

Endpoint CRUD

Completion API

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 global config :phoenix_kit_ai, capture_request_content (below) is the operator's switch, and true on a call never turns capture on while it is false. Honoured by complete/3 and the image verbs (describe_image/3, extract_text/3, compare_images/4, generate_image/3, edit_image/4, process_image/4); speak/3 and embed/3 ignore it and follow the global setting only.
  • cache: - true, [ttl: seconds | :infinity, key: term] or :refresh; see PhoenixKitAI.RequestCache. Entries are shared across users.

  • schema: / json: true on ask/3, complete/3, describe_image/3 - a JSON answer; see PhoenixKitAI.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

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

ai_tasks()

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

ask(endpoint_uuid, prompt, opts \\ [])

@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 struct
  • prompt - User prompt string
  • opts - 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, see complete/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

ask_with_prompt(endpoint_uuid, prompt_uuid, variables \\ %{}, opts \\ [])

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

change_endpoint(endpoint, attrs \\ %{})

@spec change_endpoint(PhoenixKitAI.Endpoint.t(), map()) :: Ecto.Changeset.t()

Returns an endpoint changeset for use in forms.

change_prompt(prompt, attrs \\ %{})

@spec change_prompt(PhoenixKitAI.Prompt.t(), map()) :: Ecto.Changeset.t()

Returns a prompt changeset for use in forms.

compare_images(endpoint_uuid, before, after_image, opts \\ [])

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

complete(endpoint_uuid, messages, opts \\ [])

@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 struct
  • messages - List of message maps with :role and :content
  • opts - Optional parameter overrides

Options

All standard completion parameters plus:

  • :source - Override auto-detected source for request tracking
  • :capture_request_content - false keeps this call's content out of its usage row. It only narrows: true does not override a global config :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's answered_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 - true or [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, honouring Retry-After up to max_delay. Off unless asked for (or set in the app env :retry).
  • :concurrency - [limit:, key:, wait:, max_waiting:, notify:] or false; see PhoenixKitAI.Limiter.
  • :log_cache_hit - false writes 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. See PhoenixKitAI.Errors for 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.

complete_with_system_prompt(endpoint_uuid, prompt_uuid, variables, user_message, opts \\ [])

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

count_enabled_endpoints()

@spec count_enabled_endpoints() :: non_neg_integer()

Counts the number of enabled endpoints.

count_enabled_prompts()

@spec count_enabled_prompts() :: non_neg_integer()

Counts the number of enabled prompts.

count_endpoints()

@spec count_endpoints() :: non_neg_integer()

Counts the total number of endpoints.

count_prompts()

@spec count_prompts() :: non_neg_integer()

Counts the total number of prompts.

count_requests()

@spec count_requests() :: non_neg_integer()

Counts the total number of requests.

create_endpoint(attrs, opts \\ [])

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

create_prompt(attrs, opts \\ [])

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

create_request(attrs)

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

delete_endpoint(endpoint, opts \\ [])

@spec delete_endpoint(PhoenixKitAI.Endpoint.t(), keyword()) ::
  {:ok, PhoenixKitAI.Endpoint.t()} | {:error, Ecto.Changeset.t()}

Deletes an AI endpoint.

delete_prompt(prompt, opts \\ [])

@spec delete_prompt(PhoenixKitAI.Prompt.t(), keyword()) ::
  {:ok, PhoenixKitAI.Prompt.t()} | {:error, Ecto.Changeset.t()}

Deletes an AI prompt.

describe_image(endpoint_uuid, images, opts \\ [])

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

disable_prompt(prompt_uuid, opts \\ [])

@spec disable_prompt(String.t(), keyword()) ::
  {:ok, PhoenixKitAI.Prompt.t()} | {:error, term()}

Disables a prompt. opts takes :actor_uuid, as update_prompt/3 does.

disable_system()

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

Disables the AI module.

dispatch_usage_sinks(request)

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

duplicate_prompt(prompt_uuid, new_name, opts \\ [])

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

edit_image(endpoint_uuid, prompt, images, opts \\ [])

@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"}])

embed(endpoint_uuid, input, opts \\ [])

@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 struct
  • input - Text or list of texts to embed
  • opts - 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.

enable_prompt(prompt_uuid, opts \\ [])

@spec enable_prompt(String.t(), keyword()) ::
  {:ok, PhoenixKitAI.Prompt.t()} | {:error, term()}

Enables a prompt. opts takes :actor_uuid, as update_prompt/3 does.

enable_system()

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

Enables the AI module.

enabled?()

@spec enabled?() :: boolean()

Checks if the AI module is enabled.

endpoints_topic()

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

Returns the PubSub topic for AI endpoints. Subscribe to this topic to receive real-time updates.

extract_content(response)

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?"

extract_text(endpoint_uuid, images, opts \\ [])

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

extract_tool_calls(response)

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.

extract_usage(response)

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}

generate_image(endpoint_uuid, prompt, opts \\ [])

@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_quality columns → :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.

get_config()

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

get_dashboard_stats()

@spec get_dashboard_stats() :: map()

Gets dashboard statistics for display.

Returns stats for the last 30 days plus all-time totals.

get_endpoint(id)

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

get_endpoint!(id)

@spec get_endpoint!(String.t()) :: PhoenixKitAI.Endpoint.t()

Gets a single endpoint by UUID.

Raises Ecto.NoResultsError if the endpoint does not exist.

get_endpoint_by_name(name)

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

get_endpoint_usage_stats()

@spec get_endpoint_usage_stats() :: %{optional(String.t()) => map()}

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.

get_prompt(id)

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

get_prompt!(id)

@spec get_prompt!(String.t()) :: PhoenixKitAI.Prompt.t()

Gets a single prompt by UUID.

Raises Ecto.NoResultsError if the prompt does not exist.

get_prompt_by_slug(slug)

@spec get_prompt_by_slug(String.t()) :: PhoenixKitAI.Prompt.t() | nil

Gets a prompt by slug.

Returns nil if the prompt does not exist.

get_prompt_usage_stats(opts \\ [])

@spec get_prompt_usage_stats(keyword()) :: [map()]

Gets usage statistics for all prompts.

get_prompt_variables(prompt_uuid)

@spec get_prompt_variables(String.t() | PhoenixKitAI.Prompt.t()) ::
  {:ok, [String.t()]} | {:error, term()}

Gets the variables defined in a prompt.

get_prompts_with_variable(variable_name)

@spec get_prompts_with_variable(String.t()) :: [PhoenixKitAI.Prompt.t()]

Finds all prompts that use a specific variable.

get_request(id)

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

get_request!(id)

@spec get_request!(String.t()) :: PhoenixKitAI.Request.t()

Gets a single request by UUID.

get_request_filter_options()

@spec get_request_filter_options() :: map()

Returns filter options for requests (distinct endpoints, models, and sources).

get_requests_by_day(opts \\ [])

@spec get_requests_by_day(keyword()) :: [map()]

Gets request counts grouped by day.

get_tokens_by_model(opts \\ [])

@spec get_tokens_by_model(keyword()) :: [map()]

Gets token usage grouped by model.

get_usage_stats(opts \\ [])

@spec get_usage_stats(keyword()) :: map()

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 with inserted_at in [since, until)
  • :endpoint_uuid, :user_uuid, :status, :model, :source, :source_prefix - filters

Returns

Map with statistics including total_requests, total_tokens, success_rate, etc.

image_model(endpoint_uuid)

The endpoint's own model's capabilities, or nil when the provider publishes none.

image_models(endpoint_uuid, opts \\ [])

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

image_operations()

@spec image_operations() :: %{required(atom()) => map()}

Every image operation process_image/4 understands (built-ins plus the host's).

image_options(endpoint_uuid)

@spec image_options(String.t() | PhoenixKitAI.Endpoint.t()) :: [atom()]

The option names an endpoint's adapter can send when no model listing exists.

increment_prompt_usage(prompt_uuid)

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

list_enabled_prompts()

@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()

list_endpoints(opts \\ [])

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

list_prompts(opts \\ [])

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

list_requests(opts \\ [])

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

mark_endpoint_validated(endpoint)

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

migrate_legacy()

@spec migrate_legacy() :: {:ok, map()} | {:error, term()}

Combined boot-time migration entry point.

Runs both legacy data transitions for AI:

  1. Local api_key → Integrations row + integration_uuid (delegates to run_legacy_api_key_migration/0). Endpoints with bare provider == "openrouter" and a non-empty api_key get grouped, get an Integration row created, and have both provider AND integration_uuid updated to point at it.

  2. provider-string → integration_uuid (sweep). Endpoints with integration_uuid IS NULL whose provider field resolves to a real integration uuid (either bare provider, provider:name shape, or a uuid stuffed in the string column from pre-V107 form saves) get their integration_uuid populated. 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.

migration_module()

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

notification_types()

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

oban_crons()

@spec oban_crons() :: [{String.t(), module()}]

The module's scheduled job: the daily pruning of the guard's events older than 180 days (PhoenixKitAI.GuardEventsPruneWorker, core default queue).

preview_prompt(prompt_uuid, variables \\ %{})

@spec preview_prompt(String.t() | PhoenixKitAI.Prompt.t(), map()) ::
  {:ok, String.t()} | {:error, term()}

Previews a rendered prompt without making an AI call.

process_image(endpoint_uuid, images, operations, opts \\ [])

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

prompts_topic()

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

Returns the PubSub topic for AI prompts.

record_prompt_usage(prompt)

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

render_prompt(prompt_uuid, variables \\ %{})

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

reorder_endpoints(ordered_ids, opts \\ [])

@spec reorder_endpoints([String.t()], keyword()) :: :ok | {:error, :too_many_uuids}

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.

reorder_prompts(ordered_ids, opts \\ [])

@spec reorder_prompts([String.t()], keyword()) :: :ok | {:error, :too_many_uuids}

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.

reports_cost?(endpoint)

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

requests_topic()

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

Returns the PubSub topic for AI requests/usage.

reset_prompt_usage(prompt_uuid, opts \\ [])

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

resolve_endpoint(id)

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

resolve_endpoint_by_name(name)

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

resolve_prompt(prompt)

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

run_legacy_api_key_migration()

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

  1. Group by api_key value — endpoints sharing a key share one connection (dedup).
  2. Create a PhoenixKit.Integrations connection 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.
  3. Update each endpoint's provider field 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_at setting 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-empty api_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.

search_prompts(query, opts \\ [])

@spec search_prompts(String.t(), keyword()) :: [PhoenixKitAI.Prompt.t()]

Searches prompts by name, description, or content. LIKE metacharacters in query (%, _, \) are matched literally.

speak(endpoint_uuid, text, opts \\ [])

@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). When true, the result includes character-level timing data — see Returns below. 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}} - timestamps is nil unless :with_timestamps was 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.

subscribe_endpoints()

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

subscribe_prompts()

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

subscribe_requests()

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

sum_tokens()

@spec sum_tokens() :: non_neg_integer()

Sums the total tokens used across all requests.

translatable_labels()

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

update_endpoint(endpoint, attrs, opts \\ [])

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

update_prompt(prompt, attrs, opts \\ [])

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

validate_prompt(prompt)

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

validate_prompt_content(content)

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

Validates that the content has valid variable syntax.

validate_prompt_variables(prompt_uuid, variables)

@spec validate_prompt_variables(String.t() | PhoenixKitAI.Prompt.t(), term()) ::
  :ok | {:error, term()}

Validates that all required variables are provided for a prompt.