# AI configuration

Source: https://docs.qiick.io/externa/ai-configuration

AI behaviour is configured in `config/ai.php` and consumed by `laravel/ai` plus Externa’s `AppAssistant` agent. Defaults target a **local OpenAI-compatible gateway** (LM Studio, vLLM, etc.).

> **Related**
> Env reference: [Environment variables](https://docs.qiick.io/externa/environment-variables). HTTP routes: [AI API](https://docs.qiick.io/externa/ai-api). Product overview: [AI assistant](https://docs.qiick.io/externa/ai-assistant).

## Default provider

| Config key                  | Env                   | Default  |
| --------------------------- | --------------------- | -------- |
| `default`                   | `AI_DEFAULT_PROVIDER` | `local`  |
| `default_for_images`        | _(hard-coded)_        | `gemini` |
| `default_for_audio`         | _(hard-coded)_        | `openai` |
| `default_for_transcription` | _(hard-coded)_        | `openai` |
| `default_for_embeddings`    | _(hard-coded)_        | `openai` |
| `default_for_reranking`     | _(hard-coded)_        | `cohere` |

`AppAssistant` is annotated with `#[Provider('local')]`, so chat uses the local provider entry unless you change that attribute / default.

```env
AI_DEFAULT_PROVIDER=local
```

## Local provider (`LOCAL_AI_*`)

Under `providers.local`:

| Setting            | Env                        | Default                                                                |
| ------------------ | -------------------------- | ---------------------------------------------------------------------- |
| Driver             | _(fixed)_                  | `openai` (OpenAI-compatible API shape)                                 |
| URL                | `LOCAL_AI_URL`             | `http://127.0.0.1:1234/v1`                                             |
| API key            | `LOCAL_AI_API_KEY`         | empty string                                                           |
| Default text model | `LOCAL_AI_MODEL`           | config default `local-model`; `.env.example` sets `openai/gpt-oss-20b` |
| Translate model    | `LOCAL_AI_TRANSLATE_MODEL` | _(empty — falls back to `LOCAL_AI_MODEL`)_                             |

```env
LOCAL_AI_URL=http://127.0.0.1:1234/v1
LOCAL_AI_API_KEY=
LOCAL_AI_MODEL=openai/gpt-oss-20b
# Optional: faster non-thinking model for POST /ai/translate-fields (avoids nginx 504)
# LOCAL_AI_TRANSLATE_MODEL=phi-4-mini-instruct
# LOCAL_AI_REASONING_EFFORT=
# LOCAL_AI_CONTEXT_WINDOW=131072
```

`LOCAL_AI_TRANSLATE_MODEL` is an optional faster non-thinking model for AI field translation (`POST /ai/translate-fields`), so the blocking request does not hit an nginx 504. Example: `phi-4-mini-instruct`. See [Translation workspace — AI translate](https://docs.qiick.io/externa/translation-workspace#ai-translate).

> Prefer a model that supports tool calling reliably. Tiny models often hallucinate successful tool results without calling tools — Externa’s instructions tell the agent never to invent success, but a weak model still wastes time.

`AppAssistant` sets a **300s** HTTP timeout and a high `maxSteps()` budget (field-type count + 16) for long tool loops.

> **Local gateway / Herd nginx**
> LM Studio may emit `response.reasoning_text.*` SSE events; Externa remaps them to OpenAI-shaped `response.reasoning_summary_text.*` so `laravel/ai` keeps the stream alive. If the browser still shows a mid-stream “network error”, raise nginx `fastcgi_read_timeout` (Herd default is often **60s**) to at least **600s** while the model thinks without emitting tokens.

## Cloud / other providers

`config/ai.php` registers many providers. Set the matching env keys when you switch `AI_DEFAULT_PROVIDER` or call a named provider:

| Provider key | Typical env vars                                                                                  |
| ------------ | ------------------------------------------------------------------------------------------------- |
| `anthropic`  | `ANTHROPIC_API_KEY`, `ANTHROPIC_URL`                                                              |
| `azure`      | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_URL`, `AZURE_OPENAI_API_VERSION`, deployment names          |
| `bedrock`    | `AWS_BEDROCK_REGION`, `AWS_BEARER_TOKEN_BEDROCK`, AWS credentials / `AWS_USE_DEFAULT_CREDENTIALS` |
| `cohere`     | `COHERE_API_KEY`                                                                                  |
| `deepseek`   | `DEEPSEEK_API_KEY`                                                                                |
| `eleven`     | `ELEVENLABS_API_KEY`                                                                              |
| `gemini`     | `GEMINI_API_KEY`, `GEMINI_URL`                                                                    |
| `groq`       | `GROQ_API_KEY`                                                                                    |
| `jina`       | `JINA_API_KEY`                                                                                    |
| `mistral`    | `MISTRAL_API_KEY`                                                                                 |
| `ollama`     | `OLLAMA_API_KEY`, `OLLAMA_URL` (default `http://localhost:11434`)                                 |
| `openai`     | `OPENAI_API_KEY`, `OPENAI_URL`                                                                    |
| `openrouter` | `OPENROUTER_API_KEY`                                                                              |
| `voyageai`   | `VOYAGEAI_API_KEY`                                                                                |
| `xai`        | `XAI_API_KEY`                                                                                     |

These are **not** all listed in `.env.example`; they are read when present.

## Assistant provider, reasoning and context window (1.4.0)

The in-app Assistant (`AppAssistant`) runs on `config('ai.assistant.provider')`, set by `AI_ASSISTANT_PROVIDER` (default `local`). It can differ from `AI_DEFAULT_PROVIDER`.

Each provider can set a reasoning effort (`low`, `medium` or `high`) and a context window. The Assistant turns the effort into the provider's own option:

| Provider             | Env var                      | Default          | Sent as                                                  |
| -------------------- | ---------------------------- | ---------------- | -------------------------------------------------------- |
| `openai` (and Azure) | `OPENAI_REASONING_EFFORT`    | `medium`         | `reasoning.effort` with an automatic reasoning summary   |
| `anthropic`          | `ANTHROPIC_REASONING_EFFORT` | `medium`         | extended thinking with 2048 / 8192 / 24576 budget tokens |
| `gemini`             | `GEMINI_REASONING_EFFORT`    | `medium`         | `thinking_level` with thought summaries                  |
| `local`              | `LOCAL_AI_REASONING_EFFORT`  | _(unset = none)_ | OpenAI-style `reasoning.effort`, only when set           |

Any other value, or an empty one, sends no reasoning option, so the model keeps its own behaviour. Higher effort means slower and more expensive answers; the reasoning the model returns is shown, collapsed, above the answer — [AI chat — What an answer shows](https://docs.qiick.io/externa/ai-chat#what-an-answer-shows).

`OPENAI_CONTEXT_WINDOW` (400000), `ANTHROPIC_CONTEXT_WINDOW` (200000), `GEMINI_CONTEXT_WINDOW` (1048576) and `LOCAL_AI_CONTEXT_WINDOW` (131072) set the context size in tokens that the Assistant shows next to each answer's token usage. They are display only and do not cap the request; set the local one to your model's real context length.

```env
AI_ASSISTANT_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_REASONING_EFFORT=low
```

## Remote import hosts

```php
'remote_import_hosts' => array_values(array_filter(array_map(
    'trim',
    explode(',', (string) env('AI_REMOTE_IMPORT_HOSTS', '')),
))),
```

| Env                      | Default | Meaning                                                                                                              |
| ------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------- |
| `AI_REMOTE_IMPORT_HOSTS` | empty   | Comma-separated **host allowlist** for `ImportRemoteJson` / related remote fetches. Empty → no remote hosts allowed. |

```env
AI_REMOTE_IMPORT_HOSTS=api.example.com,data.partner.test
```

> Never leave remote import open to arbitrary hosts in production. Pair allowlists with short-lived Bearer tokens when the remote API requires auth.

## Webhook HMAC secret

| Config                              | Env                                    | Purpose                                                                  |
| ----------------------------------- | -------------------------------------- | ------------------------------------------------------------------------ |
| `webhook_token`                     | `AI_WEBHOOK_TOKEN`                     | HMAC-SHA256 key for `POST /ai/webhooks/collection-import` (not a Bearer) |
| `webhook_token_previous`            | `AI_WEBHOOK_TOKEN_PREVIOUS`            | Previous key during rotation grace (optional)                            |
| `webhook_token_previous_expires_at` | `AI_WEBHOOK_TOKEN_PREVIOUS_EXPIRES_AT` | ISO 8601 end of grace window                                             |

Clients must send `X-AI-Webhook-Signature: sha256=<hex>` where the digest is HMAC-SHA256 over the **raw request body only** (from **1.2.2**, [#214](https://github.com/qiick-io/externa-core/pull/214)). Empty `AI_WEBHOOK_TOKEN` → **401** in **every** environment (1.3.0+; no local unsigned bypass); mismatch → **401**. Bearer / `X-AI-Webhook-Token` alone is not enough. Rotate with `ai:rotate-webhook-token` — [AI API](https://docs.qiick.io/externa/ai-api#rotate-the-webhook-token).

CSRF stays excepted for this path (no session). Details + PHP snippet: [AI API](https://docs.qiick.io/externa/ai-api).

```env
AI_WEBHOOK_TOKEN=generate-a-long-random-secret
```

## Daily prompt limit

| Config               | Env                     | Default |
| -------------------- | ----------------------- | ------- |
| `daily_prompt_limit` | `AI_DAILY_PROMPT_LIMIT` | `0`     |

`0` means **unlimited**. When set to a positive integer, `AiChatController` enforces a per-user daily cap.

```env
AI_DAILY_PROMPT_LIMIT=0
```

## Embeddings

| Config               | Env                     | Default | Role                                                                                                                                                                                                                                                              |
| -------------------- | ----------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `embeddings.enabled` | `AI_EMBEDDINGS_ENABLED` | `false` | When `true`, `SearchSimilarCollectionItems` uses stored vectors (cosine over JSON float arrays; pgvector later). Item saves queue `GenerateCollectionItemEmbeddingJob`. Without stored vectors / on provider failure, text fallback (`semantic-lite`) still runs. |

```env
AI_EMBEDDINGS_ENABLED=false
```

Embedding **caching** block (separate from the feature flag):

```php
'caching' => [
    'embeddings' => [
        'cache' => false,
        'store' => env('CACHE_STORE', 'database'),
    ],
],
```

## MCP server

Optional first-party MCP endpoint at `/mcp` (externa-core **1.4.0**). Full guide: [MCP server](https://docs.qiick.io/externa/mcp-server).

| Config                      | Env                            | Default | Role                                                                        |
| --------------------------- | ------------------------------ | ------- | --------------------------------------------------------------------------- |
| `mcp.enabled`               | `AI_MCP_ENABLED`               | `false` | `/mcp` returns **404** while `false`                                        |
| `mcp.write_enabled`         | `AI_MCP_WRITE_ENABLED`         | `false` | Register MCP write tools (still need `mcp:write`, the write gate, approval) |
| `mcp.rate_limit_per_minute` | `AI_MCP_RATE_LIMIT_PER_MINUTE` | `60`    | `mcp` limiter; a key's own `rate_limit_per_minute` wins when set            |

```env
AI_MCP_ENABLED=false
AI_MCP_WRITE_ENABLED=false
AI_MCP_RATE_LIMIT_PER_MINUTE=60
```

## Tool registry and write gate

Shared by chat and MCP (externa-core **1.4.0**). Write and destructive tools are denied by default. Full guide: [AI tool approvals & audit](https://docs.qiick.io/externa/ai-tool-approvals).

| Config                       | Env                                                  | Default       | Role                                                                     |
| ---------------------------- | ---------------------------------------------------- | ------------- | ------------------------------------------------------------------------ |
| `tools.enabled`              | `AI_TOOLS_ENABLED`                                   | `true`        | `false` disables every AI tool                                           |
| `tools.write_enabled`        | `AI_WRITE_TOOLS` (fallback `AI_TOOLS_WRITE_ENABLED`) | `false`       | Kill switch for mutating tools                                           |
| `tools.write_allow`          | `AI_TOOLS_WRITE_ALLOW`                               | empty         | CSV of mutating tool class names (merged with Settings → AI write tools) |
| `tools.allow`                | `AI_TOOLS_ALLOW`                                     | empty         | When set, only these tool names are available                            |
| `tools.deny`                 | `AI_TOOLS_DENY`                                      | empty         | Tool names that are never available                                      |
| `tools.approval_ttl_minutes` | `AI_TOOLS_APPROVAL_TTL`                              | `15`          | Lifetime of MCP pending approvals                                        |
| `tools.audit_retention_days` | `AI_TOOLS_AUDIT_RETENTION_DAYS`                      | `90`          | `ai:prune-tool-audit` retention                                          |
| `tools.redact_keys`          | `AI_TOOLS_REDACT_KEYS`                               | built-in list | Argument keys redacted in the audit log and approval inbox               |

```env
AI_TOOLS_ENABLED=true
AI_WRITE_TOOLS=false
AI_TOOLS_WRITE_ALLOW=
AI_TOOLS_APPROVAL_TTL=15
AI_TOOLS_AUDIT_RETENTION_DAYS=90
AI_TOOLS_ALLOW=
AI_TOOLS_DENY=
```

## Conversations tables

```php
'conversations' => [
    'connection' => env('DB_CONNECTION'),
    'tables' => [
        'conversations' => 'agent_conversations',
        'messages' => 'agent_conversation_messages',
    ],
    'generate_title' => false, // titles from first prompt via Str::limit
],
```

| Setting          | Meaning                                                                                                                        |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Tables           | Laravel AI conversation persistence uses `agent_conversations` and `agent_conversation_messages` on the default DB connection. |
| `generate_title` | `false` — Externa skips an extra LM call for titles; the first prompt is truncated instead.                                    |

HTTP CRUD for conversations is under `/ai/conversations/*` (session auth + `can-use-ai`). See [AI API](https://docs.qiick.io/externa/ai-api).

## Attachments cleanup

Expired chat attachments are purged by `ai:cleanup-attachments` (scheduled daily). Configure retention via attachment `expires_at` in application code; the command deletes expired `AiChatAttachment` rows and their files.

## Minimal local AI `.env`

```env
AI_DEFAULT_PROVIDER=local
LOCAL_AI_URL=http://127.0.0.1:1234/v1
LOCAL_AI_API_KEY=
LOCAL_AI_MODEL=openai/gpt-oss-20b
# LOCAL_AI_TRANSLATE_MODEL=phi-4-mini-instruct
AI_REMOTE_IMPORT_HOSTS=
AI_WEBHOOK_TOKEN=
AI_DAILY_PROMPT_LIMIT=0
AI_EMBEDDINGS_ENABLED=false
AI_MCP_ENABLED=false
# AI_WRITE_TOOLS=false
```
