# `PhoenixKitAI`
[🔗](https://github.com/BeamLabEU/phoenix_kit_ai/blob/v0.4.0/lib/phoenix_kit_ai.ex#L1)

Main context for PhoenixKit AI system.

Provides AI endpoint management and usage tracking for AI API requests.

## Architecture

Each **Endpoint** is a unified configuration that combines:
- Provider credentials (api_key, base_url, provider_settings)
- Model selection (single model per endpoint)
- Generation parameters (temperature, max_tokens, etc.)

Users create as many endpoints as needed, each representing one complete
AI configuration ready for making API requests.

## Core Functions

### System Management
- `enabled?/0` - Check if AI module is enabled
- `enable_system/0` - Enable the AI module
- `disable_system/0` - Disable the AI module
- `get_config/0` - Get module configuration with statistics

### Endpoint CRUD
- `list_endpoints/1` - List all endpoints with filters
- `get_endpoint!/1` - Get endpoint by UUID (raises)
- `get_endpoint/1` - Get endpoint by UUID
- `create_endpoint/1` - Create new endpoint
- `update_endpoint/2` - Update existing endpoint
- `delete_endpoint/1` - Delete endpoint

### Completion API
- `ask/3` - Simple single-turn completion
- `complete/3` - Multi-turn chat completion
- `embed/3` - Generate embeddings

### Usage Tracking
- `list_requests/1` - List requests with pagination/filters
- `create_request/1` - Log a new request
- `get_usage_stats/1` - Get aggregated statistics
- `get_dashboard_stats/0` - Get stats for dashboard display

## Usage Examples

    # Enable the module
    PhoenixKitAI.enable_system()

    # Create an endpoint
    {:ok, endpoint} = PhoenixKitAI.create_endpoint(%{
      name: "Claude Fast",
      provider: "openrouter",
      api_key: "sk-or-v1-...",
      model: "anthropic/claude-3-haiku",
      temperature: 0.7
    })

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

    # Extract the response text
    {:ok, text} = PhoenixKitAI.extract_content(response)

## Configuration

    # Persist message + response content in request metadata (default: true).
    # Disable for deployments with PII / data-retention obligations — token
    # counts, latency, model, and cost are still recorded.
    config :phoenix_kit_ai, capture_request_content: false

    # Capture process memory in request caller_context (default: false).
    config :phoenix_kit_ai, capture_request_memory: true

    # Allow endpoint base_url to point at private/loopback IPs (default: false).
    # Required for self-hosted Ollama / intranet inference.
    config :phoenix_kit_ai, allow_internal_endpoint_urls: true

# `ask`

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

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`

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

Returns an endpoint changeset for use in forms.

# `change_prompt`

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

Returns a prompt changeset for use in forms.

# `complete`

```elixir
@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

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

# `complete_with_system_prompt`

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`

```elixir
@spec count_enabled_endpoints() :: non_neg_integer()
```

Counts the number of enabled endpoints.

# `count_enabled_prompts`

```elixir
@spec count_enabled_prompts() :: non_neg_integer()
```

Counts the number of enabled prompts.

# `count_endpoints`

```elixir
@spec count_endpoints() :: non_neg_integer()
```

Counts the total number of endpoints.

# `count_prompts`

```elixir
@spec count_prompts() :: non_neg_integer()
```

Counts the total number of prompts.

# `count_requests`

```elixir
@spec count_requests() :: non_neg_integer()
```

Counts the total number of requests.

# `create_endpoint`

```elixir
@spec create_endpoint(
  map(),
  keyword()
) :: {:ok, PhoenixKitAI.Endpoint.t()} | {:error, Ecto.Changeset.t()}
```

Creates a new AI endpoint.

## 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`

```elixir
@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`

Creates a new AI request record.

Used to log every AI API call for tracking and statistics.

# `delete_endpoint`

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

Deletes an AI endpoint.

# `delete_prompt`

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

Deletes an AI prompt.

# `disable_prompt`

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

Disables a prompt.

# `disable_system`

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

Disables the AI module.

# `duplicate_prompt`

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

Duplicates a prompt with a new name.

# `embed`

```elixir
@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`

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

Enables a prompt.

# `enable_system`

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

Enables the AI module.

# `enabled?`

```elixir
@spec enabled?() :: boolean()
```

Checks if the AI module is enabled.

# `endpoints_topic`

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

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

# `extract_content`

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_usage`

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}

# `get_config`

```elixir
@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`

Gets dashboard statistics for display.

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

# `get_endpoint`

```elixir
@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!`

```elixir
@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_usage_stats`

Returns usage statistics for each endpoint.

Returns a map of endpoint_uuid => %{request_count, total_tokens, total_cost, last_used_at}

# `get_prompt`

```elixir
@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!`

```elixir
@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`

```elixir
@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`

Gets usage statistics for all prompts.

# `get_prompt_variables`

Gets the variables defined in a prompt.

# `get_prompts_with_variable`

Finds all prompts that use a specific variable.

# `get_request`

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!`

Gets a single request by UUID.

# `get_request_filter_options`

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

# `get_requests_by_day`

Gets request counts grouped by day.

# `get_tokens_by_model`

Gets token usage grouped by model.

# `get_usage_stats`

Gets aggregated usage statistics.

## Options
- `:since` - Start date for statistics
- `:until` - End date for statistics
- `:endpoint_uuid` - Filter by endpoint

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

# `increment_prompt_usage`

Increments the usage count for a prompt and updates last_used_at.

# `list_enabled_prompts`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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)
- `:since` - Filter by date (requests after this date)
- `:preload` - Associations to preload

## Returns
`{requests, total_count}`

# `mark_endpoint_validated`

```elixir
@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`

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

# `preview_prompt`

Previews a rendered prompt without making an AI call.

# `prompts_topic`

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

Returns the PubSub topic for AI prompts.

# `record_prompt_usage`

```elixir
@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`

Renders a prompt by replacing variables with provided values.

Returns `{:ok, rendered_text}` or `{:error, reason}`.

# `reorder_endpoints`

```elixir
@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 + first uuid 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`

```elixir
@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 + first uuid 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`.

# `requests_topic`

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

Returns the PubSub topic for AI requests/usage.

# `reset_prompt_usage`

Resets the usage statistics for a prompt.

# `resolve_endpoint`

```elixir
@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_prompt`

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`

```elixir
@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 NEVER cleared — it stays on each row
as a safety net. `OpenRouterClient.resolve_api_key/2` prefers
Integrations, so post-migration endpoints stop firing the legacy
warning; if Integrations later breaks for any reason, the column
still has the value and the fallback path keeps working.

## Idempotency guards (any one short-circuits)

- The `ai_legacy_api_key_migration_completed_at` setting is set →
  already ran, skip.
- ANY `integration:openrouter:*` key 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`

Searches prompts by name, description, or content.

# `subscribe_endpoints`

```elixir
@spec subscribe_endpoints() :: :ok | {:error, term()}
```

Subscribes the current process to AI endpoint changes.

# `subscribe_prompts`

```elixir
@spec subscribe_prompts() :: :ok | {:error, term()}
```

Subscribes the current process to AI prompt changes.

# `subscribe_requests`

```elixir
@spec subscribe_requests() :: :ok | {:error, term()}
```

Subscribes the current process to AI request/usage changes.

# `sum_tokens`

```elixir
@spec sum_tokens() :: non_neg_integer()
```

Sums the total tokens used across all requests.

# `update_endpoint`

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

# `update_prompt`

```elixir
@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`

Validates that a prompt is ready for use.

Returns `{:ok, prompt}` if valid, or `{:error, reason}` if not.

# `validate_prompt_content`

Validates that the content has valid variable syntax.

# `validate_prompt_variables`

Validates that all required variables are provided for a prompt.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
