# PropertyLab AI Gateway (`Src\AiGateway` · white-label LLM routing + chargeback)

**Context:** Manage (Hub: Setting → AI Gateway; Client: Setting → PropertyLab API) + a stateless API · **Routes:** `manage.ai-gateway.*`, `manage.propertylab-api.*`, `api.ai-gateway.*` · **Plan:** [`docs/planning/propertylab-ai-gateway-chargeback-plan.md`](/docs/planning/propertylab-ai-gateway-chargeback-plan.md) · **Nav:** Setting hub → `AI Gateway` (Hub) / `PropertyLab API` (client), `SettingTabs.vue`.

## What it does
One codebase, two roles, switched by env (`config/ai_gateway.php`):

- **Client** (`AI_GATEWAY_MODE=client`) — a white-label deployment. It holds no provider keys and picks no models: every `AiClient` call, every transcription and every text-to-speech is relayed to the Hub as the `propertylab` pseudo-provider and billed in **credits**. Provider keys, model pins, the AI Requests tab, the Compare modal and members' bring-your-own-key are gone (routes not registered). A **PropertyLab API** page shows credits by feature, a daily strip, the request log (provider "PropertyLab API", model "PropertyLab AI" — never the real one) and the Hub's statements. Poster images and AI Video are not available.
- **Hub** (`AI_GATEWAY_HUB_ENABLED=true`) — PropertyLab's own deployment. Serves `/ai-gateway/v1/{chat,transcribe,tts,me,statements}` behind each client's bearer key, keeps the client registry, resolves the model per client, prices models in credits, and builds monthly statements.
- Neither → standalone, unchanged. Both → refused at boot.

