# AI Integration (Shared · `Src\Ai`)

**Context:** Shared library + a thin page in each portal · **Routes / UI:** `manage.integrations.ai.*` (admin) and `main.portal.ai-settings.*` (member) · **Used by:** any feature that needs to call an AI provider (analysis, auto-reply, summarisation, …) — designed to be reused (the AI counterpart to `MediaService`).

## What it does
Two things, both provider-agnostic across **Anthropic Claude, OpenAI GPT, Google Gemini, DeepSeek, Moonshot Kimi**:

1. **Stores & resolves API keys** for two kinds of owner:
   - **Global (admin-shared)** — one set of keys all admins share, for internal Manage-portal AI features.
   - **Lead (user portal)** — a single lead's own keys (in this product **a lead *is* the portal user**), so a member can bring their own key. Keys are **encrypted at rest** and **never returned to the browser** (only a masked `•••• 1234` suffix). `AiKeyService` is the read/resolution layer; **key resolution is caller-chosen**.
2. **Calls the providers** — `AiClient` is the single, general-purpose calling service: any feature passes messages and gets back a normalized reply, regardless of provider. This is the "faucet" the rest of the system uses for AI.

It is the AI sibling of `Src\Common\Media` / `MediaService`: a storage + service + repository trio with a reference page in each portal, plus the calling layer.

## Gateway mode (white-label clients)
A deployment with `AI_GATEWAY_MODE=client` holds no keys and picks no models: `AiClient::prepare()` relays every call to the PropertyLab Hub as the `propertylab` pseudo-provider, billed in credits, and the provider-key / model-pin / AI Requests surfaces are not registered. The Hub side (`AI_GATEWAY_HUB_ENABLED=true`) lives in its own module — see [`docs/modules_handbook/ai-gateway/readMe.md`](/docs/modules_handbook/ai-gateway/readMe.md). Nothing below changes for a standalone install.

