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
- Endpoint Management — Unified configuration combining provider credentials, model selection, and generation parameters across three built-in providers (OpenRouter, Mistral, DeepSeek)
- Prompt Templates — Reusable prompts with
{{Variable}}substitution syntax, live preview, and variable validation - Completions API — Single-turn (
ask/3), multi-turn (complete/3), and embeddings (embed/3) - Reasoning capture — Chain-of-thought from reasoning models (DeepSeek-R1, Mistral Magistral, OpenAI o-series, Anthropic extended thinking) automatically persisted to request metadata
- Usage Tracking — Every API call logged with tokens, cost (nanodollars), latency, status, and full caller context
- Admin UI — LiveView pages for Endpoints, Prompts, Usage, and a live Playground
- Dynamic model selector — Models auto-load from each provider's
/modelsendpoint when an integration is picked, with a 10-second "still loading" hint and a retry button on transient failures - Real-time Updates — PubSub broadcasts for endpoint/prompt/request changes
- Integrations-backed credentials — API keys resolved via
PhoenixKit.Integrations, not stored per endpoint. Each endpoint pins to a specific connection by uuid; the picker filters to the current provider
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:
- Admin sidebar — AI with subtabs for Endpoints, Prompts, Playground, and Usage
- Admin → Modules — toggle on/off
- Admin → Roles — grant/revoke access per role
- Admin → Settings → Integrations — set up at least one provider connection (OpenRouter, Mistral, or DeepSeek). The endpoint form's picker filters to whichever provider is selected on the dropdown.
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)Extracting response data
{:ok, response} = PhoenixKitAI.ask(endpoint.uuid, "Hello!")
# Text content
{:ok, text} = PhoenixKitAI.extract_content(response)
# Usage statistics (cost in nanodollars)
usage = PhoenixKitAI.extract_usage(response)
# => %{prompt_tokens: 10, completion_tokens: 15, total_tokens: 25, cost_cents: 30}
# Full response includes latency
response["latency_ms"] # => 850Reasoning 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 modelField-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, 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.Integrationsconnection by uuid via theintegration_uuidcolumn (added in core's V107 with backfill from existingproviderstrings). The picker on the endpoint form writes the chosen connection's uuid;OpenRouterClient.resolve_api_key/1looks up credentials by uuid at request time — no per-provider guessing. After a successful migration (manual save with an integration picked, ormigrate_legacy/0at boot), the legacyapi_keycolumn is atomically wiped to""so the credential lives in exactly one place. The column itself stays in the schema (it'sNOT NULLin 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 legacyendpoint.api_keyfor the recommended workflow and the boot-time auto-migrator.
Source tracking & debugging
Every request automatically captures:
- Source — clean caller identifier (e.g.
"MyApp.ContentGenerator.summarize"), auto-extracted from the stacktrace. Override viasource: "CustomLabel". - Stacktrace — up to 20 frames.
- Caller context —
request_id(Phoenix Logger metadata),node,pid. - Message + response content — full user message list and assistant response text. Default-on; controllable via the config flag below.
- Memory snapshot (opt-in) — enable with
config :phoenix_kit_ai, :capture_request_memory, truewhen you need per-request memory data; off by default to keep JSONB metadata small.
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
# 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`).
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 integration_picker. 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)
- Open Settings → Integrations and add an OpenRouter connection if one doesn't exist.
-
Edit the endpoint and select that connection from the
integration_picker. - 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:
-
Targets endpoints with
provider == "openrouter"(the bare default — named connections like"openrouter:my-key"are NEVER touched) AND a non-emptyapi_key. -
Groups by
api_keyvalue (endpoints sharing a key share one connection — dedup). -
Creates one Integrations connection per distinct key. Naming:
"openrouter:default"for single-key deployments;"openrouter:imported-1"/"imported-2"/ etc. for multi-key. -
Updates each endpoint's
providerstring ANDintegration_uuidto point at the new connection — atomically, in a singleRepo.update_all. - Clears the legacy
api_keycolumn atomically with the linking (set to""). The credential lives in exactly one place after migration; the runtime resolver takes theintegration_uuidpath, and a broken integration surfaces a loud error rather than silently coasting on a stale duplicate key. - Un-migrated endpoints (skipped by the where-clause filter, or by
one of the idempotency gates) keep their
api_keypopulated as a safety-net fallback until they're explicitly handled — either via the manual UI workflow or a re-run of the auto-migrator after the blocking condition clears.
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)}
endResponse 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
Costs are stored in nanodollars (1/1,000,000 of a dollar) to preserve precision for cheap calls.
PhoenixKitAI.Request.format_cost(30) # => "$0.000030"
PhoenixKitAI.Request.format_cost(1_500_000) # => "$1.50"format_cost/1 tiers decimal precision: 2 decimals above $0.01,
4 decimals above $0.0001, 6 decimals 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. Migrations are managed by the parent PhoenixKit project — this repo has no migrations of its own.
phoenix_kit_ai_endpoints— endpoint configurationsphoenix_kit_ai_prompts— prompt templatesphoenix_kit_ai_requests— request logs (FK to endpoints, prompts, users)
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 |
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 + dialyzerTesting
# 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
- Models not loading — check the OpenRouter integration has a valid API key in Settings → Integrations, and the account has credits.
- Slow responses — use a faster model (Haiku instead of Opus),
reduce
max_tokens, or check OpenRouter's status page. - High costs — monitor the Usage tab; consider cheaper models and caching repeated queries.
- Debug logging —
Logger.configure(level: :debug). Request logs live inphoenix_kit_ai_requestswith full caller context.
Getting help
- This README for API documentation
- OpenRouter docs: https://openrouter.ai/docs
dev_docs/pull_requests/for the history of reviewed changes