PhoenixKitAI

AI module for PhoenixKit — provides endpoint management, prompt templates, completions, and usage tracking via three OpenAI-compatible providers (OpenRouter, Mistral, DeepSeek). Implements the PhoenixKit.Module behaviour for auto-discovery by a parent Phoenix application.

Features

Quick start

Add to your parent app's mix.exs:

{:phoenix_kit_ai, "~> 0.1"}

Run mix deps.get and start the server. The module appears in:

Installation

Local development

{:phoenix_kit_ai, path: "../phoenix_kit_ai"}

Hex package

{:phoenix_kit_ai, "~> 0.1"}

API usage

Simple chat completion

{:ok, response} = PhoenixKitAI.ask(endpoint.uuid, "What is 2+2?")
{:ok, text} = PhoenixKitAI.extract_content(response)
# => "4"

With system message

{:ok, response} = PhoenixKitAI.ask(endpoint.uuid, "Hello",
  system: "You are a pirate. Always respond like a pirate."
)

Multi-turn conversation

{:ok, response} = PhoenixKitAI.complete(endpoint.uuid, [
  %{role: "system", content: "You are a helpful assistant."},
  %{role: "user", content: "What's the weather like?"},
  %{role: "assistant", content: "I don't have real-time weather data..."},
  %{role: "user", content: "That's okay, just make something up."}
])

Parameter overrides

{:ok, response} = PhoenixKitAI.ask(endpoint.uuid, "Write a creative poem",
  temperature: 1.5,
  max_tokens: 500
)

Embeddings

# Single text
{:ok, response} = PhoenixKitAI.embed(endpoint.uuid, "Hello, world!")

# Batch
{:ok, response} = PhoenixKitAI.embed(endpoint.uuid, ["Text 1", "Text 2"])

# With dimension override
{:ok, response} = PhoenixKitAI.embed(endpoint.uuid, "Hello", dimensions: 512)

Structured output

{:ok, %{"json" => %{"axes" => axes}}} =
  PhoenixKitAI.ask(endpoint_uuid, "Propose 3–5 rating axes for chocolate bars.",
    schema: %{"type" => "object", "properties" => %{"axes" => %{"type" => "array", "items" => %{"type" => "string"}}}})

{:ok, %{"json" => %{}}} = PhoenixKitAI.complete(endpoint_uuid, messages, json: true)

The schema goes to the provider as response_format and into the prompt, so models without response_format still answer in shape (one retry without it); the parsed object comes back under "json". Nothing validates the answer against the schema locally — check the keys you depend on.

Spend caps and the request cache

# Caps in US dollars, as a string or a Decimal; trailing 24 hours; 0 = no cap.
# An integer other than 0 is refused ({:error, :ambiguous_amount}): releases
# before this one took integer millionths of a dollar.
PhoenixKitAI.Budget.set_limit(:global, "5")       # $5 per rolling day for the whole install
PhoenixKitAI.Budget.set_limit(:user, "0.10")      # $0.10 per rolling day per user_uuid

# user_uuid must be a PhoenixKit user uuid (the usage row has a foreign key on it)
{:error, {:budget_exceeded, :user}} = PhoenixKitAI.ask(ep, "…", user_uuid: current_user.uuid)
PhoenixKitAI.Budget.status(endpoint_uuid, user_uuid: current_user.uuid)  # spent / limit / remaining per scope
PhoenixKitAI.get_usage_stats(user_uuid: current_user.uuid, since: yesterday)  # what they spent

# Cache an answer for a day; the second identical call costs nothing
{:ok, _} = PhoenixKitAI.ask(ep, prompt, cache: true)
{:ok, _} = PhoenixKitAI.ask(ep, prompt, cache: [ttl: 3_600])
{:ok, _} = PhoenixKitAI.ask(ep, prompt, cache: [key: {current_user.uuid, "profile"}])
{:ok, _} = PhoenixKitAI.ask(ep, prompt, cache: :refresh)