## How it works
- **Four pieces, clean separation of concerns:**
  - `Src\Ai\AiCredential` — the Eloquent record only (schema, constants, the `lead()` relationship, masking helper). A **key model** (uuid + create/update blame) that is **hard-deleted** (no soft delete), with `api_key` cast `encrypted` and listed in `$hidden`.
  - `Src\Ai\Services\AiKeyService` — the **read / resolution** layer: `resolveForLead()` / `resolveGlobal()`, the default-provider lookups, and `present()` (the masked per-provider payload the pages render). No DB writes here.
  - `Src\Ai\Repositories\AiCredentialRepository` — the **DB-write** layer: `upsert` / `delete` (hard), each inside `DB::transaction`, per GUIDELINES.
  - `Src\Ai\Services\AiProviderValidator` — the **key-verification** probe used by Save: the smallest authenticated request per provider (a models listing) to confirm the key is accepted. Not a chat client; sends no prompt and incurs no token cost.
  - `Src\Ai\Services\AiClient` + `Src\Ai\Transports\*` — the **calling layer**: `AiClient` resolves provider/model/key and dispatches to a per-provider `AiTransport` (one POST to that provider's chat endpoint), returning a normalized `AiResponse`. See *Calling a provider* below.
- **Ownership without a morph.** Per the project decision, **user-portal links use a `lead_id` foreign key** (not a polymorphic owner). A `scope` constant distinguishes the two owner kinds: `SCOPE_GLOBAL` (lead_id null) vs `SCOPE_LEAD` (lead_id set). Uniqueness of `(scope, lead_id, provider)` is enforced in the **repository** via `updateOrCreate` — not a DB unique index, because MySQL treats the NULL `lead_id` of global rows as distinct and so could not enforce the single global row per provider.
- **Saving verifies first.** A page PUTs `{api_key}` to `…/{provider}`. The controller **verifies the key with the provider** (`AiProviderValidator`) before any DB write: an invalid key (or unreachable provider) is rejected with an inline `api_key` validation error and **nothing is stored** — for the user portal not even a lazily-created lead. Only a valid key is mapped into the nested `ai_credential` array and `upsert()`-ed. Because a row exists **only** if its key passed verification, there is no `verify_status` column to track — a stored key is verified by definition. The Manage portal also exposes a separate live **Test** action that writes an `ai_requests` row.
- **Models are NOT stored per key.** The list of available models per provider and the **default model** live only in the [config/ai.php](/config/ai.php) catalog — the single source of truth, editable without a migration. A feature gets the model via `AiKeyService::resolveModel($provider, $model)`: the model the caller passes, or the provider's catalog **`default_model`** when none is given. So "no model parameter → default model" is enforced in one place.
- **Removing is a hard delete.** There is no soft-delete tombstone and no `is_default` marker: a removed key is gone for good, and which provider a feature uses is chosen explicitly by the feature (not stored on the credential).
- **Reading.** Server-side, `$credential->api_key` decrypts transparently (the `encrypted` cast). `$hidden` only affects **serialization**, so the key is usable in PHP yet can never leak into an Inertia prop. The pages receive `present()` output — provider catalog (incl. `default_model`) + `configured` / `last_four`, but **no key material** beyond the last four characters.

### Reference usage — resolving a key in a feature
`AiKeyService` is a **shared service**: call it from any feature that needs a key. Resolution is caller-chosen — pick the lead's own key, the admin key, or fall back at your discretion:

```php
use Src\Ai\AiCredential;
use Src\Ai\Services\AiKeyService;

public function __construct(protected AiKeyService $keys) {}

// User-portal feature billed to the member's own key (the lead == the portal user):
$lead = $request->user()->lead;
$key  = $lead ? $this->keys->resolveForLead($lead, AiCredential::PROVIDER_ANTHROPIC) : null;

// Internal admin feature on the shared key:
$key = $this->keys->resolveGlobal(AiCredential::PROVIDER_GEMINI);

// "Use my own key, else fall back to the company key" — the feature's choice, made explicit:
$key = ($lead ? $this->keys->resolveForLead($lead, $provider) : null)
    ?? $this->keys->resolveGlobal($provider);

// A specific provider, with the model resolved from the catalog when none given:
$provider = AiCredential::PROVIDER_ANTHROPIC;
$apiKey   = $lead ? $this->keys->resolveForLead($lead, $provider) : $this->keys->resolveGlobal($provider);
$model    = $this->keys->resolveModel($provider, $someModel); // $someModel ?: catalog default
```

### Key-usage policy
How each feature chooses between a lead's own key and the global admin key:

- **AI Conversations** (user portal, `/property/ai-advisor` — "AI Chatbot" in the nav) → **free credits first, then the lead's own key.** Each lead gets a free **credit** allowance (1 credit = 1 request); while credits remain, requests run on the **global** key (the company absorbs the trial) and each spends a credit; once exhausted the lead must use their **own key** (`resolveForLead`); with neither credits nor a key the request is blocked. `AiCreditService::planConversation($lead, $provider)` encodes exactly this and returns `['source' => 'credit'|'own'|null, 'key' => …]`.
- **Every other AI feature** (admin tooling, internal automation, analysis jobs, …) → the **global admin-shared key** (`resolveGlobal`).

Rule of thumb: **only the user-facing AI Conversations page bills the lead** (after their free credits); **everything else runs on the company key**.

#### Free credits (per-lead)
Stored 1:1 in **`lead_ai_credits`** (`allowance` / `used` / `used_at`). A new lead is granted the default allowance automatically (`Lead::created` → `LeadAiCreditRepository::grant`); existing leads were backfilled by the migration. The default is **admin-editable** on the Manage **AI Prompts** page (general settings card) — persisted as the `ai.lead_free_credits` row of a small generic `settings` store (`Src\Setting\Setting`), falling back to `config('ai.free_credits_default')` (env `AI_LEAD_FREE_CREDITS`, default 10) — and applies to leads created from then on. Both portals show the balance.

> **Implemented:** the `lead_ai_credits` storage, grant-on-create + backfill, the admin-editable default, balance display (both portals), the `AiCreditService` read/`spend()`/`planConversation()` API, the general calling service (`AiClient`), **and the [AI Conversations](/docs/modules_handbook/main/ai-conversations/readMe.md) feature** (the user-portal chat) — its streaming endpoint composes `planConversation()` → `AiClient::stream()` → `spend()` and is what actually spends a credit.

## Calling a provider (`AiClient`)
`AiClient` is the general-purpose calling service — the one entry point any feature uses for AI (analysis, auto-reply, classification, summarisation, …). It resolves the provider/model/key, dispatches to the right transport, and returns a normalized `AiResponse`. It **fails soft** (`ok=false` + `error`, never throws) and **defaults to the global/company key**.

```php
use Src\Ai\Services\AiClient;

public function __construct(protected AiClient $ai) {}

// Simplest — global key, default provider (config('ai.default_provider')):
$res = $ai->prompt('Summarise this call transcript in 3 bullets: ' . $transcript);
if ($res->ok) { $summary = $res->text; }

// Full control: provider, model, system prompt, options:
$res = $ai->chat([
    ['role' => 'user', 'content' => 'Draft a friendly reply to: ' . $message],
], [
    'provider'    => AiCredential::PROVIDER_OPENAI,   // optional → default provider
    'model'       => 'gpt-4o-mini',                   // optional → catalog default
    'system'      => 'You are a helpful property assistant.',
    'temperature' => 0.4,
    'max_tokens'  => 500,
]);

// Structured analysis (JSON mode) — nudges each provider to return JSON:
$res = $ai->prompt('Extract {intent, budget} as JSON from: ' . $text, ['json' => true]);
$data = $res->json();   // decoded array, or null

// Run on a SPECIFIC key (e.g. AI Conversations, after AiCreditService picks it):
$plan = $credits->planConversation($lead, $provider);          // 'credit' | 'own' | null
if ($plan['key']) {
    $res = $ai->chat($messages, ['provider' => $provider, 'api_key' => $plan['key']]);
    if ($res->ok && $plan['source'] === 'credit') { $credits->spend($lead); }
}
```

**`AiResponse`** carries `ok`, `text`, `provider`, `model`, `usage` (normalized `input_tokens` / `output_tokens`), `error`, `raw`, and a `json()` helper. **Adding a provider** = add a `config/ai.php` entry + an `AiTransport`; `AiClient` and every caller are untouched.

**Streaming** (`AiClient::stream($messages, $options, $onDelta)`) shares the same resolution, fail-soft contract and two-phase `ai_requests` logging as `chat()`, but the reply is delivered token-by-token through the `$onDelta(string)` callback and the final accumulated `AiResponse` is returned. Each transport implements `streamChat()` (Anthropic / OpenAI+DeepSeek / Gemini SSE; `ai.stream_timeout`, no auto-retry on a partial stream). It is for interactive, user-waiting flows — the consumers are the [AI Conversations](/docs/modules_handbook/main/ai-conversations/readMe.md) chat and the [AI Debate](/docs/modules_handbook/main/ai-debate/readMe.md) panel (which streams many `stream()` calls in one request). Both flush each token via the shared `App\Http\Controllers\Concerns\StreamsServerSentEvents` trait.

### Multimodal attachments (images, voice, documents, video)
Any message can carry attachments; build them with [`AiAttachment`](/src/Ai/Support/AiAttachment.php) (raw-bytes factories `image()` / `audio()` / `document()` / `video()`, `imageUrl()`, `fromPath()`, and **`fromMedia()`** — straight off a GCS `Media` record, e.g. a WhatsApp voice note):

```php
use Src\Ai\Support\AiAttachment;

// "What does this voice message say?" — Media straight off GCS:
$res = $ai->chat([[
    'role' => 'user',
    'content' => 'Transcribe and summarise this voice message.',
    'attachments' => [AiAttachment::fromMedia($media)],
]], ['provider' => 'gemini', 'prompt' => AiRequest::PROMPT_VOICE_ANALYSIS, 'subject' => $media]);
// (PROMPT_VOICE_ANALYSIS is illustrative — register the key in config/ai_prompts.php first.)

// Analyse an image + a PDF together:
$res = $ai->chat([[
    'role' => 'user',
    'content' => 'Compare the photo against the floor plan.',
    'attachments' => [
        AiAttachment::image($photoBytes, 'image/jpeg'),
        AiAttachment::document($pdfBytes, 'application/pdf', 'floorplan.pdf'),
    ],
]], ['provider' => 'anthropic']);
```

Each transport translates attachments to its provider's wire shape (Anthropic content blocks · OpenAI content parts `image_url`/`input_audio`/`file` · Gemini `inline_data`). **Capabilities differ** (`config/ai.php` `providers.{slug}.attachments`):

| | image | audio (voice) | document | video |
|---|---|---|---|---|
| Anthropic | ✅ (bytes or URL) | — | ✅ PDF native; CSV/TXT/JSON auto-inlined as text | — |
| OpenAI | ✅ (bytes or URL) | ✅ (wav/mp3 — use `model: gpt-audio`; retires 2027-01-20 → `gpt-audio-1.5`) | ✅ PDF native; CSV/TXT/JSON auto-inlined as text | — |
| Gemini | ✅ (bytes) | ✅ (bytes) | ✅ (bytes, any supported mime) | ✅ (bytes) |
| DeepSeek | — (text-only API) | — | — | — |
| Kimi (Moonshot) | ✅ vision models only (`kimi-k3` / `kimi-k2.6`) | — | — | — |

(All combinations live-tested 2026-06-11 against real provider APIs — see the `multimodal_test` rows in the AI Requests log.)

An unsupported type **fails soft with a clear error** (never silently dropped) — e.g. voice → use Gemini or OpenAI; analysis of plain conversations/transcripts is just text and works everywhere. **Logging stays safe**: attachment bytes are replaced by `[base64 omitted — N KB]` placeholders in both the normalized messages and the captured wire body, so an image can never bloat `ai_requests`.

### Provider: Anthropic Claude — model generations & request shaping
The Claude catalog spans three generations, and the newest ones changed the request contract. `AnthropicTransport` absorbs both differences so callers never have to care which model they picked — but **know these before adding a model to `config/ai.php`**:

- **Models (2026-07-28 catalog).** `claude-fable-5` (most capable, $10/$50), `claude-opus-5` ($5/$25), `claude-sonnet-5` (**the default** — $3/$15, introductory $2/$10 until 2026-08-31), plus the still-active `claude-opus-4-8` / `4-7` / `4-6`, `claude-sonnet-4-6` and `claude-haiku-4-5`. Nothing here is deprecated: the earliest tentative retirement is `claude-haiku-4-5` (not sooner than 2026-10-15), everything else runs into 2027, and Anthropic gives 60 days notice.
- **`temperature` is rejected (400) from Opus 4.7 on** — and on Fable/Mythos 5 and every Opus/Sonnet 5+. `AnthropicTransport::rejectsSampling()` drops it for those models instead of sending it, so a caller passing `'temperature' => 0.4` still works everywhere; on those models it is simply ignored (steer by prompt instead). Opus 4.6, Sonnet 4.6 and Haiku 4.5 still receive it.
- **Thinking is ON by default from the 5 generation** — omitting the `thinking` field on Opus 5 / Sonnet 5 means adaptive thinking runs, and **thinking tokens share the `max_tokens` budget with the visible reply**. At our `default_max_tokens` (2048) that would truncate answers and quietly raise cost, so `AnthropicTransport::thinkingFor()` sends `{"type":"disabled"}` for them — keeping behaviour identical to the 4.x models. To turn thinking on, return `['type' => 'adaptive']` there **and** raise `max_tokens`.
- **Fable 5 is the exception:** it always thinks and **rejects any explicit thinking config with a 400**, so the field is omitted for it. Give it a larger `max_tokens` if you select it, since its thinking always draws from the same budget.
- Known caveat: with thinking disabled, **Opus 5** can occasionally leak `<thinking>` tags into the visible reply. If that shows up, the fix is to turn thinking on (and raise `max_tokens`) rather than to add "don't think" wording to the prompt, which makes it worse.

Both rules are pinned by `tests/Feature/Ai/AnthropicModelGuardsTest.php` — it asserts the actual outgoing request body per model, plus that every selectable chat model is priced.

### Provider: Kimi (Moonshot AI)
**Kimi** is Moonshot AI's model family, added as a first-class chat provider (`AiCredential::PROVIDER_KIMI = 'kimi'`). Its API is **OpenAI-compatible** (`POST https://api.moonshot.ai/v1/chat/completions`, Bearer auth), so it needed **no new transport**: `AiClient` dispatches `kimi` to the shared `OpenAiTransport` and `AiProviderValidator` verifies the key with the Bearer `GET /v1/models` probe — exactly like DeepSeek. Adding it was purely a catalog change: the `PROVIDER_KIMI` constant + `PROVIDERS` entry, a `config/ai.php` provider block (base URL, `models`, `default_model`, `pricing`) and the two `{provider}` route regexes. The provider then appears automatically on the AI Providers modal (admin), the member BYO-key modal, and the Compare picker.

- **Models (2026-07-24 catalog).** `kimi-k3` (flagship — 1M context, native vision), `kimi-k2.6` (256k, vision, **the default** — best value at ~1/3 the K3 price), `kimi-k2.7-code` and `kimi-k2.7-code-highspeed` (256k, coding). `kimi-k2.5` + the `moonshot-v1-*` series sunset for new users on **2026-08-31**; the older `kimi-k2-*-preview` / `kimi-latest` are already retired — none are in the catalog. Edit `config/ai.php` to change the list, the default, or the base URL (`MOONSHOT_BASE_URL`).
- **Pricing** (`config/ai.php` `pricing.kimi`, USD per 1M tokens, cache-miss input): `kimi-k3` $3 / $15, `kimi-k2.6` $0.95 / $4, `kimi-k2.7-code` $0.95 / $4, `kimi-k2.7-code-highspeed` $1.9 / $8. Cache-hit input is far cheaper (K3 $0.30) but the estimate uses the cache-miss rate as the conservative upper bound (same convention as DeepSeek).
- **Quirks.** Kimi's temperature range is **[0, 1]** (OpenAI's is [0, 2]) and some models pin it (k2.6 non-thinking expects `0.6`); `AiClient` never injects a default temperature, so the normal path is safe — only an explicit out-of-range temperature on a Kimi call would be rejected by the provider. **Vision (image)** works only on the multimodal models (`kimi-k3` / `kimi-k2.6`); an image sent to a text model (`kimi-k2.7-code`) fails soft with a clear error (same pattern as OpenAI audio needing `gpt-audio`).

