npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

bantaiqoder

v0.1.2

Published

Self-hosted AI gateway exposing OpenAI and Anthropic endpoints over a Qoder upstream

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 build

Tanpa 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/pats cuma memberi fingerprint yang di-mask. Yang dilindungi di sini adalah surface HTTP-nya, bukan file-nya.
  • Config dan Upstream sengaja 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 sonnet

Slot 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 gateway

OpenCode 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
bantaiqoder

Untuk memperbarui ke release terbaru atau menghapusnya:

npm install -g bantaiqoder@latest --prefer-online
npm uninstall -g bantaiqoder

Target 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 --release

Untuk mengembangkan console-nya, jalankan gateway dan Vite bersamaan:

cargo run                 # :8787
cd web && npm run dev     # :5173, proxy /v1 /admin /health ke :8787

Di 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:

  1. Login ke npm dan pastikan keenam package berada di akun publisher: bantaiqoder, empat package platform unscoped, dan package Windows @voyjnan/bantaiqoder-win32-x64.
  2. 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.
  3. Samakan versi Cargo.toml, root package.json, dan semua exact version di optionalDependencies; cek dengan npm run release:check.
  4. Commit dan push perubahan, lalu buat tag yang sama, misalnya v0.1.2.
  5. Setelah workflow sukses, verifikasi dengan npm view bantaiqoder dist-tags --json dan 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 bantaiqoder

Build-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_PROTOCOLqoder, openai, atau anthropic.
  • 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 untuk openai/anthropic, diabaikan oleh qoder yang 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_effortthinking.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 build

Vektor 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 --nocapture

scripts/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_tokens OpenAI sudah cache-inclusive; input_tokens Anthropic ditambah dua bucket cache supaya sampai ke total yang sama.
  • cached — subset dari input yang dilayani dari cache upstream.
  • cache_creation — subset dari input yang ditulis ke cache.
  • output — output non-reasoning.
  • reasoning — thinking yang ditagih terpisah dari output. reasoning_tokens nested di completion_tokens OpenAI 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×reasoning

Angkanya 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.