Caps cover the trailing 24 hours (not a calendar day), are checked before every provider call — cached answers included, so a spent cap stops the site — and warn once at 80 %. The cache is in-memory (ETS), keyed on what would be sent (or on your own cache: [key: …]), shared across users unless your key says otherwise, writes a zero-cost usage row on a hit, and empties on restart — pair it with your own persistence for "write once, keep forever". Every usage row emits [:phoenix_kit_ai, :request] telemetry; the full contract is in dev_docs/guides/spend-caps-and-caching.md.

AI tasks and the model check

A module declares what it uses AI for, and what the model must be able to do, in an ai_tasks/0 callback; calls name the task.

def ai_tasks do
  [
    %PhoenixKitAI.TaskSpec{
      key: "my_module.photo_count",
      label: "Count items in a photo",
      purpose: "Count the stock items visible in a shelf photo.",
      needs: [:vision, :json_schema],
      endpoints: {MyModule.Settings, :photo_endpoint_uuids, []}
    }
  ]
end

{:ok, %{json: json, request_uuid: row}} =
  PhoenixKitAI.describe_image(endpoint_uuid, photos, task: "my_module.photo_count", schema: schema)

# A settings form, before saving the chosen endpoint
:ok = PhoenixKitAI.Tasks.check_selection("my_module.photo_count", endpoint_uuid)
PhoenixKitAI.Tasks.endpoint_options("my_module.photo_count")  # [%{endpoint:, fitness:}]

Before any HTTP, every verb checks the model (or the call's model: override) against the task's needs and what the call itself shows — image parts, file parts, tools:, schema: / json:, and image output for generate_image / edit_image / process_image. A model that says no to images, documents, image generation, tools or a JSON schema is refused with {:error, {:model_unfit, missing}}, written as an error row marked refused: true that no usage sink receives; an unknown capability passes unless ai_guard_verified_models_only is on. With it on, providers whose model list says nothing about capabilities (OpenAI, xAI, Z.ai) need them marked on the endpoint — image generation included — or their calls are refused. Each usage row carries metadata.task, and each result the uuid of its row ("request_uuid" / :request_uuid). «AI → Tasks» lists the declared tasks with their endpoints and fitness.

Task limits

A task can be limited — the whole task, and each user in it — in money, requests and tokens, per calendar day and month of the site time zone. An admin sets them on «AI → Tasks» (with the ai.manage permission), a declaration can give defaults, and a user can have personal limits:

%PhoenixKitAI.TaskSpec{
  key: "my_module.photo_count",
  default_limits: %{user: %{requests: %{day: 50}}, task: %{money: %{month: "20.00"}}},
  watchers: {MyModule.Settings, :limit_watchers, []}   # called with the user uuid appended
}

{:error, {:task_limit_exceeded, :user, :requests, :day}} =
  PhoenixKitAI.ask(endpoint_uuid, "…", task: "my_module.photo_count", user_uuid: user_uuid)

# Several calls for one piece of work: the limits are checked once.
{:ok, token} = PhoenixKitAI.Guard.admit("my_module.photo_count", user_uuid, turn_ref, 300)
PhoenixKitAI.ask(endpoint_uuid, "…", task: "my_module.photo_count", user_uuid: user_uuid,
  guard_admission: token)

Money is in US dollars (a Decimal or a dollar string — never an integer, which releases before this one read as millionths of a dollar) and counts where a call has a cost: the provider's own (OpenRouter), else a catalogue price, the endpoint's manual price (provider_settings["pricing"], set by a holder of ai.manage) or the built-in Anthropic list price — PhoenixKitAI.reports_cost?/1 says whether an endpoint has one, and the usage row's metadata.cost_source which one counted. The vision verbs (describe_image, extract_text, compare_images) are priced like chat, for the model the answer names, else the call's model:, else the endpoint's; making or editing images is priced per image or per call and counts only the provider's own cost (TaskLimits.money_counted?/2 says so for a task that needs :image_output; one that needs :vision gets the chat answer). An empty limit is none, 0 allows nothing. Only successful calls count — a cached answer is one request — and a refusal is an error row like the model check's. At the warn percent (default 80) and when a limit is reached, the user and the watchers (ai_guard_watchers, else every Owner, plus the task's own) get a notification of type "ai_guard", once per window; a Budget refusal is told once a day, and a task whose endpoint's model stops fitting once a day per endpoint.