### Request log (`ai_requests`)
Every provider call through `AiClient` is **logged automatically** to the `ai_requests` table — the prompt we submitted, the reply, the model, token usage, **estimated cost**, **duration**, and attribution. (**Transcription** is logged too — not via `AiClient` but via the `LoggingTranscriber` decorator, under `prompt_key = transcription`; see *Speech-to-text* above. **Slot poster generation** is a third non-`AiClient` producer — see below.) Logging is **two-phase**: a **`processing`** row is opened the instant the call is submitted (`AiClient::begin()` → `AiRequestRepository::start()`) so a slow / in-flight request is visible in the log immediately, then **settled** to `success` / `failed` when the provider responds (`AiClient::finish()` → `AiRequestRepository::complete()`; the wire request `http` is captured during the call and added on settle). The **API key is never stored**. Logging is best-effort (a logging failure never breaks the call) and applies only to real attempts (a missing-key / unknown-provider error submits nothing, so nothing is opened or logged).

**Slot poster generation (image, not chat) — `prompt_key = slot_poster`.** [Events · Slot Posters](/docs/modules_handbook/manage/events/slot-posters/readMe.md) generates funnel-slot poster art via a **new** `App\Helpers\OpenAiImageClient` — OpenAI now has an **image-generation** path (`/v1/images/generations` + `/v1/images/edits`, pinned to `gpt-image-2`) that is entirely distinct from its chat wiring in this catalog (`providers.openai.models` stays chat-only; the image model lives in `config/services.php → openai_image`). Like transcription, it bypasses `AiClient` (a non-chat call shape) but still logs under the **existing** `AiCredential::PROVIDER_OPENAI` slug via `Src\Event\Services\SlotPosterGenerator` — two-phase (`start()`/`complete()`, borrowed from `AiClient::begin()`/`finish()`) rather than `LoggingTranscriber`'s one-shot `log()`, since a poster render is slow enough that an in-flight row is worth seeing immediately. Its one new hazard vs. every other logged path: OpenAI can return the generated image **inline as base64** in the response body, so `response_raw` needs the same byte-scrubbing the request side already gets everywhere else — a raw response would otherwise embed a whole poster image in the log.

Attribute or control a log via options:

```php
$ai->prompt($transcript, [
    'prompt'  => AiRequest::PROMPT_PROVIDER_TEST, // registered prompt key — sets the system prompt AND labels the request
    'subject' => $callRecording,      // any Eloquent model → polymorphic subject_type/subject_id
    'lead_id' => $lead?->id,          // attribution
    'meta'    => ['prompt_version' => 'v2'], // free-form caller context → ai_requests.meta (json)
    'log'     => false,               // opt out of logging for this call
]);
```

