# Enterprise AI SDK — Public API (TypeScript/Node.js)

Dokumentasi permukaan publik SDK. Aplikasi HANYA boleh memakai yang
didokumentasikan di sini (ADR-011). Import: `enterprise-ai-sdk`.

> Dokumen ini mencakup **gelombang kedua** (V1–V9): storage multi-connection,
> embedding+rerank lokal, knowledge dua-tahap, prompt global, meta-tools
> discovery, autoExec, feedback admin + learning stabil, Local Provider (FAQ
> cache), dan identity + tool-permission.

---

## 1. Dua Cara Pakai

### a. Facade statis (ala Eloquent — import langsung pakai)

```ts
import { AI } from 'enterprise-ai-sdk';

// Config ambient: eai.config.json + .env (DEEPSEEK_API_KEY) + AI.configure()
const res = await AI.chat('Halo, siapa kamu?');
console.log(res.getFinalMessage());
```

### b. Instance eksplisit (multi-config / DI / test)

```ts
const sdk = await AI.create({
    provider: { defaultProvider: 'deepseek', defaultModel: 'deepseek-v4-pro' },
    providers: { deepseek: { apiKey: process.env.DEEPSEEK_API_KEY } },
});
const res = await sdk.chat('Halo');
```

---

## 2. Konfigurasi — Tiga Lapis (ADR-009)

| Lapis | Cara | Scope |
|---|---|---|
| 1 — Global/Base | `AI.create(options)` / `AI.configure(options)` / `eai.config.json` / env | Lifetime instance |
| 2 — Session | `sdk.setConfig(partial)` | Sisa lifetime; TIDAK menghapus provider/tool terdaftar |
| 3 — Per-call | `sdk.use('x').model('m').temperature(0.2).session('s1').as({userId}).chat(...)` | Satu panggilan, auto-reset |

Precedence: **Lapis 3 > Lapis 2 > programmatic > file > env > default**.

Sumber file: `eai.config.json` di cwd (override path: env `EAI_CONFIG_PATH`).
apiKey per provider: `providers.<id>.apiKey` di config ATAU env `<ID>_API_KEY`
(config menang). JANGAN commit apiKey — pakai `.env`.

### Field konfigurasi