A module that limits each of its users itself declares the task with user_limits: false: the guard then has only the task's limits for it, ignores the user part of default_limits and refuses user limits ({:error, :user_limits_off}); «AI → Tasks» shows no user limits for it.

Off-purpose use and blocks

The consumer has its model flag requests that are not what the task is for; the guard never edits a prompt or a schema. It gives the instruction text and takes the report:

if PhoenixKitAI.Guard.self_flag?("my_module.photo_count") do
  # Into the system prompt, plus the consumer's own way to flag
  # (a field of its JSON schema, a marker in the answer):
  PhoenixKitAI.Guard.self_flag_instruction("my_module.photo_count")
end

# When the model flagged a call (once per call, or per turn with a turn_ref):
PhoenixKitAI.Guard.report_off_purpose("my_module.photo_count", user_uuid, reason, request_uuid)
PhoenixKitAI.Guard.report_off_purpose("my_module.chat", user_uuid, reason, %{turn_ref: turn_ref})

The watchers are told of a person's first flag in a task each day, never with the reason. An admin with ai.manage turns self-flagging off (or on) per task on «AI → Tasks»; self_flag?/1 follows at once. A declaration may also set norms that need no content (sources:, max_calls_per_hour:, max_tokens_per_call:); a broken one is signalled once a day and blocks nothing.

A person with ai.manage (or an Owner) blocks someone from a task on «AI → Tasks», from a flag or the person's row; the block lands on the task's block_group (the Playground and free-text tasks share free_text), and the person's calls are refused with {:error, {:task_blocked, group}} until it is lifted. Only an Owner blocks an Owner or lifts their block, and nobody but an Owner lifts their own. A private: true task shows the reasons of its flags and blocks only to ai.manage and Owners.

Host apps in the translation pipeline

config :phoenix_kit_ai, translatables: [{"product", MyApp.AI.ProductTranslatable}]

The module implements PhoenixKitAI.Translatable — fetch/2, source_fields/2, put_translation/4 — and its records are then queued with PhoenixKitAI.Translations.enqueue/1 like any kit module's. The shared prompt binds the field names name, title, summary, description, body and content; an adapter with other field names passes its own prompt_uuid, or those values never reach the model. A configured entry wins over a kit module's adapter for the same type.

Image editing and processing

Image-in, image-out through any provider, with the endpoint choosing the transport:

# Named operations, fitted to what the model accepts:
{:ok, %{images: [%{data: png, content_type: "image/png"}], warnings: warnings}} =
  PhoenixKitAI.process_image(endpoint_uuid, [photo_jpeg], [
    :remove_reflections,
    {:clean_background, color: "white"},
    {:upscale, resolution: "2K"}
  ])

# Or a plain instruction with reference images:
{:ok, %{images: [%{data: png}]}} =
  PhoenixKitAI.edit_image(endpoint_uuid, "Restyle the first photo like the second.",
    [%{data: room_jpeg, content_type: "image/jpeg"}, %{data: reference_jpeg, content_type: "image/jpeg"}],
    aspect_ratio: "4:3")

# Vision: free text, or a parsed JSON object per schema
{:ok, %{json: %{"brand" => brand}}} =
  PhoenixKitAI.describe_image(endpoint_uuid, label_jpeg,
    prompt: "Read the label.",
    schema: %{"type" => "object", "properties" => %{"brand" => %{"type" => "string"}}})

# Text extraction (OCR through the vision model): full transcription,
# typed blocks, language, legibility, plus any named values you want pulled out
{:ok, %{text: text, fields: %{"ean" => ean, "best_before" => date}}} =
  PhoenixKitAI.extract_text(endpoint_uuid, label_jpeg,
    fields: %{"ean" => "the barcode digits", "best_before" => "expiry date as YYYY-MM-DD"})