**The prompt registry** (GUIDELINES §3 — no raw strings). Every AI request runs under a **prompt key**: a `PROMPT_*` constant on `AiRequest` with a [config/ai_prompts.php](/config/ai_prompts.php) entry (`name`, `description`, optional inline `system`). The key does double duty — it **resolves the system prompt** the request runs with, and it is **stored on the row** (`prompt_key`) to say what the request was for. **Long prompt bodies live as files**: omit `system` in the registry and put the text in `resources/prompts/{key}.md` — picked up automatically (`AiRequest::promptSystem()`; resolution: caller's explicit `system` option > **admin override** (`ai_prompts` row) > inline registry `system` > `{key}.md` > none/label-only). Rules: omitted key → `PROMPT_DEFAULT` (the generic-assistant prompt, body in [resources/prompts/default.md](/resources/prompts/default.md)); **unknown key → fail-soft error** (a typo can never silently become the default).

**Editable prompts & per-feature models — the AI Prompts page.** `/manage/integrations/ai/prompts` (the **Prompts** header button on AI Requests) is the per-feature AI console: every editable prompt (registry keys with a default body — one per `resources/prompts/*.md` file, including the AI Debate personas `debate_house` / `debate_analyst`, registered with `'persona' => true`) beside a monospace editor, plus the **general AI settings** (the new-lead free-credit default) and — for a Super Admin only — the **Release controls** card in cards up top. Writes are gated by `manage-integrations` (viewing needs `view-integrations`), **except the Release controls, which are `super-admin` only** (see the bullet below); catalog/history presentation lives in `AiPromptService`, writes in `AiPromptRepository` (transactional, per GUIDELINES).

- **Text is versioned (full audit).** Saving appends an immutable **`ai_prompt_versions`** row — version number (per-key, monotonic), action (**Edited / Reset to default / Restored**), the full body, `created_by` and timestamp — and sets the text on the key's **`ai_prompts`** row. The **History** modal lists every version (who, when, action, expandable full text) and can **Restore** any text-carrying version — restore *appends* a new RESTORED version (like a git revert, history is never rewritten). **Reset to default** clears the override and logs a RESET marker; the audit trail survives resets. Rows are never updated or deleted. The `.md` files in the repo stay the **code defaults** (`AiRequest::promptDefault()`), so a fresh install needs no seeding and reset always has a target. Text saved identical to the default is auto-treated as a reset (no phantom "Customised"). Label-only keys (`provider_test`, `transcription`, …) and per-call-composed keys (`debate_answer` / `debate_verdict`) have no default body and are **not editable** (the controller 404s them).
- **Model pins (per feature).** The same `ai_prompts` row can pin the **provider + model** the key's requests run on. `AiClient::prepare()` applies the pin when the caller passes **neither** `provider` nor `model` explicitly (explicit options always win; a pinned model is never mixed with a different caller-chosen provider); unpinned → `config('ai.default_provider')` + catalog default. Pins are catalog-validated on save **and** on read (`AiPrompt::pinFor()`), so a removed model degrades to the provider default instead of erroring. Pin changes are blamed (`updated_by`) but not versioned — versions are for text. Keys whose model is managed elsewhere declare `'model_note'` in the registry (WhatsApp Assistant → per AI profile; Lead Intel ×2 → `config/enrichment.php`; Conversation Translation → follows the analysis pin) and show that note instead of a picker; personas get no model section at all. **Migrated here:** the former `Setting::AI_CONVERSATION_*` / `AI_ANALYSIS_*` rows became pins on `ai_conversation` / `conversation_analysis` (the migration also pinned `amenity_demand` to its previously hard-coded gemini/gemini-3.5-flash); `ConversationAnalyzer` and the AI Conversations controller now read `AiPrompt::pinFor()`.
- **Release controls (Super Admin) — the conversation release gate.** A third card switches the four conversation features on or off for the whole install: **Action Plans & Sales Checklist**, **Ask AI on a recording**, **Pipeline Product & Commission**, **Lead AI Sales Coach** — Zoom, Phone Call and Showroom F2F alike. They are `settings` **rows** (`Setting::CONVERSATION_ACTION_PLANS` / `CONVERSATION_RECORDING_CHAT` / `CONVERSATION_OPPORTUNITIES` / `CONVERSATION_SALES_COACH`, listed together as `Setting::CONVERSATION_FEATURES`) — **not** config and **not** `.env`. Release migration `2026_08_15_100000_enable_conversation_workspace_release_controls` upserts all four to `1`, so merge + normal migration exposes the workspace immediately; a Super Admin can still withdraw or restore any control **with no deploy**. A control that is off **removes its entries from the interface AND answers 404 on its endpoints**; that gate lives in the controllers and Form Requests, never at route registration, so a cached route table cannot bake it in.
    - **Reading** goes through `ConversationFeatureFlags`, the only runtime reader: one batched `whereIn` for all four per container scope, memoized on a **scoped** binding (`AppServiceProvider`) so a `queue:work` process that has been up for days still sees a change on its very next job. A row that is absent, blank, or holds anything other than `'1'` reads **OFF** — fail-closed, never coerced. The Inertia share carries the props **deferred behind closures** (both the `conversation_*` names and the legacy Zoom-named aliases), so a request that renders no page — a guest visit, a redirect-only POST, a download — costs no settings query at all.
    - **Writing** is `PUT prompts/conversation-features` (`manage.integrations.ai.prompts.conversation-features` → `AiPromptsController@updateConversationFeatures` + `ConversationFeatureSettingsRequest`), gated by the **`super-admin`** route middleware — deliberately **not** `manage-integrations` like its siblings, because the seeded `admin` role holds that permission and reusing it would let any ordinary admin switch a company-wide release on or off. The Form Request repeats the abort in `prepareForValidation`, so the refusal lands before validation and before any hint of the field contract. It accepts four **fixed** boolean fields and nothing else (a caller says whether a FEATURE is on, never which settings row to write), saved in **one transaction** (`SettingRepository::putMany`) because they are one decision made on one card. The `releaseControls` prop is **omitted entirely** for anyone else rather than hidden in Vue — a hidden card still ships the list of unreleased features in the page payload.
- Edits apply to the **next** AI request — no deploy, no cache to clear (resolution reads the DB per call; long-lived Horizon workers pick changes up too).

**Adding a new AI feature (the checklist).** 1) Add the `PROMPT_*` constant on `AiRequest`; 2) add the [config/ai_prompts.php](/config/ai_prompts.php) entry (`name` + `description`); 3) put the default body in `resources/prompts/{key}.md`; 4) call `AiClient` with `'prompt' => AiRequest::PROMPT_X` — and pass **no** `provider`/`model` unless the feature genuinely must control them (if it does, add a `model_note` to the registry entry so the page explains where the model is managed). Everything else is automatic: the body is editable + versioned and the model pinnable on the AI Prompts page, the request is logged/filterable under the friendly name on AI Requests, and resolution falls back to your `.md` default until an admin customises it.

Each row stores: `provider` / `model` / `status` (processing|success|failed), `request` (json: the normalized `messages` + safe options **plus `http`** — the **actual wire request**: method, endpoint URL, headers with auth values stored as `[redacted]`, and the provider-format body; **the api_key never touches the log**), `response_text` (extracted reply), `response_raw` (json: the **full raw provider response body**, success or error), `error`, `input_tokens` / `output_tokens`, `cost` (USD), `duration_ms`, the `subject` morph + `lead_id` + `prompt_key`, and `created_by` (the triggering user, if any). **Cost** = `tokens × config('ai.pricing')` (USD per 1M tokens per model — **estimates**, admin/dev-editable; an unpriced model logs `null`). The log is **two-phase, not append-only**: a `processing` row is inserted on submit and **updated once** to `success`/`failed` on response (its only mutation) — there is **no delete** in the repository (pruning is a separate concern). The detail modal renders the full wire request (endpoint + redacted headers + pretty JSON body) and the full raw response; rows logged before wire capture existed fall back to the normalized messages.

**Browsing the log — the AI Requests page.** `/manage/integrations/ai` (nav "AI Requests") is a standard §14 admin index over `ai_requests`: `AiRequestsController@index` + `AiRequestQueryRequest` (search across prompt-key/model/response/error; provider/status/prompt multi-select — `applyInString` for the string columns; created-date range) with per-option counts, whitelisted sorting (when/provider/model/prompt/status/cost/duration) and the shared DataTable/FilterDrawer/chips foundation. The list **auto-refreshes every 30s** (partial `router.reload` of the list props only, paused while the tab is hidden) and has a manual **Refresh** button. Two modals: **AI Providers** (header button — the global key `ProviderCard`s only, keys + live Test; actions hit `AiProvidersController`; prompts/models/credits live on the AI Prompts page) and a **row detail modal** that lazily fetches `GET …/requests/{uuid}` (JSON via `AiRequestsController@show`) so the heavy prompt/response bodies never bloat the index payload — it shows the full submitted messages, the response (or error), metrics, the lead (linked) and the requesting admin.

**Compare a request on other models (row action).** Each row also has a **Compare** action (`AiCompareModal`) that **replays that request's exact prompt** on other providers + models so the admin can compare replies side by side. The admin ticks target provider+model combinations (or **Select all** — every model of every configured chat provider) and confirms (a paid live call warning); the modal then fires **one `POST …/requests/{uuid}/compare` per target** (`AiRequestsController@compare` + `CompareRequest`, gated by `manage-integrations` since it spends money — like the provider Test), with a small client-side concurrency cap, filling each result card as it returns. The replay is **byte-identical**: the recorded messages are re-sent verbatim and the original system prompt is passed **explicitly** (even empty) so a prompt pin / registry default can never alter what is sent; it runs on the global key through `AiClient`, so each comparison call is itself **logged** to `ai_requests` tagged `meta.source = compare` (+ `compared_from` = the original uuid). Requests that **cannot** be replayed are blocked with a clear reason: those with **attachments** (image/voice/document bytes are never stored, only size placeholders) and **message-less** rows (transcription / provider-probe rows have no prompt to replay).

- **Override the system prompt (optional).** The **user input is always kept identical** to the original. The admin can additionally tick **"Use a different system prompt"** and edit the system prompt (`override_system` + `custom_system`, the editor **prefilled with the original** system text — which itself comes from the prompt registry / `resources/prompts/{key}.md` / admin override / pin) — so the **same user input** can be compared under **different system instructions** (a blank `custom_system` = no system prompt). Because the user input is still replayed, the attachment / message-less block **still applies** even when overriding the system. Overridden calls are logged with the extra `meta.custom_system` flag.

**Trigger a test (admin only).** On a configured card in the Providers modal, the **Test** button (`POST …/{provider}/test` → `AiProvidersController@test`) runs a tiny LIVE provider check and writes a real `ai_requests` row (success **or** failure) the admin can immediately open in the list. Chat providers send "Reply with exactly: OK" through `AiClient`; non-chat providers use their own credential probe instead (`Seedance`: `GET /models` with Bearer auth; `Deepgram`: `GET /v1/projects` with `Token` auth), so they never go through the chat transport. Non-chat test rows still populate the Metrics block: `model` is the configured service model, and token/cost metrics are recorded as `0 / 0` and `$0` because the probe is not a generation/transcription call.