## How it works
- **Relay.** `AiClient::prepare()` short-circuits in client mode: it still resolves the prompt key's system text locally (the client keeps its registry and admin edits) and sends the built turns + `prompt_key` + `tier` to the Hub via `Src\Ai\Transports\PropertyLabTransport` (`POST /ai-gateway/v1/chat`, SSE when streaming). Explicit `provider`/`model` options are ignored. `AiJob` and the `ai` limiter collapse onto one `propertylab` bucket.
- **Model resolution (Hub)** — `AiGatewayService::resolveModel()`: the client's override for that prompt key (`ai_gateway_client_models`) → the client's tier default (`standard_*` / `flash_*` on `ai_gateway_clients`) → env (`AI_GATEWAY_DEFAULT_MODEL` / `AI_GATEWAY_DEFAULT_FLASH_MODEL`, `provider:model`). A prompt key's **tier** is declared in `config/ai_prompts.php` (`'tier' => AiRequest::TIER_FLASH`; default standard) — it describes the job, so it lives in code; the model a tier means is per-client data.
- **Logging.** The Hub runs its own `AiClient` under `PROMPT_GATEWAY_RELAY` with `system => false` (the turns already carry the client's system message — the Hub's relay prompt body can never be injected). The row carries `client_id`, `client_prompt_key` (what the client called it), the real provider/model, USD `cost`, and `credits` at the client's price; `gateway_request_uuid` links the two sides. The client logs `provider = propertylab`, `model = null`, `credits` from the reply. **Failed calls bill 0. An unpriced model bills null and pages `ai_gateway.unpriced_model`.**
- **Pricing** — `ai_gateway_model_prices`: credits per 1M input/output tokens + flat per request; per audio minute (transcription); per 1k characters (TTS). `AiGatewayPricing` is the reader (cached 60s, `forget()` on edit). Credits are **snapshotted per row**, so a price change never rewrites a closed month. **Every model is priced AT COST by default** — no saved row means its USD list price as credits (`AiGatewayPricing::atCost()`, 1 credit = USD 1); only a model absent from `config('ai.pricing')` is unpriced. **The scheme (Lee Jie, 2026-09-08): credits are pure USD cost; money is derived.** A client carries a `markup_percent` only; its price per credit = live USD→currency rate × (1 + markup) (`AiGatewayClient::pricePerCredit()`), never typed. The rate comes from `Src\AiGateway\Services\FxRateService` — a manual override (`AI_GATEWAY_FX_OVERRIDE="MYR=4.20"`), else the Frankfurter feed (ECB reference rates, no key, cached 12h), else the last rate the feed returned, else `AI_GATEWAY_FX_FALLBACK_MYR`. `AiGatewayStatementBuilder` **freezes** `fx_rate`, `markup_percent` and the resulting `price_per_credit` onto the statement when the draft is built; rebuilding a draft re-freezes, issuing stops it. The Pricing page's **Reset all to USD cost** button (`PricingController::syncUsd`) has no markup any more — never bake a markup into a model. Per-minute / per-character rates stay hand-set — the USD table has no such unit.
- **Transcription** — client: `Src\Transcription\Drivers\GatewayTranscriber` is bound as the only driver; Hub: `POST transcribe` runs the normal chain, `LoggingTranscriber` attributes the row via `gateway_client_id` + a `gateway_correlation` in `meta`, and bills `meta.units` minutes (provider `metadata.duration`, else the caller's `duration_seconds`). **TTS** — `GeminiTtsClient::synthesize()` relays in client mode and logs every call under `PROMPT_VOICE_SYNTHESIS` on both sides.
- **Statements** — `ai-gateway:close-month` (monthly, Malaysia time, Hub only) builds a DRAFT per client via `AiGatewayStatementBuilder` (SUCCESS rows only, grouped by `client_prompt_key`) + `AiGatewayStatementRepository`. A person **issues** it (Setting → AI Gateway → Statements): the PDF is rendered (`AiGatewayStatementPdf`, Dompdf, stored via `MediaService`) and emailed (`AiGatewayStatementMail`) when the client has a billing email. Clients read issued/paid statements over the API; the client page mirrors them.
- **Auth** — `AuthenticateAiGatewayClient` (`ai-gateway` middleware): sha256 of the bearer key against `ai_gateway_clients.key_hash`; 401 unknown, 402 suspended; per-client `throttle:ai-gateway`. Rotating a key revokes the old one immediately. Mounted **without** `/api` (Dingo hijacks `/api/*` here) and without the `api` group's per-IP throttle.
- **Names on a client** — "PropertyLab API" / "PropertyLab AI" are constants in `config/ai_gateway.php` (no env), and on a client `AiGatewayMode::label()` / `modelLabel()` prefer the Hub's own `/me` answer (`service_label`, `model_label`, cached by `PropertyLabHubClient`) over the local constant — a re-branded client build that rewrites "PropertyLab" in its source still shows PropertyLab's names once the Hub has been reached (Starcity, 2026-09-06). The constants and the vendor-facing banner copy carry a trailing `// brand:keep` comment, and a white-label fork's rebrand sweep (`petav3-hk` → `scripts/brand`) skips any line that has it and never renames the phrase "PropertyLab API" at all — so a fork's fallback is still PropertyLab's name before the Hub is ever reached.
- **Spend figures** — every tile that totals AI spend sums `AiRequest::spendColumn()` (`credits` on a client, `cost` elsewhere) and renders through `Components/AiSpend.vue`, so a client never sees a dollar figure it is not paying.

## Reference usage
The hand-over document for the CLIENT's own tech person is [`client-setup.md`](client-setup.md) — send them that. Our side, standing up a white-label client:
1. Hub: Setting → AI Gateway → **Pricing** — price every model you intend to assign (a tier default or override that names an unpriced model is refused).
2. Hub: **Clients → New client** — name, currency, price per credit, tier defaults. Copy the key from the banner; it is shown once.
3. Client `.env`: `AI_GATEWAY_MODE=client`, `PROPERTYLAB_AI_BASE_URL=https://<hub>`, `PROPERTYLAB_AI_KEY=plk_…`; `php artisan config:clear && route:clear`, restart SSR/queues.
4. Verify on the client: Setting → PropertyLab API shows the account (no banner); make any AI call and see it in the log with credits.
5. Month end: the draft appears on the Hub; review it, **Issue**.

## Related files
- `config/ai_gateway.php`, `Src\Ai\Support\AiGatewayMode`, `AppServiceProvider::boot()` (consistency guard), `HandleInertiaRequests` (`features.ai_gateway`), `resources/js/composables/useAiGateway.js`, `Components/AiSpend.vue`
- `Src\Ai\Transports\PropertyLabTransport`, `AiClient::prepare()` / `gatewayCredits()`, `AiJob::middleware()`
- `src/AiGateway/` — `AiGatewayClient`, `AiGatewayClientModel`, `AiGatewayModelPrice`, `AiGatewayStatement(+Line)`, `Repositories/*`, `Services/{AiGatewayService,AiGatewayPricing,AiGatewayStatementBuilder,AiGatewayStatementPdf,PropertyLabHubClient}`
- API: `routes/ai-gateway-api.php`, `app/Http/Controllers/AiGatewayApi/*`, `app/Http/Requests/AiGatewayApi/*`, `App\Http\Middleware\AuthenticateAiGatewayClient`
- Manage: `app/Http/Controllers/Manage/AiGateway/*` + `resources/js/Pages/Manage/AiGateway/**`; `Manage/Integrations/PropertyLabApiController` + `Pages/Manage/Integrations/PropertyLabApi/Index.vue`
- Billing: `App\Console\Commands\AiGateway\CloseMonth`, `App\Mail\AiGatewayStatementMail`, `resources/views/pdf/ai-gateway-statement.blade.php`
- Non-chat: `Src\Transcription\Drivers\GatewayTranscriber`, `App\Support\Transcription\LoggingTranscriber`, `App\Helpers\GeminiTtsClient`
- Migrations: `2026_09_03_100000_add_gateway_columns_to_ai_requests`, `2026_09_03_100001_create_ai_gateway_tables`
- Tests: `tests/Feature/AiGateway/*`