Operations (PhoenixKitAI.Images.Operations): :clean_background, :blur_background, :remove_background, :replace_background, :remove_reflections, :remove_objects, :enhance, :upscale, :relight, :recolor, :straighten, :crop_to_subject, :restyle, plus free text — extendable from config, and any operation's wording can be overridden by a saved prompt named Image op <name>.

Providers: OpenRouter's unified Images API (every image model on the gateway, with per-model capabilities), xAI, OpenAI, and any OpenAI-compatible API through chat completions. Adding one is a PhoenixKitAI.Provider adapter plus a config entry. The module stores no files: results come back as bytes for the caller to keep.

Extracting response data

{:ok, response} = PhoenixKitAI.ask(endpoint.uuid, "Hello!")

# Text content
{:ok, text} = PhoenixKitAI.extract_content(response)

# Usage statistics (cost in US dollars, a Decimal)
usage = PhoenixKitAI.extract_usage(response)
# => %{prompt_tokens: 10, completion_tokens: 15, total_tokens: 25, cost: Decimal.new("0.00003")}

# Full response includes latency
response["latency_ms"] # => 850

Reasoning models — chain-of-thought capture

Reasoning models (DeepSeek-R1, Mistral Magistral, OpenAI o-series, Anthropic extended thinking) return their chain-of-thought alongside the final answer in a per-provider field on the assistant message. PhoenixKitAI normalizes the three known shapes and persists the trace into request metadata so operators can inspect it from the admin Usage page (collapsed by default — chains-of-thought routinely run 5-50× the length of the answer).

{:ok, response} = PhoenixKitAI.complete(endpoint.uuid, [
  %{role: "user", content: "If a train leaves Chicago at 2pm..."}
])

# Final answer
{:ok, answer} = PhoenixKitAI.extract_content(response)

# Chain-of-thought (if the model produced one)
PhoenixKitAI.Completion.extract_reasoning(response)
# => "Step 1: identify the variables...\nStep 2: ..."
# => nil when the model isn't a reasoning model