## Video generation — Seedance 2.0 (`SeedanceClient`)
AI **video** generation is a **separate calling path** from `AiClient`: its own transport and its own queue lane. Normal renders are not part of the prompt registry or the `ai_requests` log (those are for provider tests and text/chat providers). It lives in the **AI Video** shared module (`Src\Video`); this entry exists so the provider is discoverable from the AI handbook — full detail is in the [AI Video](/docs/modules_handbook/shared/video/readMe.md) handbook.

- **Provider — `App\Helpers\SeedanceClient`** (mirrors `GeminiClient`: transport only — `buildPayload()` / `createTask()` / `getTask()` / `downloadVideoBytes()`; no DB, no poll loop). The engine is **BytePlus ModelArk Seedance 2.0** (the model behind Dreamina). Async: create a task → poll until terminal → download the expiring `video_url` into Media immediately.
- **Multimodal references (reference-to-video).** `buildPayload(array $references, string $prompt, array $opts)` takes an **ordered** list of `['kind' => 'image'|'video'|'audio', 'url' => …]` and emits OpenAI-style `content` parts — `image_url` / `video_url` / `audio_url`, each tagged with role `reference_image` / `reference_video` / `reference_audio`. **Images** are passed as base64 data URLs and referenced in the prompt as `@Image1…@ImageN`; **video / audio** are passed as public/signed **URLs** (too large for base64) and attach by role only; **text** material is folded into the prompt. An empty reference list ⇒ text-to-video. Caps: ≤9 images, ≤3 videos, ≤3 audio clips.
- **Where assembly lives:** `Src\Video\Services\VideoGenerationService::buildPayload()` reads the generation's input Media (collection `video_input`), turns images into data URLs and video/audio into `MediaService` signed URLs, and folds text files into the prompt. The async worker is `App\Jobs\Video\GenerateVideoJob` on its **own `redis-video` lane** (separate from `redis-ai`).
- **Key resolution.** `SeedanceClient` and `SeedreamClient` read the global Seedance key from AI Providers (`ai_credentials`) only. `.env` is no longer a runtime fallback for the key; the operator must save the ARK key in the Providers modal.
- **Config:** `config/services.php` → `seedance` (`base_url` `https://ark.ap-southeast.bytepluses.com/api/v3`, `model` `dreamina-seedance-2-0-260128`, `resolution`). Gated by `FEATURE_VIDEO_ENABLED`.

## Speech-to-text — Gemini (primary) + Deepgram (fallback) (`Src\Transcription`)
Audio **transcription** is a **non-chat path**: keys are managed in the AI Providers UI (encrypted `ai_credentials`), but it is **not** called through `AiClient`. All four consumers (Phone Call, Showroom F2F, Zoom, AI Video) transcribe through the shared, provider-agnostic **[`Src\Transcription\TranscriptionService`](/docs/modules_handbook/shared/transcription/readMe.md)**, which runs an ordered driver chain — **Gemini primary, Deepgram fallback** (`config('ai.transcription.drivers')`). Gemini transcribes mixed Mandarin+English in a single diarized pass (`generateContent`, inline or File API by size); Deepgram is the automatic backup (dual zh+en pass merged).

- **Logged on the AI Requests page (`prompt_key = transcription`).** Even though transcription bypasses `AiClient`, it still appears in the `ai_requests` log: a `LoggingTranscriber` decorator (in `app/Support/Transcription`, the one place `Src\Transcription` + `Src\Ai` are bridged) wraps **each** driver and writes one row per attempt via `AiRequestRepository::log()` — provider, model, status, Gemini token usage + estimated cost (Deepgram has no tokens → null), duration, and the recording as the `subject` (jobs pass `'subject' => $recording`). A Gemini→Deepgram fallback therefore logs **two** rows (Gemini failed, Deepgram success). The **audio bytes are never stored** (only their size); logging is **best-effort** (a failure never breaks transcription). Tokens are Gemini's own `usageMetadata` (input `promptTokenCount`; output `candidatesTokenCount` + `thoughtsTokenCount`); the **cost is audio-aware** — Gemini bills audio input higher than text on some models (`pricing.gemini.{model}.audio_input`), and transcription input is audio.

- **Key resolution.** Each transcription driver gets a lazy resolver `Closure` at the container binding (so `Src\Transcription` carries no `Src\Ai` dependency): Gemini resolves `resolveGlobal('gemini')` then `GEMINI_API_KEY` (same as the analysis path); Deepgram resolves `resolveGlobal('deepgram')` only (no `.env` fallback). The Gemini transcription model is `config('ai.transcription.gemini.model')` — distinct from the analysis model.
- **Save verification.** Deepgram authenticates with an `Authorization: Token <key>` header (not Bearer), so `AiProviderValidator` probes `GET {base}/v1/projects` with that header (valid key → 200, invalid → 401).
- **Config:** the non-secret catalog (label, `base_url` `https://api.deepgram.com`, `verify_path` `/v1/projects`, **`default_model` `nova-3`**) lives in `config/ai.php` `providers.deepgram` — the model now sits in the catalog alongside every other provider's, read as `config('ai.providers.deepgram.default_model')` (there is no longer a `config/services.php` → `deepgram` block or `DEEPGRAM_MODEL` env).

## Text-to-speech — Gemini (`App\Helpers\GeminiTtsClient`)

The other non-chat Gemini path: `synthesize($text, $voiceId)` returns WAV bytes (the API returns base64 PCM; the client prepends a 44-byte header so ffmpeg reads it directly). Voices come from `config('video.voices')`. Consumers are the AI Video pipeline and **`area-guide:narrate`**, which renders the Area Guide's story one audio per chapter. Every call is logged to `ai_requests` under `PROMPT_VOICE_SYNTHESIS` — the text's LENGTH, never the audio. On a Gateway CLIENT the synthesis is relayed to the Hub and billed per 1k characters.

- **Key resolution — the same rule as everything else here: UI key first, `GEMINI_API_KEY` fallback.**
  ⚠️ **It did not follow that rule until 2026-09-19, and the failure was silent.** This client read `config('services.gemini.api_key')` and nothing else, while every other Gemini consumer (`GeminiClient`, the transcription drivers, `ZoomCoachService`, `ZoomRtmsLauncher`) resolved `resolveGlobal('gemini')` first. So an administrator who pasted a key on **Manage → AI Providers**, pressed Test, and saw it **Verified / Connected** had every Gemini feature working **except the voice** — `area-guide:narrate` sent an EMPTY key, Google returned an HTTP error, and nothing anywhere named the missing credential. It survived because the developer machines that would have caught it had a key in **both** places. `GeminiTtsClient::apiKey()` now mirrors `GeminiClient::apiKey()` exactly, and `GeminiTtsClientTest` pins both directions.
  **The lesson generalises:** a new non-chat provider call that reads `config('services.*.api_key')` directly is a bug, not a shortcut. Resolve through `AiKeyService` and keep the env as the fallback, or the AI Providers page is telling administrators something that is not true for your feature.

## Conversation analysis — the unified analyzer (`Src\Conversation`)
After transcription, Phone Call / Showroom F2F / Zoom all run the **one** shared sales-conversation analyzer (`ConversationAnalyzer`) — an **`AiClient` consumer** under the registered prompt keys `conversation_analysis` (+ `conversation_translate` for the on-demand EN→中文), so every analysis + translation is logged to `ai_requests` with the recording as `subject`. The provider + model are admin-configurable on the **AI Prompts page** (the pin on the `conversation_analysis` key → `AiPrompt::pinFor()`, config fallback Gemini / `gemini-3.5-flash`; translation follows the same pin). ⚠️ The config value is only the **fallback** — if the key carries a model pin, the pin wins and editing `config/ai.php` changes nothing. Full detail: **[Conversation Analysis](/docs/modules_handbook/shared/conversation-analysis/readMe.md)**.

## Resilience & operations
The service is layered so a busy server or a slow/down provider makes AI **slower, never broken**. The principle: every provider is treated as slow and unreliable by default, and AI work stays off the synchronous web path.