| Grup | Field | Default |
|---|---|---|
| _(top-level)_ | `namespace` — isolasi tenant scope-key semua subsistem storage (#13) | null |
| `provider` | `defaultProvider`, `defaultModel`, `allowedProviders` | null/null/[] |
| `providers.<id>` | `apiKey`, `model`, `baseUrl`, `temperature`, `maxTokens` | — |
| `timeout` | `requestTimeoutMs`, `providerTimeoutMs` (min 1000) | 30000/25000 |
| `retry` | `enabled`, `maxRetries` (≤5), `retryDelayMs` | false/2/1000 |
| `fallback` | `enabled`, `fallbackProviders[]` | false/[] |
| `logging` | `enabled`, `level`, `redactionEnabled` | true/info/true |
| `cost` | `trackingEnabled`, `pricing{provider:{model:{inputPer1kTokens,outputPer1kTokens}}}` | true/null |
| `security` | `redactionEnabled`, `promptDebuggingEnabled` | true/false |
| `storage` | `enabled`, `defaultConnection`, `databases{<nama>:{adapter,connectionString,prefix}}` | false/'sqlite'/{sqlite:…} |
| `session` | `enabled`, `maxMessages`, `fields` | false/20/`{}` |
| `memory` | `enabled`, `maxEntries` | false/100 |
| `knowledge` | `enabled`, `maxEntries` | false/500 |
| `learning` | `enabled`, `autoRecord` | false/false |
| `cache` | `enabled`, `ttlMs` (≥1000), `maxEntries`, `driver` (`memory`\|`redis`), `redisUrl` | false/300000/500/`memory`/null |
| `embedding` | `model`, `threshold` (0–1 \| `null`=auto per model), `scanLimit`, `candidateLimit`, `driver` (`local`\|`remote`), `remoteUrl`, `remoteApiKey` | e5-small/null/500/100/`local`/null/null |
| `rerank` | `model`, `threshold` (0–1 \| `null`=auto), `topK`, `driver` (`local`\|`remote`), `remoteUrl`, `remoteApiKey` | bge-reranker-base/null/10/`local`/null/null |
| `prompt` | `systemPrompt` | null |
| `tools` | `autoExec`, `maxDiscoveryIterations` (≥1) | true/1 |
| `localProvider` | `enabled`, `similarityThreshold`, `rerankThreshold`, `scanLimit` | false/0.90/0.30/500 |

### Storage multi-connection (gaya Laravel, V1)

```ts
storage: {
    enabled: true,
    defaultConnection: 'main',
    databases: {
        main: { adapter: 'postgres', connectionString: 'postgres://…', prefix: 'eai_' },
        // 'sqlite' selalu ada sebagai default embedded (bisa di-override).
    },
}
```

`storage.enabled` = master switch. `false` → subsystem storage-backed pakai
in-memory ephemeral (hilang saat proses mati). Adapter: `sqlite` (embedded,
fallback `./.eai/llm-storage.sqlite`), `postgres`, `mysql`, `mongodb` (install
driver DB yang dipakai).

> **Catatan penamaan** — `MemoryStorageAdapter` (internal) = adapter storage
> *in-memory* (RAM), fallback saat `storage.enabled:false`. Ini BEDA dari
> subsystem **memory** (`sdk.remember`) yang menyimpan ingatan lintas
> percakapan. Nama sengaja dipertahankan; jangan tertukar.

---

## 3. Provider

Bawaan (auto-register bila apiKey ter-resolve dari config/env):
`deepseek` (default model `deepseek-v4-pro`), `openai` (`gpt-4o-mini`),
`anthropic` (`claude-sonnet-5`), `google` (`gemini-2.0-flash`).

Env key: `DEEPSEEK_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`,
`GOOGLE_API_KEY`.

Custom: `sdk.registerProvider(id, adapter)` dengan `ProviderAdapter`
(`identity()`, `execute(request)`, `listModels?()`). Bertahan lintas
`setConfig`.

### baseUrl per provider (endpoint)

Tiap provider punya **default baseUrl**, dan aplikasi bisa **meng-override**-nya
via `providers.<id>.baseUrl` (untuk proxy, gateway seperti OpenRouter, Azure
OpenAI, endpoint regional, atau server self-hosted yang wire-compatible).

| Provider | Default baseUrl |
|---|---|
| `deepseek` | `https://api.deepseek.com` |
| `openai` | default resmi SDK OpenAI (`https://api.openai.com/v1`) |
| `anthropic` | default resmi SDK Anthropic (`https://api.anthropic.com`) |
| `google` | default resmi SDK Google GenAI |

`baseUrl: null` (default) = pakai endpoint resmi provider. Set string untuk
mengganti. Override berlaku via **Lapis 1 (`AI.create`)** maupun **Lapis 2
(`setConfig`)** — pipeline membaca config terkini tiap request. (Tidak ada
override per-call/Lapis 3 untuk baseUrl.)

```ts
// Lapis 1 — saat create:
const sdk = await AI.create({
    provider: { defaultProvider: 'openai', defaultModel: 'gpt-4o-mini' },
    providers: {
        openai: { apiKey: process.env.OPENAI_API_KEY, baseUrl: 'https://openrouter.ai/api/v1' },
        deepseek: { apiKey: process.env.DEEPSEEK_API_KEY, baseUrl: 'https://proxy.internal/deepseek' },
    },
});

// Lapis 2 — ubah belakangan (berlaku untuk request berikutnya):
sdk.setConfig({ providers: { openai: { baseUrl: 'https://api.openai.com/v1' } } });
```

> Kompatibilitas endpoint jadi tanggung jawab aplikasi: provider `openai`/
> `deepseek` memakai wire-protocol OpenAI (`/chat/completions`), jadi baseUrl
> harus OpenAI-compatible; `anthropic`/`google` mengikuti SDK resmi masing-masing.

> **Local Provider** ≠ memilih `'local'` sebagai provider (itu masih TODO).
> FAQ cache "local-first" diaktifkan via `localProvider.enabled` — lihat #8.

---

## 4. Method

### AI (statis — lazy global instance)
| Method | Keterangan |
|---|---|
| `AI.create(options?)` | Instance terisolasi (bukan global) |
| `AI.configure(options)` | Base config global instance |
| `AI.chat(message)` | Chat |
| `AI.vision(prompt, images, options?)` | Analisis gambar (multimodal); `options.fields` = skema output kanonik — lihat #12 |
| `AI.stream(message)` / `AI.reason(message)` | STUB — `capability_not_implemented` |
| `AI.use/model/apiKey/temperature/maxTokens/session/as(...)` | Chaining Lapis 3 |
| `AI.setConfig(options)` | Lapis 2 |
| `AI.setPrompt(text)` | Set system prompt global (V4) |
| `AI.setSessionFields({field: desc})` | Skema ekstraksi session data (auto chat+vision) — §7a |
| `AI.setNamespace(id\|null)` | Set namespace tenant (isolasi scope-key semua subsistem storage) — #13 |
| `AI.setIdentity({userId,…})` | Set identity default (V9) |
| `AI.registerProvider(id, adapter)` / `AI.listProviders()` | Provider |
| `AI.registerTool(tool)` / `AI.listTools()` | Tools |
| `AI.remember(content, scope?)` | Memory (per-user) |
| `AI.session(id).rememberData(record)` / `.sessionData()` | Session data — fakta terstruktur per-session (§7a) |
| `AI.clearSession(id)` | Hapus SELURUH state session: history + session data + pending-tool (§7a) |
| `AI.addKnowledge({title, content})` | Knowledge |
| `AI.warmup()` | Preload model embedding+rerank (atasi cold-start; panggil saat boot) |
| `AI.listHistory(limit?)` / `AI.historyFeedback(id, rating)` | Admin feedback (V7) |
| `AI.listCapabilities()` | Capability terdaftar |
| `closeAllStorage()` (named export) | Tutup semua pool storage ber-share (graceful shutdown, mis. SIGTERM) — #13 |
| `AI.reset()` / `AI.swap(instance)` | Lifecycle global (testing) |

### EnterpriseAiSdk (instance)
Semua di atas kecuali `create/configure/reset/swap` (milik facade).

### AiResponse (nilai balik chat)
| Method | Keterangan |
|---|---|
| `status` / `requestId` / `raw` / `data` | Envelope StandardResponse (ADR-008) |
| `getFinalMessage()` | Jawaban AI (placeholder terganti; auto bila `tools.autoExec`) |
| `getConfirmationMessage()` / `getNotificationMessage()` / `getPayload()` | Field workflow |
| `hasAction()` / `getAction()` | Saran tool dari AI |
| `executeTools()` / `isToolsExecuted()` / `getToolResults()` | Eksekusi tool manual (bila `autoExec:false`) |
| `saveToHistory()` | Simpan assistant ke history manual (autoExec=false, V6) |
| `isCacheable()` | Ditandai AI stabil → kandidat FAQ cache (V7) |
| `usedLocalProvider()` / `getLocalStatus()` | Dijawab dari FAQ cache? status hit/miss (V8) |
| `recordToLearning(metadata?)` / `isRecorded()` | Rekam interaksi ke learning (butuh `learning.enabled`) |

---

## 5. Tools + Discovery (ADR-021)

```ts
sdk.registerTool({
    name: 'getSalary',
    description: 'Ambil informasi gaji karyawan. WAJIB untuk pertanyaan gaji.',
    placeholders: ['salary_info'],
    parameters: { type: 'object', properties: { employeeId: { type: 'string' } } },
    roles: ['hr'],                       // V9: hanya identity ber-role 'hr'
    handler: async (params, context) => ({ success: true, data: { salary_info: 'Rp 15.000.000' } }),
});

// autoExec default TRUE → SDK jalankan tool + resolve placeholder otomatis:
const res = await sdk.as({ userId: 'u1', roles: ['hr'] }).chat('Berapa gaji saya?');
console.log(res.getFinalMessage());      // "{salary_info}" sudah terganti
```

**Meta-tools discovery (V5).** Tool TIDAK dikirim penuh tiap request. SDK
menawarkan `listTools`/`listKnowledge`; saat AI memintanya, SDK menyaring
(embed+rerank) lalu menyuntik hanya schema yang relevan. Konsekuensi: chat
ber-tool butuh 2–3 panggilan provider. Guard `tools.maxDiscoveryIterations`
(default 1) mencegah loop → `tool_discovery_exceeded`.

**autoExec (V6).** `tools.autoExec:true` (default) → SDK eksekusi tool +
resolve placeholder SEBELUM menyimpan history (tidak ada placeholder bocor).
`false` → aplikasi memanggil `executeTools()` lalu (opsional) `saveToHistory()`.

**Params kurang → konfirmasi.** Bila AI butuh param yang belum ada, response
`requires_confirmation` + `getConfirmationMessage()`; SDK mengingat tool
terpilih (carry-forward, butuh `session(id)`) sampai param lengkap.

**Permission (V9).** `roles:[]` = publik. Non-kosong → identity wajib punya ≥1
role cocok; jika tidak, tool tak ditawarkan ke AI dan eksekusi ditolak
(`ToolExecutionResult.success:false`).

Kegagalan handler/params/timeout/permission → `ToolExecutionResult.success:false`
(placeholder diganti pesan error). Pemakaian salah (tool tak terdaftar,
`executeTools` tanpa action) → throw SdkError kategori `tool`.

---

## 6. Identity & Tool-Permission (V9)

SDK **tidak** melakukan authentication — aplikasi menyetel identity yang sudah
tervalidasi.

```ts
sdk.setIdentity({ userId: 'u-1', displayName: 'John Doe', roles: ['admin'] }); // default instance
await sdk.as({ userId: 'u-2', roles: ['hr'] }).chat('...');                     // per-call (auto-reset)
```

Resolusi identity: per-call `as()` → default `setIdentity` → anonymous
(`{userId:'anonymous', roles:[]}`). `roles` menjadi basis tool-permission (#5).
Identity hanya `userId`, `displayName`, `roles` — field `attributes` dibuang.

---

## 7. Session, Memory, Knowledge, Prompt

```ts
// Session — AI mengingat percakapan (butuh session.enabled):
await sdk.session('sesi-user-42').chat('Namaku John Doe');
await sdk.session('sesi-user-42').chat('Siapa namaku?'); // → "John Doe"

// Memory — ingatan lintas percakapan (butuh memory.enabled), PER-USER:
sdk.setIdentity({ userId: 'u-1' });
await sdk.remember('User suka jawaban singkat');   // scope = 'u-1'
// Anonymous (tanpa setIdentity/scope) → TIDAK disimpan & TIDAK di-recall
// (mencegah tumpukan 'global' yang hanya membebani prompt). Beri scope
// eksplisit untuk memaksa: sdk.remember('...', 'scope-x').

// Knowledge — fakta domain, dicari dua-tahap embed→rerank, disuntik bila
// relevan via listKnowledge (butuh knowledge.enabled). Knowledge & learning
// TIDAK di-scope per-user; isolasinya lewat `namespace` tenant (#13) — cocok
// karena knowledge biasanya milik tenant/aplikasi, bukan per end-user:
await sdk.addKnowledge({ title: 'Jam kerja', content: 'Kantor buka 09.00-17.00 WIB.' });

// Prompt global (persona/aturan) — SELALU dibawa tiap request (V4):
sdk.setPrompt('Kamu asisten HR. Selalu jawab dalam Bahasa Indonesia.');
```

**Retrieval lokal (V2/V3).** Embedding (`Xenova/multilingual-e5-small`) +
rerank (`Xenova/bge-reranker-base`) berjalan LOKAL (ONNX, tanpa cost API,
unduh model sekali lalu ter-cache). Keduanya ber-**threshold**: di bawah
ambang = "tidak ada" (kosong) — bukan hasil paling-tidak-jelek.

**kNN native (pgvector).** Recall tahap-1 (cosine) knowledge & FAQ cache
dihitung **di database** bila backend mendukung — kini **PostgreSQL + pgvector**:
adapter meng-`CREATE EXTENSION IF NOT EXISTS vector` saat init, lalu query
`ORDER BY (data->>'embedding')::vector <=> $q` (top-K + threshold + filter model
di DB) alih-alih menarik semua baris + cosine manual di Node. Backend lain
(SQLite/MySQL/MongoDB) **tetap** jalur fetch + cosine. Otomatis & transparan;
degrade dengan anggun bila ekstensi tak ada. Rerank tahap-2 (cross-encoder) sama
untuk kedua jalur. **Skala besar:** SDK menyiapkan otomatis (lazy) generated
column `vector(dim)` + **index HNSW** — embedding rusak/beda-dim jadi NULL (insert
tak pernah gagal), dan query pakai ANN index alih-alih seq-scan.

### 7a. Session Data — fakta terstruktur per-session (bukan pesan)

Beda dari **history session** (daftar pesan, dipotong `maxMessages`), **session
data** menyimpan **fakta mesin** (nama, nominal, no. referensi, hasil ekstraksi
vision) yang **SELALU disuntik penuh** ke prompt — **tidak pernah terpotong** —
selama session hidup. Cocok untuk data yang dikumpulkan bertahap dan tak boleh
"lupa" (mis. akumulasi Budi lalu Tono dalam satu percakapan panjang).

| | History session | **Session data** | Memory |
|---|---|---|---|
| Isi | pesan chat | **fakta terstruktur (objek)** | ingatan lintas percakapan |
| Kunci | `sessionId` | **`sessionId`** | `userId` |
| Dipotong? | ya (`maxMessages`) | **tidak — selalu penuh** | ya (`maxEntries`) |
| Diisi | otomatis tiap chat | **`session.fields` (auto, chat+vision) + `rememberData` (manual)** | `remember()` |

**Tiga cara mengisi:**

**1. Skema session-global `session.fields` (otomatis, chat DAN vision).** Set
sekali via `setSessionFields(...)` (atau config `session.fields`). Selama session
aktif, **setiap** request (chat biasa maupun vision) meminta model mengekstrak
field ini ke `payload`; hasil non-null yang **unik** di-append (dedup).

```ts
sdk.setSessionFields({
    userId:  'nama/ID user yang dicek',
    tanggal: 'tanggal yang dicek (YYYY-MM-DD)',
    noRek:   'nomor rekening',
});
// User mengetik biasa: "cek transaksi dian tanggal 2026-04-02" → SDK menyimpan
// { userId:'dian', tanggal:'2026-04-02' }. Lain kali "cek anto 2026-05-02" →
// record kedua. Request identik TIDAK menambah duplikat (difilter).
```

**2. Vision `fields` per-call (§12)** — sekali pakai untuk satu gambar.

**3. Manual `rememberData` (WAJIB via `.session(id)`):**
```ts
await sdk.session('trx-42').rememberData({ pengirim: 'Budi', bank: 'BCA' });
const facts = await sdk.session('trx-42').sessionData();   // baca semua (kronologis)
```

**Dua bentuk di prompt.** Blok `SESSION DATA` disuntik dalam dua tampilan agar AI
bisa **merekomendasikan** pilihan yang sudah pernah diproses:
```
Nilai unik per field (yang pernah muncul):
- userId: dian, anto
- tanggal: 2026-04-02, 2026-05-02
Kombinasi (tiap baris = satu set data yang pernah diproses):
1. {"userId":"dian","tanggal":"2026-04-02"}
2. {"userId":"anto","tanggal":"2026-05-02"}
```
→ mis. AI bisa menjawab "mau cek dian (2026-04-02) atau anto (2026-05-02), atau
nama/tanggal lain?".

`rememberData()` / `sessionData()` **wajib** menargetkan session via
`.session(id)` — tanpa itu → error `session_id_required`. Data di-append **dengan
dedup** (record identik dibuang), disuntik sebagai sumber kebenaran (model diminta
tak menanyakan ulang data yang sudah ada).

> **Data, bukan pesan; bukan tool.** Session data hanya disuntik ke *prompt*
> sebagai konteks — TIDAK dilempar sebagai argumen ke tool (tool tetap menerima
> parameter dari model saat dipanggil). Retensi: belum ada TTL/expiry — bertahan
> sampai `session.enabled=false` mati atau storage di-clear (kebijakan retensi
> dibahas terpisah).

**Menghapus session.** Session bukan entitas ber-record; ia hidup lewat kunci
`sessionId` yang dipakai bersama oleh history, session data, dan pending-tool.
`clearSession(id)` menghapus **ketiganya sekaligus** (idempoten — aman walau
session kosong):
```ts
await sdk.clearSession('trx-42');   // history + session data + pending-tool → hilang
```

---

## 8. Feedback, Learning Stabil & Local Provider (FAQ Cache)

### Feedback admin + learning stabil (V7)

AI menandai tiap jawaban `cacheable` (stabil / tak berubah lintas waktu).
**Hanya `cacheable:true` yang direkam** ke learning (bukan seluruh chat).
Feedback diberikan **admin, asinkron** (bukan end-user, bukan saat request):

```ts
const items = await sdk.listHistory(20);                 // tinjau interaksi
await sdk.historyFeedback(items[0].id, 'positive');      // positive|neutral|negative
```

Interaksi default `feedback:null` sampai admin menilai. Bermakna hanya bila
`storage.enabled`.

### Local Provider = Semantic FAQ Cache (V8)

Bila `localProvider.enabled`, pipeline **local-first**: cari jawaban stabil
ber-feedback dari learning store SEBELUM memanggil provider berbayar. Hit →
jawab tanpa cost; miss → lanjut ke provider (fallback inheren).

```ts
const sdk = await AI.create({
    storage: { enabled: true },
    learning: { enabled: true, autoRecord: true },
    localProvider: { enabled: true },   // WAJIB storage.enabled
});
const res = await sdk.chat('berapa hari cuti tahunan');
res.usedLocalProvider(); // true bila dijawab dari cache
```

Seleksi kandidat: **positive** dulu (acak bila >1); **neutral** hanya bila tak
ada positive; **negative & null tidak pernah**. Threshold sengaja tinggi
(embed 0.90 / rerank 0.30) untuk menekan false-positive.

> **⚠ BATASAN KERAS Local Provider — WAJIB dipahami:**
> - **Untuk FAQ stabil, BUKAN pemahaman bahasa umum.** Kata simpel ("halo",
>   nama orang seperti "andi") memang TIDAK dikenali cache — itu benar &
>   memang tugas provider asli.
> - **Feedback-gated.** Cache KOSONG sampai admin memberi feedback
>   positive/neutral. Tanpa itu, semua chat jatuh ke provider.
> - **Risiko false-positive.** Cache-hit yang salah menjawab pertanyaan
>   berbeda dengan jawaban lama. Threshold tinggi menekannya tetapi
>   menurunkan recall (lebih sering miss). Kalibrasi `similarityThreshold`/
>   `rerankThreshold` sesuai domain; audit via `getLocalStatus()`.
> - **Butuh `storage.enabled`.** Tanpa storage, learning ephemeral → cache
>   selalu kosong (config `localProvider.enabled` tanpa storage → error).

---

## 9. Error (ADR-007)

`chat()` TIDAK melempar untuk kegagalan runtime — selalu envelope. Cek
`res.status`; detail `res.raw.error`: `{errorId, code, category, severity,
message, retryable, ...}`.

Kategori: `validation` (pesan kosong/kepanjangan), `configuration`
(provider tak terdaftar/key/storage), `provider` (401 dst), `timeout`,
`rate_limit`, `tool`, `runtime` (`capability_not_implemented`, `learning_*`,
`history_unavailable`, `tool_discovery_exceeded`), `internal`.

Yang MELEMPAR saat setup: `AI.create`/`setConfig` config invalid (mis.
`localProvider.enabled` tanpa storage, threshold di luar [0,1]), konstruktor
adapter tanpa apiKey, `registerTool` definisi invalid, `executeTools`/
`recordToLearning`/`saveToHistory` dipakai salah.

## 10. Resiliency & Observability

- **Timeout**: `timeout.providerTimeoutMs` membatalkan request provider.
- **Retry**: hanya error retryable (timeout/429/5xx), backoff eksponensial;
  `metadata.retryCount`.
- **Fallback**: provider cadangan dicoba saat utama gagal
  (provider/timeout/rate_limit); `metadata.fallbackOccurred`.
- **Cache**: panggilan identik dilayani cache; `metadata.cacheStatus: 'hit'`.
- **Local Provider**: `metadata.localProviderUsed` / `localResultStatus`
  (`hit`/`miss`/`unavailable`) untuk audit hit-rate.
- **Cost**: `usage.estimatedCost` bila `cost.pricing` diisi. FAQ-cache hit =
  tanpa cost provider.
- **Logging**: JSON 1-baris, redaction credential & konten prompt.
- **Telemetry**: ekosistem siap (no-op default) — backend menyusul.

## 11. Capability

Berfungsi penuh: `chat`, `vision` (#12). Stub (`capability_not_implemented`):
`stream`, `reason`, `embedding`, `ocr`. `listCapabilities()` menampilkan
semuanya.

---

## 12. Vision (analisis gambar)

Analisis GAMBAR oleh model multimodal. Provider yang mendukung: **OpenAI**
(diverifikasi), **Anthropic**, **Google** (kode siap). DeepSeek: dukungan
vision terbatas — pakai OpenAI/Anthropic/Google.

`sdk.vision(prompt, images, options?)` — `prompt` = pertanyaan/instruksi;
`images` = array 1..n `ImageInput` dengan **3 tipe input** (SDK menyesuaikan
format per provider otomatis); `options.fields` = skema ekstraksi terstruktur
(lihat subbagian di bawah):

| Tipe | Bentuk | Keterangan |
|---|---|---|
| `base64` | `{ type:'base64', data, mimeType }` | raw base64 ATAU data URI `data:<mime>;base64,<data>` |
| `file` | `{ type:'file', path }` | baca dari disk (dirFile); mime dari ekstensi |
| `url` | `{ type:'url', url }` | di-fetch SDK; mime dari header |

```ts
const res = await sdk.vision('Bacakan nominal & nomor referensi dari struk ini.', [
    { type: 'file', path: './bukti-transfer.jpg' },
    // { type: 'url', url: 'https://…/receipt.png' },
    // { type: 'base64', data: 'iVBOR…', mimeType: 'image/png' },
]);
console.log(res.getFinalMessage());   // JSON terstruktur / teks analisis
```

Response = `StandardResponse` biasa (JSON-structured — cocok untuk ekstraksi
field terstruktur). Vision TIDAK memakai tool discovery / knowledge / FAQ cache
(panggilan provider tunggal). Error validasi input (mime bukan gambar, file tak
ada, url gagal) → envelope error `invalid_image_input` / `vision_images_required`.

### Output terstandar via `fields` (nama field kanonik)

Model bebas menamai field hasil ekstraksi (mis. `jumlah` vs `amount` vs `total`).
Untuk **standarisasi**, berikan skema `fields = { namaKanonik: deskripsi }`. SDK
menyisipkan instruksi agar model mengisi `data.payload` dengan **nama field kamu
persis** — apa pun istilah/label pada gambar.

```ts
const res = await sdk.session('trx-42').vision(
    'Baca bukti transfer ini.',
    [{ type: 'file', path: './bukti.jpg' }],
    { fields: {
        pengirim: 'nama pengirim',
        nominal:  'nominal transfer (angka saja)',
        noRef:    'nomor referensi/jurnal',
    } },
);
res.raw.data.payload; // → { pengirim: 'Budi', nominal: '170000', noRef: 'FT2507…' }
```

**Auto-store ke session data.** Bila dipanggil dalam session (`.session(id)`)
dan `session.enabled`, `payload` vision otomatis di-append ke **SESSION DATA**
(§7a) — jadi hasil ekstraksi terbawa penuh ke request berikutnya tanpa perlu
disimpan manual.

> **⚠️ Catatan penting — keputusan & risiko ada pada aplikasi (SDK hanya alat):**
> - **Gambar BUKAN bukti final.** Gambar bisa diedit/dipalsukan. Pakai vision
>   hanya untuk MEMBACA (mis. nomor referensi/nominal); **kebenaran wajib
>   diverifikasi aplikasi** ke sistem asli (mis. lookup nomor referensi ke
>   payment gateway) — jangan percaya gambar mentah.
> - **Vision bisa salah baca** (mirip OCR). Minta model mengembalikan **field
>   terstruktur** lalu cocokkan di sisi aplikasi; jangan andalkan interpretasi
>   bebas untuk keputusan penting.

---

## 13. Multi-tenant (namespace + berbagi resource)

Pola sah untuk aplikasi multi-tenant (mis. panel banyak bot): **satu instance
SDK per tenant** (karena `setPrompt`/`setSessionFields`/`baseUrl` di level
instance), sering **berbagi satu database**. Tiga hal menopang ini:

### a. `namespace` — isolasi data antar-tenant

Set `namespace` (mis. `siteId`) di level instance. Ia di-fold ke **scope-key
SEMUA subsistem storage-backed** — memory, session, session-data, knowledge,
learning — sehingga dua tenant yang berbagi tabel **tidak saling melihat** data,
walau `userId`/`sessionId`-nya kebetulan sama.

```ts
const sdk = await AI.create({ namespace: 'site-42', storage: { enabled: true, /* … */ } });
// atau runtime: sdk.setNamespace('site-42');
```

- `null`/kosong (default) = tanpa namespace → **perilaku identik versi sebelumnya**
  (adapter memperlakukan scope-key null = tanpa filter).
- Tanpa namespace, instance membaca **semua** data (mode legacy/admin); dua
  instance ber-namespace berbeda-lah yang terisolasi satu sama lain.

### b. Model retrieval di-share otomatis

Sesi ONNX embedding/rerank di-**cache berkunci identitas model** (bukan per
instance): N instance dengan `embedding.model` sama → **satu** sesi di RAM (bukan
N × ~400 MB), dan pemuatan paralel di-dedup (anti *thundering herd*). Otomatis,
tanpa konfigurasi. `setConfig({ embedding: { model } })` saat runtime kini
**berlaku** (config dibaca per-pakai) → model baru dimuat sesuai identitas.

### c. Pool koneksi di-share otomatis

Adapter/pool storage di-**cache berkunci identitas koneksi** (`adapter |
connectionString | prefix`, di-hash). Instance dengan koneksi sama → **satu**
pool (bukan N × 10 koneksi → kehabisan `max_connections`); koneksi berbeda → pool
terpisah. Refcount: pool ditutup saat instance terakhir melepas. Untuk graceful
shutdown proses:

```ts
import { closeAllStorage } from 'enterprise-ai-sdk';
process.on('SIGTERM', async () => { await closeAllStorage(); });
```

### d. Cache response: `memory` vs `redis`

Backend cache dipilih via `cache.driver`:
- **`memory`** (default) — in-memory per-proses. Cocok **single server**. Tak
  dibagi antar-instance (aman: persona/tenant tiap instance beda).
- **`redis`** — dibagi lintas proses/server. Cocok **multi-server** (satu server
  menjawab, lainnya pakai jawaban sama). Butuh `cache.redisUrl` + optionalDependency
  `ioredis`. Key otomatis dinamespace `eai:cache:{namespace}:` → aman multi-tenant
  di satu Redis. TTL native Redis; `maxEntries` diabaikan (pakai `maxmemory-policy`).

```ts
const sdk = await AI.create({
    cache: { enabled: true, driver: 'redis', redisUrl: 'redis://localhost:6379', ttlMs: 300000 },
});
```
**Fail-open:** bila Redis tak terjangkau, cache di-bypass (get=miss, set=no-op) —
request tetap jalan. Set `namespace` berbeda per tenant agar cache tak bercampur.

> `ConfigState`, `ToolRegistry`, dan provider registry tetap **per-instance**
> (bukan di-share).

### e. Embed/rerank: `local` vs `remote` (TEI)

Model embed/rerank stateless terhadap tenant, tapi memakan RAM per proses. Untuk
fleet besar, alih dari memuat model di tiap server ke **layanan bersama**:
- **`local`** (default) — ONNX in-process (via ExtractorRegistry, 1 sesi/proses).
- **`remote`** — HTTP ke **TEI (Hugging Face Text-Embeddings-Inference)**. App
  server **tak memuat model** (hemat RAM); model hidup di TEI (bisa GPU). SDK ini
  **hanya** mendukung TEI sebagai backend remote — pasang TEI sendiri (baca
  dokumentasi TEI). Satu TEI = satu model, jadi embed & rerank pakai URL terpisah.

```ts
const sdk = await AI.create({
    embedding: { driver: 'remote', remoteUrl: 'http://tei-embed:8080', model: 'intfloat/multilingual-e5-small' },
    rerank:    { driver: 'remote', remoteUrl: 'http://tei-rerank:8081', model: 'BAAI/bge-reranker-base' },
});
```
**Fail-soft:** bila TEI tak terjangkau → retrieval kosong (knowledge/FAQ tak
disuntik), **chat tetap jalan** via provider utama. `remoteApiKey` → header
`Authorization: Bearer`.

**Multi-model.** SDK memetakan konvensi tiap model (ModelSpec): E5 diberi prefix
`query:`/`passage:`, BGE/GTE tanpa prefix, Nomic `search_query:`; dan `threshold`
`null` = **auto** (rekomendasi per model). Jadi ganti model = ganti `model`
(+ TEI yang melayaninya) — prefix & ambang menyesuaikan otomatis, local atau
remote. Catatan: pindah backend/model = **re-embed korpus** (embModel berubah →
backfill otomatis saat diakses).