Field-name normalization (handled internally; you don't need to know which shape your provider uses):

Provider / response shape Field on message
OpenRouter and what it proxies reasoning
DeepSeek native API reasoning_content
Some others thinking

The trace lands in phoenix_kit_ai_requests.metadata.response_reasoning. Subject to the same capture_request_content? privacy gate as response content (see "Privacy / retention controls" below) — when content capture is off (globally, or for the call with capture_request_content: false), reasoning is dropped too. Reasoning can mirror prompt content and is PII-equivalent.

Prompt templates

Prompts are reusable templates with {{VariableName}} substitution. Variable names must start with a letter or underscore.

{:ok, prompt} = PhoenixKitAI.create_prompt(%{
  name: "Email Writer",
  content: "Write a professional email about {{Topic}} to {{Recipient}}."
})

{:ok, response} = PhoenixKitAI.ask_with_prompt(
  endpoint.uuid,
  "email-writer",                 # accepts uuid, slug, or struct
  %{"Topic" => "Q4 results", "Recipient" => "stakeholders"}
)

Other helpers: get_prompt_variables/1, preview_prompt/2, validate_prompt_variables/2, search_prompts/2, get_prompts_with_variable/1, list_prompts/0, list_enabled_prompts/0, enable_prompt/1, disable_prompt/1, duplicate_prompt/2, delete_prompt/1, get_prompt_usage_stats/0, reset_prompt_usage/1.

Endpoint management

# Create — OpenRouter
{:ok, endpoint} = PhoenixKitAI.create_endpoint(%{
  name: "Claude Fast",
  provider: "openrouter",          # provider key (see Supported providers)
  integration_uuid: integration.uuid,
  model: "anthropic/claude-3-haiku",
  temperature: 0.7
})

# Create — Mistral. base_url defaults to https://api.mistral.ai/v1
# via Endpoint.default_base_url/1; model id is provider-native.
{:ok, ep} = PhoenixKitAI.create_endpoint(%{
  name: "Mistral Large",
  provider: "mistral",
  integration_uuid: mistral_integration.uuid,
  model: "mistral-large-latest"
})

# Create — DeepSeek
{:ok, ep} = PhoenixKitAI.create_endpoint(%{
  name: "DeepSeek Reasoner",
  provider: "deepseek",
  integration_uuid: deepseek_integration.uuid,
  model: "deepseek-reasoner"       # reasoning model — chain-of-thought captured
})

# List with filters and sorting
PhoenixKitAI.list_endpoints(
  provider: "openrouter",
  enabled: true,
  sort_by: :usage,
  sort_dir: :desc
)

# Update / toggle / delete
{:ok, updated} = PhoenixKitAI.update_endpoint(endpoint, %{temperature: 0.5})
{:ok, _}       = PhoenixKitAI.update_endpoint(endpoint, %{enabled: false})
{:ok, _}       = PhoenixKitAI.delete_endpoint(endpoint)

API keys are managed via Integrations. Each endpoint references a specific PhoenixKit.Integrations connection by uuid via the integration_uuid column (added in core's V107 with backfill from existing provider strings). The picker on the endpoint form writes the chosen connection's uuid; OpenRouterClient.resolve_api_key/1 looks up credentials by uuid at request time — no per-provider guessing. After a successful migration (manual save with an integration picked, or migrate_legacy/0 at boot), the legacy api_key column is atomically wiped to "" so the credential lives in exactly one place. The column itself stays in the schema (it's NOT NULL in core's V34, so the value must be a string — empty string represents "cleared") so a manual DB recovery is still possible if catastrophe strikes; planned for removal in a future major version. See Migrating from legacy endpoint.api_key for the recommended workflow and the boot-time auto-migrator.

Source tracking & debugging

Every request automatically captures:

All of this is stored in phoenix_kit_ai_requests.metadata (JSONB) and surfaced in the admin Usage page's request-details modal.

Privacy / retention controls

Image processing (see dev_docs/guides/image-processing.md)

provider_adapters: %{}, # provider key => PhoenixKitAI.Provider module image_operations: %{}, # extra / replacement named operations max_image_bytes: 25_000_000, # largest input or fetched output allow_internal_image_urls: false # lift the image-fetch host policy (tests only)

# config/config.exs (defaults shown)
config :phoenix_kit_ai,
  # Persist user message + assistant response content in request
  # metadata. Default `true` matches the shipped debugging shape.
  # Set to `false` for deployments with PII / data-retention
  # obligations — token counts, latency, model, and cost are still
  # recorded; only the user-supplied strings get redacted (replaced
  # with `metadata.content_redacted: true`). A call can only narrow this:
  # `capture_request_content: false` on complete/3 or an image verb redacts
  # that call; `true` on a call never overrides a global `false`
  # (speak/3 and embed/3 ignore the per-call option).
  capture_request_content: true,

  # Capture process memory in request `caller_context` (default off).
  capture_request_memory: false,

  # Bypass SSRF guard on `Endpoint.base_url` — required for
  # self-hosted Ollama / intranet inference. Off by default; the
  # guard rejects loopback / RFC1918 / link-local / `*.local` /
  # non-http(s) URLs unless this is enabled.
  allow_internal_endpoint_urls: false

Migrating from legacy endpoint.api_key

Endpoints created before V107 / the Integrations migration stored the OpenRouter API key directly in the api_key column and used the bare provider field ("openrouter") without a specific connection reference. V107's backfill stamps integration_uuid for any endpoint whose provider matches a PhoenixKit.Integrations row. Endpoints that can't be auto-resolved keep working via the legacy api_key column with a deprecation warning per request — until they're migrated, at which point the column is atomically wiped.

The recommended workflow is to point each endpoint at a specific integration connection via the form's connection picker (a provider's only connection with credentials is picked on its own). The endpoint changeset clears the legacy column to "" in the same DB transaction (Endpoint.maybe_clear_legacy_api_key/1), so once migrated the credential lives only in the integration row.

Stuck endpoints (api_key populated, integration_uuid still NULL — e.g., when V107 couldn't match anything and the boot-time migrator hasn't reached this endpoint) get a "Legacy API key (recovery)" card on the edit form, with a copy button so the operator can paste the key into a new Integration without bouncing back to OpenRouter. The card disappears once an integration is selected and saved.

Manual workflow (per-endpoint)

  1. Open Settings → Integrations and add an OpenRouter connection if one doesn't exist.
  2. Edit the endpoint and select that connection in the connection picker.
  3. Save. The legacy warning stops firing on the next request.

Boot-time auto-migrator

The recommended entry point is the orchestrator PhoenixKit.ModuleRegistry.run_all_legacy_migrations/0. Call it once from your host app's Application.start/2; it walks every registered PhoenixKit module and invokes its migrate_legacy/0 callback (idempotent per module, never crashes the boot):

def start(_type, _args) do
  children = [...]
  result = Supervisor.start_link(children, opts)

  # One call. Walks all modules. Per-module errors caught + logged.
  PhoenixKit.ModuleRegistry.run_all_legacy_migrations()

  result
end

For AI specifically, PhoenixKitAI.migrate_legacy/0 (the callback) runs both kinds of legacy data migration: the api_key→Integrations credentials migration AND the provider-string→integration_uuid reference sweep. Both emit PhoenixKit.Activity entries (action: "integration.legacy_migrated") with PII-safe metadata so operators can audit migrations from the activity feed.

Behaviour:

Idempotency guards (any one short-circuits): completion-flag setting, existing integration:openrouter:* key in phoenix_kit_settings, no endpoints needing migration. Failure modes are contained — top-level try/rescue/catch :exit shell ensures the migration NEVER crashes the host-app boot.

The migration is opt-in (you must call the function explicitly). Operators who prefer the manual UI workflow can simply not call it — the resolver's legacy fallback path keeps existing endpoints working indefinitely.

The api_key column itself is flagged Deprecated in PhoenixKitAI.Endpoint and is planned for removal in a future major version.

Legacy raw_response metadata

Older request rows stored the full raw API response under metadata["raw_response"] for debugging. This was removed to avoid persisting provider responses verbatim; new rows omit the key entirely. The admin usage UI still renders the raw-response panel when it's present in a row's metadata, so historic data stays viewable.

Error handling

All public functions return {:ok, result} or {:error, reason} where reason is an atom from a known set:

Atom Cause Translated message (via PhoenixKitAI.Errors.message/1)
:endpoint_not_found Endpoint UUID does not exist "Endpoint not found"
:endpoint_disabled Endpoint exists but enabled: false "Endpoint is disabled"
:invalid_endpoint_identifier Caller passed a non-UUID value "Invalid endpoint identifier"
:invalid_api_key OpenRouter returned 401 "Invalid API key"
:insufficient_credits OpenRouter returned 402 "Insufficient credits"
:rate_limited OpenRouter returned 429 "Rate limited"
:request_timeout HTTP request timed out "Request timeout"
{:api_error, status} Other non-2xx status "API error: …"
{:connection_error, reason} Transport-level failure "Connection error: …"
:invalid_json_response Response wasn't valid JSON "Invalid JSON response"
{:prompt_error, :not_found | :disabled | :missing_variables} Prompt resolution issues "Prompt …"

Business logic stays locale-agnostic; the UI calls PhoenixKitAI.Errors.message/1 to render the atom as a translated string via gettext.

case PhoenixKitAI.ask(endpoint.uuid, "Hello") do
  {:ok, response} ->
    {:ok, text} = PhoenixKitAI.extract_content(response)
    text

  {:error, reason} ->
    Logger.warning("AI call failed: #{inspect(reason)}")
    {:error, PhoenixKitAI.Errors.message(reason)}
end

Response structure

Chat completion

%{
  "id" => "gen-...",
  "model" => "anthropic/claude-3-haiku",
  "choices" => [
    %{
      "message" => %{"role" => "assistant", "content" => "..."},
      "finish_reason" => "stop"
    }
  ],
  "usage" => %{
    "prompt_tokens" => 10,
    "completion_tokens" => 15,
    "total_tokens" => 25,
    "cost" => 0.00003
  },
  "latency_ms" => 850
}

Embeddings

%{
  "data" => [%{"embedding" => [0.123, -0.456, ...], "index" => 0}],
  "usage" => %{"prompt_tokens" => 5, "total_tokens" => 5},
  "latency_ms" => 120
}

Cost tracking

A call's cost is stored as it is, in US dollars (column cost, numeric(28,18), a Decimal in Elixir), so cheap calls keep their cost: a short embedding at $0.00000004 is 0.00000004.

PhoenixKitAI.Request.format_cost(Decimal.new("0.00000004"))  # => "$0.0000000400"
PhoenixKitAI.Request.format_cost(Decimal.new("0.00003"))     # => "$0.000030"
PhoenixKitAI.Request.format_cost(Decimal.new("1.5"))         # => "$1.50"

format_cost/1 tiers decimal precision: 2 decimals from $0.01, 4 from $0.0001, 6 from $0.000001, 10 below.

Project structure

lib/
  phoenix_kit_ai.ex                    # Main module (behaviour + context)
  phoenix_kit_ai/
    endpoint.ex                        # Endpoint schema
    prompt.ex                          # Prompt template schema
    request.ex                         # Request logging schema
    errors.ex                          # Atom → translated message mapping
    completion.ex                      # OpenRouter HTTP client
    openrouter_client.ex               # API key validation & model discovery
    ai_model.ex                        # Normalized model struct
    routes.ex                          # Admin sub-routes (new/edit forms)
    web/
      endpoints.ex/.heex               # Endpoints list + usage page
      endpoint_form.ex/.heex           # Create/edit endpoint
      prompts.ex/.heex                 # Prompts list
      prompt_form.ex/.heex             # Create/edit prompt
      playground.ex/.heex              # Interactive testing
test/
  phoenix_kit_ai_test.exs              # Behaviour compliance tests
  phoenix_kit_ai/
    completion_test.exs                # HTTP + error parsing (unit)
    completion_coverage_test.exs       # Req.Test-stubbed integration tests
    endpoint_test.exs                  # Schema + CRUD + SSRF guard
    errors_test.exs                    # Atom → message mapping
    openrouter_client_test.exs         # API key + model discovery (unit)
    openrouter_client_coverage_test.exs# Req.Test-stubbed integration tests
    prompt_test.exs                    # Variable extraction + changeset
    prompt_changeset_test.exs          # Persistence + uniqueness
    request_test.exs                   # Schema + format_cost
    coverage_test.exs                  # Top-level public API integration
    schema_coverage_test.exs           # Schema-level edge cases
    activity_logging_test.exs          # Per-action activity log assertions
    legacy_api_key_migration_test.exs  # Auto-migrator (idempotency + dedup)
    destructive_rescue_test.exs        # DROP-TABLE-in-sandbox rescue tests
    web/                               # LiveView smoke + coverage tests
  support/                             # Test infra (DataCase, LiveCase, etc.)

Database tables

All tables use UUIDv7 primary keys and timestamptz columns. The three tables below are created by the parent PhoenixKit project's migrations:

The module's own chain (PhoenixKitAI.Migrations, run by mix phoenix_kit.update after core's) holds only tables core does not create:

It also adds one index to core's phoenix_kit_ai_requests: ((metadata->>'task'), inserted_at). It is built inside the migration's transaction, not concurrently, and AI usage rows wait while it builds: on a large journal, measure it first and update in a quiet window.

Admin pages

Page Path Description
Endpoints /admin/ai/endpoints List, create, edit, delete, validate
Endpoint form /admin/ai/endpoints/new, .../edit Create/edit with model selection
Prompts /admin/ai/prompts List, create, edit, delete, reorder
Prompt form /admin/ai/prompts/new, .../edit Create/edit with variable extraction
Playground /admin/ai/playground Interactive testing with live variables
Usage /admin/ai/usage Dashboard stats and request history
Tasks /admin/ai/tasks Declared AI tasks, their needs, endpoints and fitness; limits, use, watchers; off-purpose flags and blocks (changes need ai.manage)

Supported providers

Three OpenAI-compatible providers are wired into the endpoint form. All share the same Completion.chat_completion/3 HTTP path; the form's provider dropdown drives the picker filter, default base URL, and model-list fetcher.

Provider Default base URL Models endpoint Notes
OpenRouter https://openrouter.ai/api/v1 /models (~100 aggregated chat models with pricing + modality metadata) Models grouped by underlying provider (anthropic, openai, meta-llama, etc.). Embedding models served separately — see below
Mistral https://api.mistral.ai/v1 /v1/models (chat + embedding mixed in one list) OpenAI-compatible response. Pricing / context-length / modality fields aren't returned; the form renders sparser model cards
DeepSeek https://api.deepseek.com/v1 /models (chat only — deepseek-chat, deepseek-reasoner) OpenAI-compatible. Reasoner models return chain-of-thought (see Reasoning capture above)