**Layer 1 — hardened HTTP (every call, sync or queued).** `AbstractTransport::http()` gives all providers: a fail-fast **connect timeout** (`ai.connect_timeout`, 5s), the request timeout (`ai.request_timeout`, 60s), and **transient-only retries** (`ai.retry`: 3 total attempts) — retrying **only** connection errors / 429 / 5xx with exponential backoff + jitter, honouring the provider's `Retry-After`, capped at `max_delay_ms`. Auth/validation errors (401/400/…) fail immediately — retrying them only burns time and money. After exhaustion the last response is returned (not thrown), so the normal fail-soft path applies. Failures carry **`AiResponse->retryable`** (true for transient) which drives the job layer.

**Layer 2 — the queue lane (background AI; extend `App\Jobs\Ai\AiJob`).** AI must not hold web workers: a 10–30s call per request under load stalls the whole app, so background AI runs as queued jobs on a **dedicated lane** — the `redis-ai` connection (`retry_after` 390s > job timeout, so a long job is never re-delivered mid-run and double-billed) + `ai` queue + Horizon **supervisor-ai** (own capped processes; an AI burst queues up instead of swamping the server, and a dead provider can never starve `default` jobs). Extending `AiJob` (implement **`run()`**, not `handle`; pass results through **`aiOrFail()`**) gives automatically:
- **Per-provider rate limiting** — the `ai` named limiter (AppServiceProvider; `ai.rate_limits` rpm) smooths spikes into a steady drip under provider limits.
- **Circuit breaker** — `ThrottlesExceptionsWithRedis` keyed per provider (`ai.breaker`): after 10 transient failures in 10 min, that provider's jobs are released untried for a 5-min cooldown instead of hammering a dead API.
- **Smart retries** — `aiOrFail()` throws `AiTransientFailure` (retryable → backoff 30s/2m/5m until `ai.retry_until_minutes`) or `AiPermanentFailure` (bad key / bad request → **fails immediately, no retry**). Time-based `retryUntil` is used instead of `$tries` because limiter/breaker releases also consume attempts.
- **Idempotency hooks** — opt-in `overlapKey()` (`WithoutOverlapping`) so the same logical work never runs twice concurrently; jobs should also guard re-runs ("skip if result already exists") since a retried job re-executes `run()` from the top.

**When to queue vs call inline:** background work (analysis, auto-reply, batch) → an `AiJob`; interactive user-waiting flows (the future AI Conversations) → inline `AiClient` (short, rare) or dispatch + poll/stream. Workers: Horizon runs both supervisors (`php artisan horizon`); WSL/Linux required for the daemon.

**Monitoring:** the `ai_requests` log (latency `duration_ms`, failures `status`, spend `cost`) + the Horizon dashboard (`/horizon`, `ai` queue wait alerts at 300s).

## Data model (`ai_credentials` table)
| Column | Type | Notes |
|--------|------|-------|
| `id` | `bigIncrements` | primary key |
| `uuid` | `uuid` unique | public identifier (`HasUuid`) |
| `scope` | `unsignedInteger` indexed | `1` global (admin-shared), `2` lead (user portal) |
| `lead_id` | `unsignedBigInteger` nullable, indexed | owning lead for LEAD scope (`leads.id`); null for GLOBAL. No schema-level FK (GUIDELINES §7) |
| `provider` | `string(30)` indexed | `anthropic` / `openai` / `gemini` / `deepseek` / `kimi` — plus the non-chat `seedance` (video) and `deepgram` (speech-to-text) keys (model constants) |
| `api_key` | `text` nullable | **encrypted** at rest (model cast) + `$hidden` |
| `last_four` | `string(8)` nullable | non-secret key suffix for masked display |
| `meta` | `json` nullable | free-form extra metadata (cast to array) |
| `created_by` / `updated_by` | `unsignedInteger` nullable | blame (`RecordsBlame`); no `deleted_by` — hard delete |
| `timestamps` | | created/updated (no soft delete — rows are hard-deleted) |

## Configuration
`config/ai.php` (non-secret — **keys are never in env/config**, only in the encrypted table):

**Provider catalog**
| Key | Meaning |
|-----|---------|
| `providers.{slug}.label` / `.color` / `.logo` | UI label, badge colour, card logo |
| `providers.{slug}.models` / `.default_model` | available models + the default used when a caller passes none |
| `providers.{slug}.base_url` / `.verify_path` / `.chat_path` | endpoints for the key-verification probe and chat calls (env-overridable base URL) |
| `providers.{slug}.docs_url` | "Get a key ↗" link |
| `pricing.{slug}.{model}` | USD per 1M `input` / `output` tokens — drives the `ai_requests.cost` estimate (unpriced model → null). Optional **`audio_input`** = the higher rate Gemini bills audio input tokens at (used by transcription) |

**Model lifecycle — review this catalog quarterly.** Providers retire models on their own schedules, and a retired id fails every call. Verified 2026-07-28:

| Deadline | What goes | Action |
|---|---|---|
| **2026-08-31** | Kimi `kimi-k2.5` + `moonshot-v1-*` sunset for new users | already out of the catalog ✔ · Claude Sonnet 5's introductory $2/$10 also ends (list price $3/$15 is what we record) |
| **2026-10-15** | `claude-haiku-4-5` earliest tentative retirement | watch only — Anthropic gives 60 days notice |
| **2026-10-16** | `gemini-2.5-pro` / `-flash` / `-flash-lite` **shut down** | 🔴 drop them and move to the 3.5 / 3.6 equivalents. `conversation_analysis` already moved to `gemini-3.5-flash`; **`config/services.php` → `gemini.flash_model` is still on `gemini-2.5-flash`** and must move too |
| **2026-10-23** | OpenAI `gpt-4o-2024-05-13` (the DATED snapshot), `gpt-4.1-nano`, `gpt-4-0613`, `gpt-4-turbo`, `o4-mini` | none are referenced ✔ — the bare `gpt-4o` / `gpt-4o-mini` / `gpt-4.1` / `gpt-4.1-mini` aliases we DO use are not on OpenAI's deprecation list |
| **2027-01-20** | OpenAI `gpt-audio` | migrate to `gpt-audio-1.5` (it is our voice-attachment model) |

Cost estimates are deliberately **conservative upper bounds** (cache-miss rates for DeepSeek/Kimi, list price for Sonnet 5) with two known **under**-estimates: long-context tiers are not modelled (Gemini 3.1 Pro >200k, OpenAI GPT-5.6 long-context), and `gpt-audio` bills audio tokens at 32/64 while the estimate reads the 2.5/10 text rate. `pricing.deepseek.deepseek-v4-pro` is a 75%-off rate that some trackers still call promotional — re-check it before trusting DeepSeek spend.

**Calling & resilience**
| Key | Meaning |
|-----|---------|
| `default_provider` | provider used when a caller passes none (`AI_DEFAULT_PROVIDER`) |
| `ai_prompts.{key}` *(own file)* | the **prompt registry** ([config/ai_prompts.php](/config/ai_prompts.php)) — `name` / `description` / optional inline `system` (+ optional `persona` / `model_note` flags) per `AiRequest::PROMPT_*` key; long bodies in `resources/prompts/{key}.md`. Inline/file text is only the **code default** — admin edits live on the key's `ai_prompts` row (versioned in `ai_prompt_versions`, AI Prompts page) and win over it |
| `request_timeout` / `connect_timeout` | chat request timeout 60s / fail-fast connect timeout 5s |
| `verify_timeout` | seconds the key-verification probe waits (default 12) |
| `default_max_tokens` | completion cap when a caller passes none (required by Anthropic) |
| `retry.attempts` / `.base_delay_ms` / `.max_delay_ms` | transport-level transient retry: total attempts (3) + backoff bounds |
| `rate_limits.{slug}` / `.default` | outbound rpm caps for queued AI jobs (the `ai` limiter) |
| `breaker.max_exceptions` / `.decay_seconds` / `.cooldown_minutes` | the per-provider circuit breaker |
| `queue_connection` / `queue` / `retry_until_minutes` | the AI job lane (`redis-ai` / `ai`) + how long transient retries continue |
| `free_credits_default` | fallback for the admin-editable new-lead free-credit Setting |

The provider slugs are the canonical `AiCredential::PROVIDER_*` constants, so config and model never drift. Encryption uses the app `APP_KEY` (Laravel `encrypted` cast) — the same mechanism as `ZoomCredential`. The one runtime-editable value lives as a `Setting` row (`ai.lead_free_credits`) managed on the Manage AI page; config only provides its default.

