# Operator API Reference

Dokumentasi ini untuk backend operator di project ini. Script automation memakai private API key, sedangkan panel browser memakai admin session.

Dokumentasi interaktif tersedia di `/docs`. Public customer API `/v1` didokumentasikan terpisah di `PUBLIC_API_DOCUMENTATION.md`.

## Navigasi

- [Auth admin](#auth)
- [Upload reference](#upload-reference-image)
- [Generate image](#generate-image)
- [Generate video](#generate-video---seedance-20-fast)
- [Account pool](#list-accounts)
- [Check balance](#check-balance)
- [Retry dan idempotency](#important-retry-rule)

## Base URL

Local:

```text
http://localhost:3000
```

Deployment:

```text
https://admin.seedance2unlimited.space
```

Admin console:

```text
https://admin.seedance2unlimited.space/admin
```

Root `/` adalah halaman produk, dokumentasi interaktif ada di `/docs`, dan panel operator ada di `/admin`.

## Auth

Untuk script atau server-to-server automation, kirim header ini di request `/api/*`:

```http
Authorization: Bearer <MY_PRIVATE_API_KEY>
```

Untuk JSON request:

```http
Content-Type: application/json
```

Panel `/admin` tidak meminta private key. First-time setup membuat satu admin dengan email dan password scrypt. Setelah login, browser memakai cookie session `HttpOnly`, `SameSite=Strict`, `Secure` pada HTTPS, serta `X-CSRF-Token` untuk mutasi.

Endpoint auth panel:

```text
GET  /api/admin-auth/status
POST /api/admin-auth/setup/start
POST /api/admin-auth/login
GET  /api/admin-auth/session
POST /api/admin-auth/logout
```

Tidak ada allowlist IP untuk login admin. Percobaan login yang gagal tetap dibatasi per email dan sumber request.

## Leonardo Bearer Cache

Backend tidak mengambil bearer baru di setiap request. Bearer Leonardo disimpan di memory cache per akun.

```text
Normal behavior      = cache sampai JWT exp mendekat
Safety buffer        = BEARER_CACHE_SAFETY_SECONDS, default 120 detik
Fallback TTL         = TOKEN_CACHE_TTL_MINUTES, default 10 menit, hanya jika exp JWT tidak terbaca
Force refresh        = POST /api/accounts/:accountId/refresh
```

Kalau bearer masih valid dan belum mendekati expiry, generate/status/result akan memakai bearer yang sama.

## Upload Reference Image

```http
POST /api/leonardo/uploads/init-image
```

Endpoint ini dipakai untuk file desktop atau image URL sebelum gambar dipakai sebagai reference. Backend akan:

```text
1. minta upload target ke Leonardo lewat mutation UploadImage uploadType INIT
2. upload binary image ke storage URL dari Leonardo
3. polling GetInitImageModeration
4. mengembalikan initImageId/imageId
```

Body dari file desktop bisa dikirim sebagai `dataUrl` atau `imageBase64`:

```json
{
  "accountId": "account@example.com",
  "fileName": "reference.png",
  "mimeType": "image/png",
  "dataUrl": "data:image/png;base64,...",
  "waitForModeration": true
}
```

Body dari link gambar:

```json
{
  "accountId": "account@example.com",
  "imageUrl": "https://example.com/reference.png",
  "waitForModeration": true
}
```

Response:

```json
{
  "ok": true,
  "accountId": "account@example.com",
  "uploadId": "upload-ak-uuid",
  "initImageId": "uploaded-image-id",
  "imageId": "uploaded-image-id",
  "type": "UPLOADED",
  "status": "COMPLETE",
  "width": 1024,
  "height": 1024
}
```

Pakai `imageId`/`initImageId` sebagai `imageReferenceIds` di generate GPT Image 2 atau Seedance. Untuk reference dari upload, sebaiknya generate memakai `accountId` yang sama; panel admin otomatis mengunci auto-rotate saat reference dipakai.

## Generate Image

```http
POST /api/leonardo/gpt-image-2/generate
```

Gunakan `Idempotency-Key` supaya retry atau double-click tidak membuat generate dobel.

```http
Idempotency-Key: unique-request-id
```

Body:

```json
{
  "clientRequestId": "unique-request-id",
  "prompt": "sawah pemandangan",
  "quality": "LOW",
  "dimensionPreset": "16:9_SMALL",
  "imageReferenceIds": ["uploaded-image-id"],
  "imageReferenceStrength": "MID",
  "autoRotate": true,
  "checkBalance": true,
  "waitForResult": false
}
```

Supported `quality`:

```text
LOW
MEDIUM
```

Supported `dimensionPreset`:

```text
16:9_SMALL  = 1376x768
2:3_SMALL   = 848x1264
1:1_SMALL   = 1024x1024
9:16_SMALL  = 768x1376
```

Current token cost:

```text
LOW    all supported SMALL sizes = 8
MEDIUM all supported SMALL sizes = 66
```

Opsional image reference:

```json
{
  "imageReferenceIds": "uploaded-image-id-1, uploaded-image-id-2",
  "imageReferenceStrength": "MID"
}
```

Atau kirim bentuk langsung:

```json
{
  "imageReferences": [
    {
      "image": {
        "id": "uploaded-image-id-1",
        "type": "UPLOADED"
      },
      "strength": "MID"
    }
  ]
}
```

`imageReferenceIds` bisa berasal dari endpoint upload reference image. Maksimal 4 reference image. Jika reference dipakai dengan `accountId`, backend tidak akan rotate ke akun lain.
Untuk GPT Image 2 dengan image reference, backend otomatis memakai `promptEnhance: "OFF"` karena payload Leonardo yang valid memakai `prompt_enhance: "OFF"`.

Recommended:

```json
{
  "autoRotate": true,
  "checkBalance": true,
  "waitForResult": false
}
```

`waitForResult: false` lebih aman untuk tunnel/API karena response cepat. Ambil hasil pakai endpoint status/result.
Jika `waitForResult: true`, backend akan polling result setiap 500ms dan baru selesai saat `imageUrl` muncul atau timeout.

Untuk trafik banyak/multi-call, gunakan `Idempotency-Key` per request unik dan tetap pakai `waitForResult: false`. Backend akan menyimpan `jobId`, menulis file job secara atomik, dan mengantre generate/upload per akun Leonardo supaya request paralel tidak memakai akun yang sama pada saat bersamaan.

Balance guard multi-call:

```text
real balance       = subscriptionTokens dari Leonardo
reserved balance   = estimasi token generate yang sedang/baru diterima backend
available balance  = real balance - reserved balance
```

Sebelum generate, backend membuat local reservation sesuai estimasi token. Jika generate gagal sebelum diterima Leonardo, reservation langsung dilepas. Jika generate diterima dan Leonardo mengembalikan `generationId`, reservation ditahan sementara selama `CREDIT_RESERVATION_COMMITTED_TTL_SECONDS` supaya request berikutnya tidak memakai saldo Leonardo yang mungkin belum tersinkron.

Balance check wajib untuk semua generate. Field `checkBalance` masih diterima untuk kompatibilitas request lama, tetapi backend tetap akan menghitung estimasi token dan reserve saldo lokal sebelum memanggil Leonardo.
Jika akun kandidat tidak punya `availableSubscriptionTokens` yang cukup, akun itu di-skip tanpa cooldown/error akun dan backend lanjut ke akun berikutnya. Leonardo `Generate` hanya dipanggil setelah akun tersebut lolos estimasi token dan local reservation. Error `no_account_with_sufficient_balance` baru dikembalikan kalau semua akun kandidat tidak cukup.

Strategi rotasi GPT Image 2: untuk generate image tanpa uploaded reference yang mengunci akun, backend memprioritaskan akun dengan `availableSubscriptionTokens` paling kecil yang masih cukup untuk estimasi token request. Tujuannya agar sisa kredit kecil tetap terpakai untuk image dan akun berbalance besar bisa disimpan untuk video.

Response:

```json
{
  "ok": true,
  "accountId": "account@example.com",
  "generationId": "generation-id",
  "status": "PENDING",
  "apiCreditCost": null,
  "triedAccountIds": ["account@example.com"],
  "jobId": "job-id",
  "clientRequestId": "unique-request-id",
  "idempotentReplay": false
}
```

Jika retry dengan `Idempotency-Key` yang sama, response akan mengarah ke job/generation yang sama.

## Get Status

```http
GET /api/leonardo/gpt-image-2/status/:generationId?accountId=:accountId
```

Response:

```json
{
  "ok": true,
  "generationId": "generation-id",
  "status": "COMPLETE",
  "rawStatus": "COMPLETE",
  "imageCount": 1
}
```

## Get Result

```http
GET /api/leonardo/gpt-image-2/result/:generationId?accountId=:accountId
```

Response:

```json
{
  "ok": true,
  "generationId": "generation-id",
  "status": "COMPLETE",
  "model": "gpt-image-2",
  "imageUrl": "https://cdn.leonardo.ai/...",
  "imageId": "image-id",
  "width": 1376,
  "height": 768
}
```

## Get Job

```http
GET /api/leonardo/gpt-image-2/jobs/:jobId
```

Response:

```json
{
  "ok": true,
  "job": {
    "id": "job-id",
    "clientRequestId": "unique-request-id",
    "status": "PENDING",
    "accountId": "account@example.com",
    "generationId": "generation-id",
    "apiCreditCost": null,
    "error": null
  }
}
```

## Get Job Status

```http
GET /api/leonardo/gpt-image-2/jobs/:jobId/status
```

Pakai endpoint ini jika kamu memakai response `jobId` dari generate. Client tidak perlu kirim `accountId`.

## Get Job Result

```http
GET /api/leonardo/gpt-image-2/jobs/:jobId/result
```

Response sama seperti Get Result, tetapi otomatis memakai `accountId` dan `generationId` yang tersimpan di job.

## Generate Video - Seedance 2.0 Fast

```http
POST /api/leonardo/seedance-2-fast/generate
```

Gunakan `Idempotency-Key` sama seperti image supaya retry tidak membuat video dobel.

Body:

```json
{
  "clientRequestId": "unique-request-id",
  "prompt": "cinematic rice field flyover",
  "dimensionPreset": "16:9_SMALL",
  "duration": 15,
  "motionHasAudio": true,
  "seed": -1,
  "imageReferenceIds": ["uploaded-image-id"],
  "imageReferenceStrength": "MID",
  "autoRotate": true,
  "checkBalance": true,
  "waitForResult": false
}
```

Opsional reference image Seedance:

```json
{
  "imageReferenceIds": "uploaded-image-id-1, uploaded-image-id-2",
  "imageReferenceStrength": "MID"
}
```

Atau kirim bentuk langsung:

```json
{
  "imageReferences": [
    {
      "image": {
        "id": "uploaded-image-id-1",
        "type": "UPLOADED"
      },
      "strength": "MID"
    }
  ]
}
```

`imageReferenceIds` boleh berupa string dipisah baris/koma/titik koma/spasi atau array string. `imageReferenceStrength` menerima `LOW`, `MID`, atau `HIGH` dan default ke `MID`. Maksimal 4 reference image.

Aturan Seedance:

```text
model              = seedance-2.0-fast
max prompt         = 5000 characters
duration           = 15
quantity           = 1
default seed       = -1
default timeout    = 1200 seconds
image references   = optional, up to 4 uploaded image IDs
reference strength = LOW, MID, or HIGH
```

Untuk trafik banyak/multi-call Seedance juga sebaiknya `waitForResult: false`, lalu polling lewat endpoint job status/result. Generate dan upload reference akan diantre per akun Leonardo sesuai `MAX_CONCURRENT_LEONARDO_PER_ACCOUNT`.

Supported `dimensionPreset` dan token cost:

```text
16:9_STANDARD = 864x496    qty 1 = 1687
1:1_STANDARD  = 640x640    qty 1 = 1687
9:16_STANDARD = 496x864    qty 1 = 1687
16:9_SMALL    = 1280x720   qty 1 = 3628
1:1_SMALL     = 960x960    qty 1 = 3628
9:16_SMALL    = 720x1280   qty 1 = 3628
```

Response generate:

```json
{
  "ok": true,
  "model": "seedance-2.0-fast",
  "accountId": "account@example.com",
  "generationId": "generation-id",
  "status": "PENDING",
  "apiCreditCost": null,
  "triedAccountIds": ["account@example.com"],
  "jobId": "job-id",
  "clientRequestId": "unique-request-id",
  "idempotentReplay": false
}
```

## Get Seedance Job Status

```http
GET /api/leonardo/seedance-2-fast/jobs/:jobId/status
```

Response:

```json
{
  "ok": true,
  "generationId": "generation-id",
  "status": "COMPLETE",
  "rawStatus": "COMPLETE",
  "videoCount": 1
}
```

## Get Seedance Job Result

```http
GET /api/leonardo/seedance-2-fast/jobs/:jobId/result
```

Response:

```json
{
  "ok": true,
  "generationId": "generation-id",
  "status": "COMPLETE",
  "model": "seedance-2.0-fast",
  "outputType": "video",
  "videoUrl": "https://cdn.leonardo.ai/...",
  "gifUrl": "https://cdn.leonardo.ai/...",
  "previewUrl": "https://cdn.leonardo.ai/...",
  "width": 1280,
  "height": 720,
  "duration": 15
}
```

PowerShell generate + wait:

```powershell
$baseUrl = "https://admin.seedance2unlimited.space"
$apiKey = "<MY_PRIVATE_API_KEY>"
$requestId = [guid]::NewGuid().ToString()

$body = @{
  clientRequestId = $requestId
  prompt = "cinematic rice field flyover"
  dimensionPreset = "16:9_SMALL"
  duration = 15
  motionHasAudio = $true
  seed = -1
  autoRotate = $true
  checkBalance = $true
  waitForResult = $false
} | ConvertTo-Json

$generate = Invoke-RestMethod `
  -Method POST `
  -Uri "$baseUrl/api/leonardo/seedance-2-fast/generate" `
  -Headers @{
    Authorization = "Bearer $apiKey"
    "Idempotency-Key" = $requestId
  } `
  -ContentType "application/json" `
  -Body $body

$jobId = $generate.jobId
$deadline = (Get-Date).AddSeconds(1200)

while ((Get-Date) -lt $deadline) {
  try {
    $result = Invoke-RestMethod `
      -Method GET `
      -Uri "$baseUrl/api/leonardo/seedance-2-fast/jobs/$jobId/result" `
      -Headers @{ Authorization = "Bearer $apiKey" }

    if ($result.videoUrl) {
      $result
      break
    }
  } catch {}

  Start-Sleep -Milliseconds 500
}
```

## List Accounts

```http
GET /api/accounts
```

Response:

```json
{
  "ok": true,
  "accounts": []
}
```

## Check Balance

```http
GET /api/accounts/:accountId/balance
```

Response:

```json
{
  "ok": true,
  "accountId": "account@example.com",
  "subscriptionTokens": 18,
  "reservedSubscriptionTokens": 8,
  "availableSubscriptionTokens": 10,
  "reservations": [
    {
      "id": "reservation-id",
      "tokens": 8,
      "status": "committed",
      "generationId": "generation-id",
      "expiresAt": "2026-06-30T05:30:00.000Z"
    }
  ]
}
```

`subscriptionTokens` adalah angka mentah dari Leonardo. `availableSubscriptionTokens` adalah angka yang dipakai backend untuk generate berikutnya setelah dikurangi reservation lokal.

## Import Account Legacy

```http
POST /api/accounts/import
```

Endpoint ini dipertahankan untuk kompatibilitas panel/admin lama. Untuk import lewat API baru, gunakan `POST /api/accounts/import-cookie`.

Body legacy bisa berisi full JSON dari browser `get-session`:

```json
{
  "cookie": "{ full get-session JSON response here }"
}
```

Response:

```json
{
  "ok": true,
  "accountId": "account@example.com",
  "message": "Account imported from browser session JSON."
}
```

## Import Cookie Via API

```http
POST /api/accounts/import-cookie
```

Endpoint ini khusus untuk import akun dari cookie dan langsung validasi ke Leonardo `get-session`.
Account ID otomatis diambil dari email hasil `get-session`.

Format yang didukung:

```json
{
  "cookie": "__Secure-better-auth.session_token=...; CF_Access_Token=...; anonymous-id=..."
}
```

Cookie Editor JSON:

```json
{
  "cookies": [
    {
      "name": "__Secure-better-auth.session_token",
      "value": "..."
    },
    {
      "name": "CF_Access_Token",
      "value": "..."
    }
  ]
}
```

Raw `text/plain` cookie string juga bisa:

```text
__Secure-better-auth.session_token=...; CF_Access_Token=...; anonymous-id=...
```

Tidak didukung di endpoint ini:

```text
get-session JSON response
Copy as fetch
PowerShell Invoke-WebRequest dump
```

Response:

```json
{
  "ok": true,
  "accountId": "account@example.com",
  "message": "Account imported from cookie.",
  "session": {
    "checked": true,
    "source": "cookie",
    "hasBearer": true,
    "email": "account@example.com",
    "hasUserSub": true
  }
}
```

PowerShell:

```powershell
$baseUrl = "https://admin.seedance2unlimited.space"
$apiKey = "<MY_PRIVATE_API_KEY>"
$cookie = "__Secure-better-auth.session_token=...; CF_Access_Token=..."

Invoke-RestMethod `
  -Method POST `
  -Uri "$baseUrl/api/accounts/import-cookie" `
  -Headers @{ Authorization = "Bearer $apiKey" } `
  -ContentType "application/json" `
  -Body (@{ cookie = $cookie } | ConvertTo-Json -Depth 5)
```

## PowerShell Example

```powershell
$baseUrl = "https://admin.seedance2unlimited.space"
$apiKey = "<MY_PRIVATE_API_KEY>"
$requestId = [guid]::NewGuid().ToString()

$body = @{
  clientRequestId = $requestId
  prompt = "sawah pemandangan indah"
  quality = "LOW"
  dimensionPreset = "16:9_SMALL"
  autoRotate = $true
  checkBalance = $true
  waitForResult = $false
} | ConvertTo-Json

$generate = Invoke-RestMethod `
  -Method POST `
  -Uri "$baseUrl/api/leonardo/gpt-image-2/generate" `
  -Headers @{
    Authorization = "Bearer $apiKey"
    "Idempotency-Key" = $requestId
  } `
  -ContentType "application/json" `
  -Body $body

$generate
```

Polling result sekali saja dengan `jobId`:

```powershell
$jobId = $generate.jobId

Invoke-RestMethod `
  -Method GET `
  -Uri "$baseUrl/api/leonardo/gpt-image-2/jobs/$jobId/result" `
  -Headers @{ Authorization = "Bearer $apiKey" }
```

Full generate + wait sampai `imageUrl` keluar. Polling result dilakukan setiap 500ms:

```powershell
$baseUrl = "https://admin.seedance2unlimited.space"
$apiKey = "<MY_PRIVATE_API_KEY>"
$requestId = [guid]::NewGuid().ToString()

$body = @{
  clientRequestId = $requestId
  prompt = "sawah pemandangan indah"
  quality = "LOW"
  dimensionPreset = "16:9_SMALL"
  autoRotate = $true
  checkBalance = $true
  waitForResult = $false
} | ConvertTo-Json

$generate = Invoke-RestMethod `
  -Method POST `
  -Uri "$baseUrl/api/leonardo/gpt-image-2/generate" `
  -Headers @{
    Authorization = "Bearer $apiKey"
    "Idempotency-Key" = $requestId
  } `
  -ContentType "application/json" `
  -Body $body

$generationId = $generate.generationId
$jobId = $generate.jobId
$deadline = (Get-Date).AddSeconds(240)
$pollMilliseconds = 500

Write-Host "Generation started: $generationId"
Write-Host "Job: $jobId"

while ((Get-Date) -lt $deadline) {
  try {
    $result = Invoke-RestMethod `
      -Method GET `
      -Uri "$baseUrl/api/leonardo/gpt-image-2/jobs/$jobId/result" `
      -Headers @{ Authorization = "Bearer $apiKey" }

    if ($result.imageUrl) {
      $result
      break
    }
  } catch {}

  $status = Invoke-RestMethod `
    -Method GET `
    -Uri "$baseUrl/api/leonardo/gpt-image-2/jobs/$jobId/status" `
    -Headers @{ Authorization = "Bearer $apiKey" }

  Write-Host "Status: $($status.status)"

  Start-Sleep -Milliseconds $pollMilliseconds
}
```

Script siap pakai juga tersedia di:

```text
examples/generate-and-wait.ps1
```

## cURL Example

```bash
BASE_URL="https://admin.seedance2unlimited.space"
API_KEY="<MY_PRIVATE_API_KEY>"
REQ_ID="$(date +%s)-test-1"

curl -sS -X POST "$BASE_URL/api/leonardo/gpt-image-2/generate" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $REQ_ID" \
  -d "{
    \"clientRequestId\": \"$REQ_ID\",
    \"prompt\": \"sawah pemandangan indah\",
    \"quality\": \"LOW\",
    \"dimensionPreset\": \"16:9_SMALL\",
    \"autoRotate\": true,
    \"checkBalance\": true,
    \"waitForResult\": false
  }"
```

## Important Retry Rule

Jika request generate timeout, 502, atau response hilang:

```text
Retry dengan Idempotency-Key yang sama.
```

Jangan ganti `Idempotency-Key` kecuali memang ingin membuat generate baru.

## Common Errors

```json
{
  "ok": false,
  "error": {
    "code": "account_balance_low",
    "message": "Account '...' has insufficient subscriptionTokens (2/8)."
  }
}
```

```json
{
  "ok": false,
  "error": {
    "code": "token_cost_not_configured",
    "message": "Token cost is not configured for HIGH 16:9 Small."
  }
}
```

```json
{
  "ok": false,
  "error": {
    "code": "idempotency_key_conflict",
    "message": "Idempotency-Key already exists for a different generate request."
  }
}
```