Each provider's connection is set up under Settings → Integrations (those entries live in core's PhoenixKit.Integrations.Providers registry). After validation, the picker on the AI endpoint form filters connections to whichever provider is currently selected on the dropdown; switching providers clears any selected integration and resets base_url to the new provider's default.

Models auto-load from the chosen provider's /models endpoint when the integration is picked. Slow fetches (>10s) surface a "still loading" hint next to the spinner; failed fetches show a Retry button on the error pane so operators can recover from transient upstream issues without re-picking the integration.

Embedding models for OpenRouter are NOT returned by /models — OpenRouter proxies embeddings via POST /api/v1/embeddings but doesn't list them anywhere queryable. The embedding-model dropdown for OpenRouter endpoints is backed by a curated list in OpenRouterClient.builtin_embedding_models/0 (last refreshed in source); extensible via:

config :phoenix_kit_ai,
  embedding_models: [
    %{"id" => "custom/embedding-model", "name" => "Custom",
      "context_length" => 8192, "dimensions" => 1024,
      "pricing" => %{"prompt" => 0.00000001, "completion" => 0}}
  ]

Mistral exposes embedding models in the same /v1/models list as chat models (mistral-embed, codestral-embed); operators select the right one manually. DeepSeek currently exposes chat models only.

PhoenixKit.Module callbacks