## GUIDELINES alignment
**Conforms**
- **§7 Key model** — `AiCredential extends Model` + `HasUuid` + `RecordsBlame` ⇒ `id`, `uuid`, create/update blame, timestamps. **Intentionally hard-deleted** (no `SoftDeletes`, no `deleted_at`/`deleted_by`): a removed key is unrecoverable, so a tombstone would only retain an unusable encrypted secret. ✔
- **§7 Migrations** — snake_case, **no schema-level FK** (`lead_id` indexed only), `->index()` on `scope`/`lead_id`/`provider`, `->nullable()` on optionals, `_by` suffixes, `unsignedInteger` CONST columns defaulting to model constants. ✔
- **§2 Repository pattern** — every write goes through `AiCredentialRepository` inside `DB::transaction`; input filtered with `data_only()` **before** the transaction; nested array keyed by `ai_credential`; returns the refreshed model. ✔
- **§3 Constants for states** — `SCOPE_*` / `PROVIDER_*` on `AiCredential` and `STATUS_*` on `AiRequest`, each with their `SCOPES` / `STATUSES` metadata arrays; no magic numbers/strings. ✔
- **§3 Thin controllers** — validation in Form Requests, explicit one-by-one input mapping into the nested array, writes delegated to the repository, `flash()` + `back()` feedback. ✔
- **§13 Frontend** — controllers return `Inertia::render`; pages are Tailwind-only Vue under `Pages/{Context}/…`; the shared `ProviderCard` lives in `Components/Ai/`; destructive remove uses `ConfirmModal` (never native `confirm()`); the key field is masked and write-only. ✔
- **Secret hygiene** — `api_key` `encrypted` + `$hidden`, mirroring `ZoomCredential`; only `last_four` is ever serialized. ✔

**Minor notes**
1. **No Facade for the repository** — follows the actually-emulated pattern (Media / Whatsapp inject the repository directly; the project has no `Facades`). 
2. **Save makes a live verification call** — the one place this module touches a provider; it lists models (no prompt, no token cost) and blocks the save unless the key is accepted. A transient `429` / network error therefore also blocks the save (reported inline) — by design, only a confirmed-good key is stored.
3. **Lazy lead creation** — the user portal creates the member's `Lead` on first key save via `LeadRepository::firstOrCreateForUser()` (transactional, per §3), since *lead == portal user*; viewing the page creates nothing.

## Related files
**Backend — Model**
- [src/Ai/AiCredential.php](/src/Ai/AiCredential.php) — the encrypted credential record (uuid + create/update blame; hard-deleted); `scope` / `provider` constants; `lead()`; `lastFour()` / `catalog()`.

**Backend — Services & Repository**
- [src/Ai/Services/AiKeyService.php](/src/Ai/Services/AiKeyService.php) — `resolveForLead()` / `resolveGlobal()`, default lookups, `present()`, `providers()`.
- [src/Ai/Services/AiProviderValidator.php](/src/Ai/Services/AiProviderValidator.php) — per-provider key verification (called by Save).
- [src/Ai/Services/AiCreditService.php](/src/Ai/Services/AiCreditService.php) — credit read/policy: `remaining()` / `hasCredits()` / `spend()` / `planConversation()`.
- [src/Ai/Repositories/AiCredentialRepository.php](/src/Ai/Repositories/AiCredentialRepository.php) — transactional `upsert` / `delete` (hard).

**Backend — Calling layer**
- [src/Ai/Services/AiClient.php](/src/Ai/Services/AiClient.php) — the general AI calling service (`prompt()` / `chat()`); resolves provider/model/key, dispatches, fails soft, and logs every call.
- [src/Ai/Responses/AiResponse.php](/src/Ai/Responses/AiResponse.php) — normalized result (`ok`/`text`/`usage`/`error`/`raw`/`retryable`/`httpRequest` + `json()`).
- [src/Ai/Support/AiAttachment.php](/src/Ai/Support/AiAttachment.php) — attachment factories (`image`/`audio`/`document`/`video`/`imageUrl`/`fromMedia`/`fromPath`).
- [src/Ai/Contracts/AiTransport.php](/src/Ai/Contracts/AiTransport.php) + [src/Ai/Transports/](/src/Ai/Transports/) — `AbstractTransport` + `AnthropicTransport` / `OpenAiTransport` (OpenAI + DeepSeek + **Kimi**) / `GeminiTransport`.

**Backend — Request log**
- [src/Ai/AiRequest.php](/src/Ai/AiRequest.php) — the call log (uuid; `subject()` morph; `lead()`; status consts incl. `STATUS_PROCESSING`; `totalTokens()`) + the prompt resolution statics (`promptMeta()` / `promptSystem()` override-aware / `promptDefault()` code default / `promptLabel()`).
- [src/Ai/Repositories/AiRequestRepository.php](/src/Ai/Repositories/AiRequestRepository.php) — transactional two-phase log: `start()` (open `processing`) / `complete()` (settle) + one-shot `log()`; all set `created_by` from the auth user.

**Backend — Editable prompts & model pins**
- [src/Ai/AiPrompt.php](/src/Ai/AiPrompt.php) — one runtime-config row per registry key: edited `system` body + pinned `provider`/`model` (`RecordsBlame`); resolution reads `runtimeFor()` / `valueFor()` / `pinFor()` / `overrideText()`.
- [src/Ai/AiPromptVersion.php](/src/Ai/AiPromptVersion.php) — append-only text history (`ACTION_EDITED` / `ACTION_RESET` / `ACTION_RESTORED` + `ACTIONS`); immutable rows (`UPDATED_AT = null`), never deleted.
- [src/Ai/Repositories/AiPromptRepository.php](/src/Ai/Repositories/AiPromptRepository.php) — transactional versioned writes: `saveText` / `resetText` / `restore` (each appends a version) + `savePin` (blamed, not versioned); unique-collision retry in a fresh transaction.
- [src/Ai/Services/AiPromptService.php](/src/Ai/Services/AiPromptService.php) — `catalog()` (editable prompts + default/effective bodies, pins, version summary, blame), `versions()` (History payload), `editable()` / `modelPinnable()`.
- [app/Http/Controllers/Manage/Integrations/AiPromptsController.php](/app/Http/Controllers/Manage/Integrations/AiPromptsController.php) — the AI Prompts page (`index` / `update` / `destroy` / `versions` / `restoreVersion` / `updateModel` / `updateSettings` / `updateConversationFeatures`) · [PromptUpdateRequest.php](/app/Http/Requests/Manage/Integrations/Ai/PromptUpdateRequest.php) · [PromptModelRequest.php](/app/Http/Requests/Manage/Integrations/Ai/PromptModelRequest.php) · [ConversationFeatureSettingsRequest.php](/app/Http/Requests/Manage/Integrations/Ai/ConversationFeatureSettingsRequest.php) (Super-Admin abort + the four fixed boolean fields).

**Backend — Resilience**
- [app/Jobs/Ai/AiJob.php](/app/Jobs/Ai/AiJob.php) — abstract base for queued AI jobs (dedicated lane, rate limit, circuit breaker, retry policy, `aiOrFail()`).
- [src/Ai/Exceptions/AiTransientFailure.php](/src/Ai/Exceptions/AiTransientFailure.php) · [AiPermanentFailure.php](/src/Ai/Exceptions/AiPermanentFailure.php) — retry vs fail-now signals thrown by `aiOrFail()`.
- [app/Providers/AppServiceProvider.php](/app/Providers/AppServiceProvider.php) — the per-provider `ai` rate limiter.
- [config/queue.php](/config/queue.php) (`redis-ai` connection) · [config/horizon.php](/config/horizon.php) (`supervisor-ai` + `ai` queue wait alert).

