bantaiqoder
v0.1.2
Published
Self-hosted AI gateway exposing OpenAI and Anthropic endpoints over a Qoder upstream
Maintainers
Readme
bantaiqoder
Self-hosted AI gateway di atas upstream Qoder. Satu binary Rust yang menyajikan endpoint OpenAI dan Anthropic sekaligus, jadi SDK mana pun bisa menembak gateway ini tanpa perubahan kode. Web console-nya ikut di dalam binary yang sama — tidak ada proses kedua yang perlu dijalankan.
Console
Buka http://localhost:8787/ setelah gateway jalan.
| Route | Isi |
|---|---|
| / | Status, chart usage 24 jam, request terakhir |
| /chat | Chat streaming, pemilih model, system prompt, parameter |
| /images | Image generation |
| /playground | Kirim JSON mentah ke endpoint mana pun, lihat SSE apa adanya |
| /requests | Penelusuran request log dengan filter |
| /console/keys | CRUD proxy key |
| /console/models | Katalog model + alias |
| /console/pats | Kesehatan kredensial upstream + probe |
| /console/settings | Konfigurasi efektif (read-only) |
/playground tidak ada di console referensi. Awalnya dibuat karena format wire
Qoder belum terverifikasi; sekarang protokol nativenya sudah jalan dan teruji
lawan Qoder hidup, tapi halaman itu tetap berguna: menampilkan request yang
keluar dan balasan mentah yang masuk, tanpa curl manual.
Build console-nya dari web/:
cd web && npm install && npm run buildTanpa itu, cargo build tetap jalan: build.rs memasang placeholder yang
memberitahu console belum dibuild, dan API-nya tidak terpengaruh sama sekali.
Endpoint
| Method | Path | Keterangan |
|---|---|---|
| GET | /v1/models | Daftar model format OpenAI |
| POST | /v1/chat/completions | OpenAI chat, dengan SSE streaming |
| POST | /v1/messages | Anthropic Messages, dengan SSE streaming |
| POST | /v1/messages/count_tokens | Estimasi token Anthropic, dihitung lokal |
| POST | /v1/images/generations | Image generation format OpenAI |
| GET | /health | Status, uptime, jumlah kredensial sehat |
Tambahan di luar spesifikasi awal: GET /v1/models/{id} untuk satu model, dan
surface admin (/admin/login, /admin/logout, /admin/session,
/admin/settings, /admin/pats, /admin/model-aliases).
Manajemen proxy key:
| Method | Path | Keterangan |
|---|---|---|
| GET | /admin/keys | Daftar key, env dan tersimpan (dengan rpm_limit, key_suffix, can_reveal) |
| POST | /admin/keys | Buat key baru ({"name","rpm_limit"}), plaintext dibalas sekali |
| PATCH | /admin/keys/{id} | Ubah nama dan/atau rpm_limit (patch parsial) |
| GET | /admin/keys/{id}/secret | Baca plaintext untuk reveal/copy (key baru saja) |
| POST | /admin/keys/{id}/revoke | Cabut |
| POST | /admin/keys/{id}/unrevoke | Pulihkan |
| DELETE | /admin/keys/{id} | Hapus permanen |
Usage, request log, dan probe kredensial:
| Method | Path | Keterangan |
|---|---|---|
| GET | /admin/usage | Total + rincian per model dan per endpoint (?range=24h, default semua waktu) |
| GET | /admin/usage/series | Deret berbucket untuk chart (?range=24h&bucket=1h) |
| GET | /admin/logs | Request log (?limit&offset&status&model) |
| POST | /admin/logs/clear | Kosongkan log |
| POST | /admin/pats/{index}/probe | Uji satu PAT ke upstream |
| GET | /admin/quota | Saldo kredit per kredensial (protokol native) |
| GET | /admin/models/live | Katalog model dari upstream (protokol native) |
Kredensial upstream dikelola dari console:
| Method | Path | Keterangan |
|---|---|---|
| POST | /admin/pats | Simpan kredensial baru ({"pat","label"}) |
| POST | /admin/pats/bulk | Simpan banyak sekaligus ({"pats":[{"label","pat"}]}) |
| PATCH | /admin/pats/{id} | Ganti nama |
| DELETE | /admin/pats/{id} | Hapus permanen |
| POST | /admin/pats/{id}/enable | Masukkan ke rantai fallback |
| POST | /admin/pats/{id}/disable | Keluarkan dari rantai fallback |
| POST | /admin/pats/{id}/refresh-quota | Baca ulang saldo, lewati cache 5 menit |
Mutasi pakai id database, bukan posisi pool: menambah atau menghapus satu
kredensial menggeser nomor semua yang setelahnya, jadi posisi bukan nama yang
stabil. /{index}/probe tetap pakai posisi — route itu memang selalu begitu, dan
probe memang soal "kredensial yang sekarang ada di slot N".
/admin/pats/bulk menyimpan banyak sekaligus dalam satu transaksi dan satu
reload pool. Validasi per-item, jadi satu baris rusak tidak menjatuhkan yang
lain: balasannya berisi agregat (total, added, skipped, failed) plus
hasil per input yang selaras urutan — status added, duplicate, atau
invalid. Label kosong dinamai otomatis per posisi batch (Qoder PAT 1, dst.);
duplikat (terhadap database maupun sesama batch) dilewati, bukan ditulis ulang.
Sama seperti route tunggal, balasan tidak pernah memantulkan raw token — hanya
fingerprint bertopeng.
enabled beda dari bench. Bench itu reaksi gateway sendiri terhadap penolakan
dan hilang sendiri; enabled keputusan operator dan bertahan sampai diubah lagi.
/admin/quota dan /admin/models/live cuma berarti di protokol native. Yang
kedua boleh menembak jaringan, sementara GET /v1/models yang tanpa kredensial
hanya membaca cache — kalau tidak, caller tanpa key bisa memicu satu exchange
plus satu fetch bertanda tangan per request.
Yang tetap read-only: /admin/settings dan /admin/model-aliases. Bikin
keduanya editable menimbulkan ambiguitas presedensi env-vs-DB, dan itu tidak
sebanding manfaatnya sekarang — ini pemotongan yang sengaja.
Autentikasi
Authorization: Bearer <proxy key> atau x-api-key: <proxy key> — Anthropic
SDK memakai bentuk kedua. Session token dashboard juga diterima di endpoint API.
Kalau PROXY_API_KEYS kosong, ADMIN_PASSWORD kosong, dan tidak ada key
tersimpan di database, gateway jalan sebagai open gate: semua request
diterima. Mode ini menolak bind ke alamat selain loopback, jadi tidak bisa
kebuka ke jaringan tanpa sengaja.
Surface /admin hanya menerima session token, bukan proxy key. Proxy key
dibagikan ke client dan tidak boleh bisa membaca konfigurasi.
Dua jenis proxy key
PROXY_API_KEYS adalah bootstrap key: selalu diterima, tidak bisa dicabut
dari console, ditandai source: "env" di daftar. Jadi operator yang terkunci
dari dashboard tetap punya jalan masuk.
Key yang dibuat lewat /admin/keys menyimpan hash (untuk auth lookup),
prefix + suffix (untuk tampilan bertopeng prefix••••suffix), dan sekarang
juga plaintext yang recoverable (untuk reveal). Karena key-nya random 244
bit, SHA-256 sudah cukup untuk sisi pengenalan: argon2 ada untuk memperlambat
penebakan password pilihan manusia, dan di sini tidak ada yang perlu diperlambat.
Pencabutan langsung berlaku di request berikutnya, tidak perlu restart. Auth-nya tetap lookup map di memori (dicocokkan lewat hash), jadi tidak ada round trip ke disk di jalur panas.
Reveal butuh plaintext tersimpan
Konsekuensi menyimpan plaintext recoverable: file SQLite berisi proxy key yang
baru dalam bentuk terbaca, sama seperti PAT upstream di bawah. Perlakukan
bantaiqoder.db (plus sidecar -wal/-shm), backup, dan snapshot disk sebagai
rahasia — siapa pun yang bisa membaca file itu bisa memakai key-nya.
Key lama dari sebelum migration ini cuma menyimpan hash, jadi plaintext-nya
tidak bisa dipulihkan: barisnya ditandai can_reveal: false dan rpm_limit: null
(unlimited), dan console menampilkan "Rotate to enable reveal" — cabut lalu buat
key baru untuk mengaktifkan reveal. GET /admin/keys tidak pernah mengirim
plaintext; console mengambilnya on-demand lewat /admin/keys/{id}/secret saat
tombol Eye atau Copy ditekan, dan nilai itu hanya hidup di state halaman.
Rate limit per key
Setiap stored key baru wajib punya RPM: form create diprefill 60, dan
backend juga memakai 60 kalau field rpm_limit tidak dikirim. Nilai harus
integer positif; nol dan negatif ditolak. Unlimited (rpm_limit: null) disediakan
hanya untuk key lama hasil migration — tidak ada nilai yang bisa dikirim
client untuk meminta key unlimited baru. PROXY_API_KEYS tetap unlimited karena
tidak punya baris database untuk menyimpan limit.
Semantiknya: maksimum N request dalam rolling window 60 detik, gabungan per
key untuk endpoint inference (/v1/chat/completions, /v1/messages,
/v1/images/generations) — bukan endpoint admin, health, atau models. Satu
attempt yang lolos auth memakai satu slot; respons 429 tidak memakai slot
tambahan. Streaming dihitung sekali saat request diterima, bukan per chunk. 429
lokal memakai shape error yang sama seperti 429 upstream (rate_limit_error) plus
header Retry-After dan x-ratelimit-limit/x-ratelimit-remaining: 0.
Ubah RPM lewat PATCH /admin/keys/{id} berlaku di request berikutnya: menaikkan
langsung memberi ruang, menurunkan memblokir sampai jumlah request 60 detik
terakhir turun di bawah limit baru (window tidak di-reset).
Counter disimpan in-memory dan per proses: reset saat restart, dan berlaku per instance. Deployment multi-replica membutuhkan limiter bersama untuk RPM global yang strict — itu sengaja di luar scope sekarang.
Membuat, mencabut, dan menghapus key wajib session dashboard, jadi
ADMIN_PASSWORD harus diset. Ini bukan kerewelan: kalau key bisa dibuat lewat
open gate, gerbangnya menutup di start berikutnya dan tanpa ADMIN_PASSWORD
tidak ada lagi cara sign in untuk mengelola key yang baru dibuat itu. Console
akan mengunci dirinya dari key-nya sendiri.
Kredensial upstream disimpan plaintext
Ini kebalikan dari proxy key di atas, dan sengaja: file SQLite berisi PAT dalam bentuk terbaca. Tidak ada jalan lain. Proxy key cuma perlu dikenali, jadi menyimpan hash-nya cukup; PAT harus diserahkan ke Qoder di setiap request yang ditandatangani, dan hash tidak bisa diserahkan.
Konsekuensinya:
- Perlakukan
bantaiqoder.db(plus sidecar-wal/-shm) sebagai file rahasia. Sudah masuk.gitignore, tapi backup, volume Docker, dan snapshot disk juga ikut membawanya. - API tidak pernah mengembalikan tokennya —
/admin/patscuma memberi fingerprint yang di-mask. Yang dilindungi di sini adalah surface HTTP-nya, bukan file-nya. ConfigdanUpstreamsengaja tidak#[derive(Debug)]supaya{:?}yang tidak sengaja tidak membocorkan kredensial ke log.
QODER_PATS sekarang bootstrap sekali jalan, bukan sumber kebenaran. Di start
pertama isinya dikopi ke database, lalu satu baris di tabel meta menandai import
itu sudah terjadi dan tidak akan pernah jalan lagi. Alasannya: tanpa penanda itu,
menghapus kredensial terakhir dari console akan menghidupkan ulang salinan dari
environment di restart berikutnya, dan penghapusannya akan tampak gagal. Setelah
import, database yang menentukan — termasuk kalau environment masih mencantumkan
token yang sudah dihapus.
Jumlah key tersimpan yang dihitung untuk keputusan gate termasuk yang sudah dicabut. Database yang tinggal berisi key tercabut semua harus gagal-tertutup, bukan balik jadi menerima semua request.
Claude Code
Claude Code bicara Anthropic Messages API, jadi arahkan saja base URL-nya ke
gateway ini lewat ~/.claude/settings.json:
{
"hasCompletedOnboarding": true,
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787",
"ANTHROPIC_AUTH_TOKEN": "<proxy-key>",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "ultimate",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "performance",
"ANTHROPIC_DEFAULT_FABLE_MODEL": "performance",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "efficient"
}
}ANTHROPIC_AUTH_TOKEN menghasilkan Authorization: Bearer; kalau kamu pakai
ANTHROPIC_API_KEY, gateway juga menerima x-api-key. Base URL tanpa suffix
/v1 — gateway yang menambahkannya.
Yang penting: jangan mengandalkan wire model ID claude-*. Qoder tidak
mengenalnya. Sebagai gantinya petakan tiap slot Claude Code ke tier Qoder lewat
ANTHROPIC_DEFAULT_*_MODEL di atas, lalu pilih slot dari CLI:
claude --model sonnetSlot sonnet di sini menghasilkan wire model performance karena setting client
di atas. Mapping slot→tier ini pilihan routing, bukan klaim bahwa tier Qoder
setara model Claude:
| Slot Claude Code | Tier Qoder |
|---|---|
| Opus | ultimate |
| Sonnet | performance |
| Fable | performance |
| Haiku | efficient |
Kalau client tetap mengirim ID claude-*
Untuk tooling yang hard-code ID Claude, pakai MODEL_ALIASES yang exact:
MODEL_ALIASES={"claude-opus-5":"ultimate","claude-sonnet-5":"performance","claude-fable-5":"performance","claude-haiku-4-5-20251001":"efficient"}Alias-nya exact dan case-sensitive — tidak ada wildcard. Daftar model Claude
berubah dari waktu ke waktu, jadi alias ini akan usang dan harus diperbarui
operator sendiri. Slot mapping di sisi client lebih disarankan.
ALLOW_UNKNOWN_MODELS=true bukan pengganti alias: Qoder tetap tidak mengenal
wire model claude-*, jadi request-nya tetap gagal di upstream.
Estimasi token
Claude Code memakai POST /v1/messages/count_tokens untuk context accounting dan
auto-compaction. Gateway menjawabnya lokal: tidak menembak Qoder, tidak
menyentuh kredensial, tidak menghabiskan kuota, dan tidak dicatat sebagai usage.
Estimasinya ceil(karakter / 4) atas system prompt, tools, dan isi pesan — ini
perkiraan, bukan tokenizer Anthropic, jadi jangan dipakai sebagai angka
billing.
Sebagian versi Claude Code menambahkan /v1/messages ke base URL yang sudah
berakhiran /v1, menghasilkan /v1/v1/messages. Gateway menerima bentuk ganda
itu sebagai safety net dan merutekannya ke handler yang sama.
OpenCode
OpenCode dikonfigurasi sebagai provider OpenAI-compatible. Base URL-nya berakhiran
/v1 di sini, beda dari Claude Code:
{
"provider": {
"bantaiqoder": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "http://127.0.0.1:8787/v1",
"apiKey": "<proxy-key>"
},
"models": {
"auto": { "name": "Auto" },
"efficient": { "name": "Efficient" },
"performance": { "name": "Performance" },
"ultimate": { "name": "Ultimate" }
}
}
},
"model": "bantaiqoder/performance"
}Namespace-nya provider/model:
bantaiqoder/performance
└──────────┘ └─────────┘
provider ID wire model ID
OpenCode yang dikirim ke gatewayOpenCode menembak /v1/chat/completions, bukan /v1/messages, dan tidak butuh
endpoint count_tokens. Model di models harus tier/model yang benar-benar keluar
dari GET /v1/models — jangan mencantumkan model marketing Claude sebagai model
native Qoder.
Install dan menjalankan
Release npm membawa binary Rust yang sudah dikompilasi beserta web console, jadi device tujuan hanya membutuhkan Node.js 18+ dan npm — tidak perlu Rust, Cargo, atau checkout source:
npm install -g bantaiqoder
bantaiqoder --version
bantaiqoderUntuk memperbarui ke release terbaru atau menghapusnya:
npm install -g bantaiqoder@latest --prefer-online
npm uninstall -g bantaiqoderTarget prebuilt yang tersedia: macOS Apple Silicon dan Intel, Linux glibc ARM64
dan x64, serta Windows x64. Linux musl/Alpine dan architecture lain belum
didukung; launcher akan menampilkan target yang tidak didukung secara eksplisit.
Jangan install dengan --omit=optional atau --no-optional, karena binary native
memang dikirim sebagai optional dependency yang dipilih npm sesuai platform.
bantaiqoder membaca .env dari current working directory dan default database
./bantaiqoder.db juga relatif ke directory itu. Jadi jalankan dari directory
yang konsisten, atau set DATABASE_PATH ke path absolut. Minimal konfigurasi
untuk upstream native adalah QODER_PROTOCOL=qoder plus satu PAT di
QODER_PATS; setelah import pertama, kredensial dikelola dari console. Untuk
protokol openai/anthropic, QODER_BASE_URL juga wajib.
Menjalankan dari source
cp .env.example .env # isi konfigurasi upstream
cd web && npm install && npm run build && cd .. # opsional: console
cargo run --releaseUntuk mengembangkan console-nya, jalankan gateway dan Vite bersamaan:
cargo run # :8787
cd web && npm run dev # :5173, proxy /v1 /admin /health ke :8787Di build debug, rust-embed membaca web/dist dari disk, jadi iterasi frontend
tidak perlu rebuild Rust. Di release, bundle-nya ikut masuk ke binary.
Merilis package npm
Repository memakai workflow .github/workflows/publish-npm.yml. Tag vX.Y.Z
membangun dan menguji lima binary native, mem-publish package platform terlebih
dahulu, lalu package bantaiqoder terakhir. Urutan ini mencegah tag latest
menunjuk CLI yang binary-nya belum tersedia.
Sebelum release pertama:
- Login ke npm dan pastikan keenam package berada di akun publisher:
bantaiqoder, empat package platform unscoped, dan package Windows@voyjnan/bantaiqoder-win32-x64. - Buat npm granular access token yang boleh publish public package (aktifkan
bypass 2FA untuk automation bila diperlukan), lalu simpan di GitHub repository
secret bernama
NPM_TOKEN. - Samakan versi
Cargo.toml, rootpackage.json, dan semua exact version dioptionalDependencies; cek dengannpm run release:check. - Commit dan push perubahan, lalu buat tag yang sama, misalnya
v0.1.2. - Setelah workflow sukses, verifikasi dengan
npm view bantaiqoder dist-tags --jsondan install dari device bersih.
Source repository saat ini private, tetapi package dan binary npm harus public
agar npm install -g bantaiqoder bekerja tanpa GitHub authentication. Workflow
menggunakan NPM_TOKEN; provenance hanya diaktifkan otomatis kalau repository
kelak public. Nama package yang tampak kosong di registry belum aman sampai
release pertama benar-benar dipublish.
Docker
docker build -t bantaiqoder .
docker run --rm -p 8787:8787 -v bantaiqoder-data:/data --env-file .env bantaiqoderBuild-nya tiga stage: Node build console, Rust compile binary (embed hasil console tadi), lalu image runtime Debian slim berisi satu binary plus CA bundle.
Mount /data. Proxy key, statistik request, dan kredensial upstream ada di
SQLite di situ; tanpa volume, semuanya hilang tiap container diganti.
DATABASE_PATH sudah diset absolut ke /data/bantaiqoder.db karena default
relatifnya akan mengarah ke direktori yang user non-root di image itu tidak bisa
tulis.
Volume itu berisi PAT dalam bentuk terbaca (lihat Kredensial upstream disimpan plaintext), jadi perlakukan sama seperti file rahasia lain — termasuk backup dan snapshot-nya.
Konfigurasi
Semua lewat environment variable, lengkapnya ada di .env.example. Yang paling
sering dipakai:
QODER_PROTOCOL—qoder,openai, atauanthropic.QODER_PATS— daftar kredensial dipisah koma. Dibaca sekali saja, di start pertama, lalu dikopi ke database; setelah itu kredensial dikelola dari console.QODER_BASE_URL— endpoint yang diproxy. Wajib untukopenai/anthropic, diabaikan olehqoderyang host-nya sudah pasti.PROXY_API_KEYS— key yang client kirim ke gateway.MODEL_ALIASES— map JSON, mis.{"gpt-4o":"glm-5.2"}.ALLOW_UNKNOWN_MODELS— teruskan id tak dikenal apa adanya, bukan 404.
Ada tiga protokol upstream:
| QODER_PROTOCOL | Yang dilakukan |
|---|---|
| qoder | Bicara langsung ke Qoder: COSY signing (RSA + AES-128-CBC + MD5) di atas body yang diobfuskasi |
| openai | JSON biasa bentuk OpenAI |
| anthropic | JSON biasa bentuk Anthropic |
Dua yang terakhir ada untuk relay yang memfrontend Qoder dan menangani signing
sendiri — arahkan QODER_BASE_URL ke situ. PAT mentah yang dikirim sebagai
plain bearer tidak akan pernah jalan lawan Qoder langsung: PAT tidak bisa
menandatangani COSY, jadi harus ditukar dulu jadi job token berumur pendek, dan
itu yang dilakukan protokol native.
Khusus protokol native, ada tiga setelan lain yang defaultnya sudah menunjuk ke
Qoder sendiri: QODER_OPENAPI_BASE, QODER_CHAT_BASE, dan
QODER_RSA_PUBLIC_KEY. Yang terakhir bisa diganti karena kunci bawaannya hasil
ekstraksi dari binary Qoder IDE dan bisa berubah kapan saja — GET
/admin/settings melaporkan apakah kunci bawaan yang sedang dipakai.
Cara translasi bekerja
Keempat arah dialek ada, jadi client selalu dapat balasan dalam dialek yang dia pakai — tidak peduli upstream bicara apa:
| Endpoint client | Upstream openai | Upstream anthropic |
|---|---|---|
| /v1/chat/completions | diteruskan apa adanya | diterjemahkan dua arah |
| /v1/messages | diterjemahkan dua arah | diteruskan apa adanya |
Saat dialeknya sama, body diteruskan byte-per-byte supaya field spesifik provider yang tidak kita modelkan tetap sampai ke upstream.
Yang ditangani di jalur translasi: pesan system (Anthropic memisahkannya dari
percakapan), turn beruntun dengan role sama (Anthropic menolaknya, jadi
digabung), tool call dan tool result, gambar (data: URL dibongkar jadi base64
plus media type), serta reasoning_effort ↔ thinking.budget_tokens dengan
clamp supaya budget tetap di bawah max_tokens.
Streaming dikonversi live per frame, bukan dibuffer sampai generasi selesai.
Parser SSE-nya inkremental, jadi chunk yang terpotong di tengah baris tetap
tersusun benar. Stream yang terputus di tengah tetap ditutup rapi — client
OpenAI dapat [DONE], client Anthropic dapat message_stop — supaya tidak
menggantung.
Fallback kredensial
PAT dipakai berurutan, bukan round-robin. Yang pertama ditambahkan jadi primary dan menerima semua request selama masih tersedia; sisanya backup, baru kena traffic kalau semua yang di depannya habis. Yang mulai menolak traffic di-bench, dan request-nya jatuh ke kredensial berikutnya di rantai. Token yang cuma kena rate limit balik sendiri tanpa restart.
Posisinya tetap, bukan hasil kompetisi: kredensial yang cooldown-nya habis — atau yang saldonya di-top-up, setelah cache quota expired — jadi primary lagi, bukan pindah ke belakang. Itu memang tujuannya: habiskan akun pertama dulu sebelum menyentuh yang kedua, bukan menguras semuanya sekaligus.
Kredensial yang sedang tidak tersedia dilewati, bukan ditaruh di ujung sebagai percobaan nekat. Mengirim ke token yang baru saja menolak cuma membuang satu attempt untuk mempelajari hal yang sudah diketahui, dan budget attempt lebih berguna dipakai menjangkau lebih jauh ke bawah rantai. Kalau semuanya cooling down, request gagal dengan "no upstream credential is currently healthy".
QODER_MAX_RETRIES adalah jumlah handover per request, yaitu berapa langkah di
bawah primary yang boleh ditempuh satu request. Isi satu kurang dari jumlah
kredensial supaya request bisa sampai ke backup terakhir — 4 untuk rantai lima
akun. Kredensial yang dilewati preflight saldo tidak dihitung, jadi beberapa akun
habis di depan rantai tidak memakan budget akun sehat di belakangnya.
Cooldown-nya punya dua dimensi, bukan satu: (token, model). Alasannya, 429
dari Qoder hampir selalu berarti "kuota model ini habis untuk token ini",
bukan "token ini mati" — kuotanya nempel di pasangan itu. Satu dimensi bikin dua
kerusakan berlawanan: bench memarkir seluruh PAT karena satu model, jadi request
model lain melewati kapasitas yang masih sehat; dan revive menghapus catatan itu
pada tiap sukses, jadi sukses di model B menghapus limit hidup model A. Traffic
campur lalu jadi loop: bench → hapus → 429 lagi → bench.
| Kegagalan | Cooldown | Cakupan | |---|---|---| | 401 / 403 | 5 menit | seluruh kredensial | | 429 | 1s berlipat sampai ~4 menit | model itu saja | | Transport / 5xx | 10 detik | model itu saja | | Saldo habis | 5 menit | seluruh kredensial |
Backoff 429 direset setelah sukses. Kalau upstream mengirim retry-after, itu
yang dipakai — tetap dengan cap, karena top-up bisa datang kapan saja. Sukses
cuma menghapus cooldown model yang sukses itu, plus bench kredensial-wide yang
memang terbantah oleh sukses itu sendiri.
Di protokol native, saldo kredit dibaca dari GET /admin/quota (cache 5 menit)
dan kredensial yang dilaporkan habis di-bench sebelum request dikirim, jadi
tidak ada generasi yang dibuang untuk menunggu 429.
GET /admin/pats menunjukkan status per token — hanya fingerprint yang
di-mask, tokennya sendiri tidak pernah dikembalikan. Config dan Upstream
sengaja tidak #[derive(Debug)] supaya {:?} yang tidak sengaja tidak
membocorkan kredensial ke log.
Testing
cargo test # 202 unit test Rust
bash scripts/smoke.sh # 202 check end-to-end lawan mock upstream
cd web && npm test # 47 test vitest: parser SSE, api client, bulk import, format
cd web && npm run build # tsc --noEmit + vite buildVektor uji untuk encoding dan COSY signing diambil dari test suite implementasi referensi, bukan ditulis ulang dari port ini — assertion yang cuma mengulang apa yang kode ini lakukan akan ikut mengulang kesalahannya.
Ada tiga test yang menembak Qoder sungguhan dan karena itu #[ignore] secara
default: exchange PAT + model list bertanda tangan, kuota, dan satu generasi
utuh. Butuh PAT asli di QODER_PATS, dan yang terakhir menghabiskan kredit:
cargo test qoder::tests::live -- --ignored --nocapturescripts/smoke.sh menjalankan scripts/mock_upstream.py sebagai upstream
tiruan, lalu memeriksa kedua endpoint dalam mode buffered dan streaming,
tool call, auth, surface admin, siklus hidup proxy key, pencatatan usage,
request log, penyajian console, CORS, dan fallthrough rantai kredensial. Tidak
butuh kredensial asli.
Mock-nya memverifikasi tanda tangan COSY, bukan cuma menerimanya: signature
direkonstruksi dan dibandingkan, hash body dicek, body yang diobfuskasi
di-decode, lalu payload-nya diperiksa (model_config harus cocok dengan
cerminannya di chat_context, dan tidak boleh ada role system yang tertinggal
di dalam messages). Jadi seluruh jalur bertanda tangan teruji offline. Kunci
RSA produksi itu kunci publik yang private half-nya tidak dipegang siapa pun di
luar Qoder, jadi mock membawa keypair uji sendiri dan gateway diarahkan ke
public half-nya lewat QODER_RSA_PUBLIC_KEY — dan ada satu check yang sengaja
membootkan gateway dengan kunci produksi untuk memastikan mock benar-benar
menolak apa yang tidak bisa dibuka.
Mock juga menahan socket lima detik setelah frame terakhir, meniru agent
keepalive Qoder. Request buffered diuji dengan deadline pendek karena itu satu-
satunya cara bug ini kelihatan: client streaming sudah dapat [DONE]-nya dan
tidak akan pernah menyadarinya.
Siklus hidup key diuji lawan gateway hidup: buat key, pakai untuk chat sungguhan,
cabut, pastikan langsung ditolak tanpa restart, pulihkan, hapus. Tiap boot dapat
DATABASE_PATH sendiri yang dibersihkan setelahnya, jadi satu run tidak pernah
mewarisi key dari run sebelumnya.
Test komponen React saya lewati dengan sengaja — nilainya rendah dibanding biayanya. Yang diuji vitest adalah parser SSE, satu-satunya bagian frontend dengan logika yang bisa salah secara halus (chunk terpotong di tengah baris, CRLF, karakter multi-byte yang terbelah antar chunk, frame yang tidak pernah ditutup server).
Catatan
Model di src/models.rs (25 entri: tier routing
auto/lite/efficient/performance/ultimate plus id vendor-prefixed)
sekarang cuma jadi bootstrap. Di protokol native, daftar hiduplah sumber
kebenarannya, dan GET /v1/models beralih ke situ begitu fetch pertama berhasil.
Kalau Qoder menambah model, /admin/models/live sudah menampilkannya tanpa
perubahan kode; CATALOG cuma perlu diedit untuk memberinya nama yang ramah.
Nama model di native adalah kode internal Qoder, bukan nama marketingnya:
glm-5.2 dikirim sebagai gm51model, kimi-k3 sebagai kmodel_latest. Kode-
nya juga tidak mengikuti urutan versi — GLM 5.2 itu gm51model sementara 5.3
yang lebih baru justru gmodel tanpa sufiks. Semuanya dicocokkan ke daftar dari
akun Qoder hidup.
Empat entri Claude dan gpt-4o-mini yang ada di katalog gateway referensi saya
buang: Qoder tidak punya model itu, dan mengiklankannya cuma menukar 404 yang
jelas dari gateway ini dengan penolakan yang membingungkan dari upstream.
Gambar hilang di jalur native. Payload chat Qoder memipihkan content jadi
string biasa, dan image_urls/chat_context.imageUrls yang selalu null belum
terverifikasi sebagai tempatnya. Jadi request bergambar tetap dilayani, tapi
gambarnya tidak terkirim. Yang paling buruk dari itu adalah kalau terjadi
diam-diam, jadi tidak: setiap part yang dibuang dihitung dan dicatat sebagai
peringatan. Penanganan gambar di src/translate.rs tetap dipakai penuh oleh
protokol openai dan anthropic.
&Encode=1 pada URL chat ada untuk melewati WAF Alibaba Cloud — komentar sumber
referensinya menyatakan itu terang-terangan. Proyek ini memang sudah
memproksikan Qoder, jadi bukan hal baru, tapi menghindari WAF satu langkah lebih
jauh dari sekadar memanggil API. Disebut sekali di sini supaya jadi keputusan
sadar, bukan efek samping.
Kunci RSA dan skema encoding-nya hasil reverse-engineering dari binary Qoder IDE
v0.9. Bisa berubah kapan saja tanpa pemberitahuan, dan kalau berubah, jalur
bertanda tangan mati bersamaan — karena itu kuncinya jadi konfigurasi
(QODER_RSA_PUBLIC_KEY) dan bukan konstanta mati.
Satu risiko yang sudah terjawab: http::HeaderName di Rust selalu menormalkan
nama header ke lowercase, dan tidak ada API di reqwest/hyper untuk mengirim
casing campuran seperti Cosy-Machineid yang dipakai implementasi referensi.
Kalau validator Qoder case-sensitive, seluruh pendekatan ini batal. Ternyata
tidak: panggilan bertanda tangan lawan Qoder hidup dijawab 200. Envelope
{statusCodeValue, body}-nya memang bentuk serialisasi ResponseEntity milik
Spring, dan HttpHeaders Spring case-insensitive.
Persistence-nya SQLite lewat rusqlite fitur bundled — SQLite ikut
dikompilasi ke dalam binary, jadi cerita "satu binary" tetap utuh dan tidak ada
yang perlu dipasang di sistem. Yang disimpan: proxy key (hash) dan statistik
request. Body request dan response tidak pernah ditulis ke disk kecuali
LOG_BODIES dinyalakan, dan itu pun ke log proses, bukan ke database.
Pencatatan usage tidak pernah menahan response. Event masuk lewat channel
berbatas ke satu task penulis di belakang; kalau channel penuh, event dibuang
dan dihitung, lalu dilaporkan sebagai dropped_events di /admin/usage.
Kehilangan satu baris statistik jauh lebih ringan daripada menahan stream.
Untuk request streaming, jumlah token baru diketahui saat stream ditutup, jadi
pencatatannya nempel di Drop — bukan di akhir generator. Ini disengaja: client
yang memutus koneksi di tengah generasi membatalkan generator tanpa pernah
sampai ke cabang akhirnya, padahal request itu tetap terjadi dan tetap memakan
token upstream.
Supaya upstream OpenAI-compatible benar-benar mengirim chunk usage penutup pada
mode streaming, gateway menyisipkan stream_options.include_usage = true ke body
(diatur QODER_OPENAI_INCLUDE_USAGE, default nyala). Tanpa itu banyak upstream
menghilangkan usage di stream dan setiap request streaming tercatat nol token.
include_usage: false dari client ditimpa karena gateway butuh angkanya untuk
accounting. Request Anthropic dan native Qoder tidak disentuh.
Normalisasi token dan estimasi biaya
Semua usage dinormalisasi ke satu invariant sebelum disimpan, apa pun dialek
balasannya, lewat satu pembaca bersama (tokens_from_usage) supaya balasan biasa
dan satu frame streaming tidak pernah dinormalisasi beda:
input— seluruh prompt, termasuk cache read dan cache creation.prompt_tokensOpenAI sudah cache-inclusive;input_tokensAnthropic ditambah dua bucket cache supaya sampai ke total yang sama.cached— subset dariinputyang dilayani dari cache upstream.cache_creation— subset dariinputyang ditulis ke cache.output— output non-reasoning.reasoning— thinking yang ditagih terpisah darioutput.reasoning_tokensnested dicompletion_tokensOpenAI dikeluarkan (output = completion − reasoning) supaya tidak dobel; jumlah reasoning tidak pernah ditebak dari panjang teks.total = input + output + reasoning.
Biaya dihitung per model dari tabel pricing 9router (port src/pricing.rs), lima
bucket penuh:
uncached = max(0, input − cached − cache_creation)
cost = uncached×input + cached×cached + cache_creation×cache_creation
+ output×output + reasoning×reasoningAngkanya estimasi list-price eksternal, bukan tagihan Qoder yang sebenarnya.
Qoder menagih dalam credits terhadap saldo, dan credits tidak dikonversi
ke USD — saldo tetap disajikan terpisah di /admin/quota dalam satuan credits.
Pricing memakai resolved_model (snapshot model upstream saat request dirutekan),
bukan model yang diminta client, supaya alias yang ganti target dari waktu ke
waktu tidak me-reprice traffic lama ke rate target baru. Tier routing (auto,
lite, efficient, performance, ultimate) sengaja tidak diberi harga:
Qoder tidak mengungkap model final yang dipilih, jadi tier masuk unpriced_models
dan membuat estimated_cost_complete jadi false daripada mengarang angka.
Referensi 9router mem-fallback auto ke $2/$8 OpenRouter; itu tidak dipakai di
sini karena auto adalah tier routing Qoder, bukan model.
Row lama (sebelum kolom resolved_model ada) tidak dibackfill: resolved_model
NULL, dan pricing-nya fallback ke model yang diminta sambil ditandai
pricing_model_inferred dan dihitung di legacy_unresolved_requests. History
selalu di-reprice memakai tabel pricing build saat ini — ini estimasi, bukan
ledger invoice yang di-pin per request.
Halaman console referensi yang tidak saya buat: /console/audit,
/console/migration, /console/websearch. Semuanya milik fitur yang gateway
ini tidak punya, jadi isinya cuma akan kosong.
Token session console ditaruh di localStorage supaya bertahan saat reload, sama seperti referensi. Trade-off-nya: token bisa terbaca kalau ada XSS. Untuk console admin self-hosted saya nilai itu wajar, tapi saya sebut supaya kamu tahu, bukan saya sembunyikan.