Callback Value
module_key/0 "ai"
module_name/0 "AI"
enabled?/0 DB-backed boolean with rescue fallback
enable_system/0 / disable_system/0 Persist via Settings API
version/0 current package version
permission_metadata/0 key "ai", icon hero-sparkles
admin_tabs/0 Parent + 4 subtabs
css_sources/0 [:phoenix_kit_ai]
route_module/0 PhoenixKitAI.Routes
required_integrations/0 ["openrouter"]
get_config/0 %{enabled, endpoints_count, total_requests, total_tokens}

Development

mix deps.get          # Install dependencies
mix precommit         # compile + format + credo --strict + dialyzer
mix test              # Run all tests
mix format            # Format code
mix credo --strict    # Linting
mix dialyzer          # Type checking
mix quality           # format + credo --strict + dialyzer

Testing

# Unit tests (no DB needed)
mix test

# Integration tests (need PostgreSQL)
createdb phoenix_kit_ai_test
mix test

# LiveView tests (same DB, uses local Test.Endpoint)
mix test test/phoenix_kit_ai/web/

Integration tests use an embedded PhoenixKitAI.Test.Repo with Ecto sandbox. When the test database is absent they are automatically excluded and unit tests still run.

Troubleshooting

Getting help

  1. This README for API documentation
  2. OpenRouter docs: https://openrouter.ai/docs
  3. dev_docs/pull_requests/ for the history of reviewed changes