**Backend — Conversation release controls**
- [src/Conversation/Support/ConversationFeatureFlags.php](/src/Conversation/Support/ConversationFeatureFlags.php) — the ONLY runtime reader of the four controls (`actionPlans()` / `opportunities()` / `recordingChat()` / `salesCoach()`; `toArray()` eager + `toLazyArray()` deferred for the Inertia share; `flush()`).
- [src/Conversation/Support/ConversationFeatureResolver.php](/src/Conversation/Support/ConversationFeatureResolver.php) — the batched, fail-closed `settings` read behind it; registered as a container-**scoped** binding in [app/Providers/AppServiceProvider.php](/app/Providers/AppServiceProvider.php) so the memo dies with the request and between queued jobs.
- [src/Setting/Setting.php](/src/Setting/Setting.php) — the keys: `CONVERSATION_ACTION_PLANS` / `CONVERSATION_RECORDING_CHAT` / `CONVERSATION_OPPORTUNITIES` / `CONVERSATION_SALES_COACH` + the `CONVERSATION_FEATURES` list · [SettingRepository::putMany()](/src/Setting/Repositories/SettingRepository.php) — the one-transaction write the card uses.
- [app/Http/Middleware/HandleInertiaRequests.php](/app/Http/Middleware/HandleInertiaRequests.php) — shares them under both prop-name sets, each **deferred behind a closure**.

**Backend — Free credits & settings**
- [src/Lead/LeadAiCredit.php](/src/Lead/LeadAiCredit.php) — per-lead credit balance (1:1); `remaining()` / `hasCredits()`.
- [src/Lead/Repositories/LeadAiCreditRepository.php](/src/Lead/Repositories/LeadAiCreditRepository.php) — transactional `grant` / `spend` / `topUp`.
- [src/Setting/Setting.php](/src/Setting/Setting.php) + [src/Setting/Repositories/SettingRepository.php](/src/Setting/Repositories/SettingRepository.php) — generic key-value settings store (holds `ai.lead_free_credits`).

**Backend — Controllers & Form Requests**
- [app/Http/Controllers/Manage/Integrations/AiRequestsController.php](/app/Http/Controllers/Manage/Integrations/AiRequestsController.php) — the AI Requests index (+ `show` JSON for the detail modal, + `compare` — replay a request's prompt on another provider/model) · [AiRequestQueryRequest.php](/app/Http/Requests/Manage/Integrations/Ai/AiRequestQueryRequest.php) · [CompareRequest.php](/app/Http/Requests/Manage/Integrations/Ai/CompareRequest.php) (validates the compare target provider+model).
- [app/Http/Controllers/Manage/Integrations/AiProvidersController.php](/app/Http/Controllers/Manage/Integrations/AiProvidersController.php) — global key actions (`update`/`destroy`) + `test` (live test request, logged), driven from the Providers modal (settings moved to `AiPromptsController@updateSettings`).
- [app/Http/Requests/Manage/Integrations/Ai/AiRequestQueryRequest.php](/app/Http/Requests/Manage/Integrations/Ai/AiRequestQueryRequest.php) — list search/filtering (§9).
- [app/Http/Controllers/Main/Portal/AiSettingsController.php](/app/Http/Controllers/Main/Portal/AiSettingsController.php) — member (per-lead) key write endpoints (`update`/`destroy`); the key UI now lives in the AI Conversations providers modal (`index` just redirects there).
- [app/Http/Requests/Manage/Integrations/Ai/UpdateRequest.php](/app/Http/Requests/Manage/Integrations/Ai/UpdateRequest.php) · [SettingsRequest.php](/app/Http/Requests/Manage/Integrations/Ai/SettingsRequest.php) · [app/Http/Requests/Main/Portal/AiSettings/UpdateRequest.php](/app/Http/Requests/Main/Portal/AiSettings/UpdateRequest.php).

**Frontend**
- [resources/js/Components/Ai/ProviderCard.vue](/resources/js/Components/Ai/ProviderCard.vue) — shared provider key card (Save verifies then stores; Remove; opt-in `testable` live-Test button for the admin portal; shows the catalog default model read-only), reused by both portals.
- [resources/js/Pages/Manage/Integrations/Ai/Index.vue](/resources/js/Pages/Manage/Integrations/Ai/Index.vue) — the AI Requests DataTable index · [Partials/ProvidersModal.vue](/resources/js/Pages/Manage/Integrations/Ai/Partials/ProvidersModal.vue) (key cards + Test only) · [Partials/AiRequestDetailModal.vue](/resources/js/Pages/Manage/Integrations/Ai/Partials/AiRequestDetailModal.vue) (lazy-fetched prompt/response detail) · [Partials/AiCompareModal.vue](/resources/js/Pages/Manage/Integrations/Ai/Partials/AiCompareModal.vue) (Compare row action — replay the prompt on other models, side-by-side results).
- [resources/js/Pages/Manage/Integrations/Ai/Prompts.vue](/resources/js/Pages/Manage/Integrations/Ai/Prompts.vue) — the AI Prompts page (prompt list + monospace editor with Customised·v{n} badge, Save / Reset-to-default, unsaved-changes + leave guard, per-feature model picker, free-credits card, and the Super-Admin-only **Release controls** card — four switches, saved together, rendered only when the `releaseControls` prop is present; reached via the **Prompts** header button on AI Requests) · [Partials/PromptHistoryModal.vue](/resources/js/Pages/Manage/Integrations/Ai/Partials/PromptHistoryModal.vue) (lazy-fetched version history + Restore).
- [resources/js/Pages/Main/Portal/AiConversations/Partials/ProvidersModal.vue](/resources/js/Pages/Main/Portal/AiConversations/Partials/ProvidersModal.vue) — the member (per-lead) BYO-key cards, reached from the AI Conversations page (the standalone AI Settings page was removed).
- Nav entry: [resources/js/Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) ("AI Requests"). The member keys UI is opened from the AI Conversations page, not a nav item.

**Config & Migrations**
- [config/ai.php](/config/ai.php) — non-secret provider catalog + `free_credits_default` + calling settings (`default_provider`, `request_timeout`, `default_max_tokens`) + per-model `pricing`.
- [config/ai_prompts.php](/config/ai_prompts.php) — the prompt registry (key → name/description/inline system + `persona`/`model_note` flags) · [resources/prompts/](/resources/prompts/) — the **default** prompt bodies (`{key}.md`, auto-loaded by `AiRequest::promptDefault()`; admin edits on the `ai_prompts` row win).
- [database/migrations/2026_06_09_000003_create_ai_credentials_table.php](/database/migrations/2026_06_09_000003_create_ai_credentials_table.php) · […_create_settings_table.php](/database/migrations/2026_06_10_000001_create_settings_table.php) · […_create_lead_ai_credits_table.php](/database/migrations/2026_06_10_000002_create_lead_ai_credits_table.php) (grants existing leads on backfill) · […_create_ai_requests_table.php](/database/migrations/2026_06_10_000003_create_ai_requests_table.php) · […_create_ai_prompt_tables.php](/database/migrations/2026_07_11_120001_create_ai_prompt_tables.php) (`ai_prompts` + `ai_prompt_versions`; migrates the legacy model Settings into pins).

**Relationships**
- [src/Lead/Lead.php](/src/Lead/Lead.php) — `aiCredentials()` + `aiCredit()`; grant-on-create + delete-cascade. · [src/People/User.php](/src/People/User.php) — `lead()` (1:1, the anchor for a member's keys + credits).

**See also:** [Media](/docs/modules_handbook/shared/media/readMe.md) (the storage-service sibling this mirrors) · [AI Conversations](/docs/modules_handbook/main/ai-conversations/readMe.md) (the user-portal chat) · [AI Debate](/docs/modules_handbook/main/ai-debate/readMe.md) (the multi-model debate panel) — both consume `AiClient::stream()` + `AiCreditService` · [Events · Slot Posters](/docs/modules_handbook/manage/events/slot-posters/readMe.md) (a non-`AiClient` `ai_requests` producer, image generation not chat).

### Reference usage: private CEO employee email reviews

`Src\Ceo\Services\EmployeeEmailAi` and the `ReviewEmployeeEmail` / `ChatEmployeeEmail` AI jobs use registered prompt `employee_email_review` with explicit OpenAI `gpt-6-astra`. This owner-private use case intentionally sets `log => false`: company-wide AI Request readers must not receive personal CEO emails or questions. Results live encrypted in the owner-scoped review/chat tables. Gateway client mode is refused because it cannot honor the exact model pin. See [Email → Employee update](/docs/modules_handbook/manage/email/readMe.md).
