公开访问必须设置 TOKEN 环境变量,否则控制台与 API 无鉴权;默认监听 7070 端口,需用 LLMIO_SERVER_PORT 修改;本地从源码运行需 Go >= 1.26.1,构建前端还需 pnpm
llmio
atopos31MIT活跃维护统一的多模型 LLM 网关,把 Claude、Gemini、DeepSeek 等上游统一接入,提供权重负载均衡、可观测性和调用费用追踪,适合多模型混用场景。
Unified LLM gateway with weighted load balancing, observability & cost tracking. 统一的 LLM 网关,提供权重负载均衡、可观测性与费用追踪。
docker compose up -dREADME 未说明;为 Go 单二进制加纯 Go SQLite,本地持久化到 ./db/llmio.db,未提及 GPU 或显存要求
统一 LLM 网关,把多家模型接入一个服务,做权重路由、可观测与费用追踪
统一的多模型 LLM 网关,把 Claude、Gemini、DeepSeek 等上游统一接入,提供权重负载均衡、可观测性和调用费用追踪,适合多模型混用场景。
核心能力
- 统一 OpenAI、Anthropic、Gemini 原生协议入口,支持流式与非流式透传
- 客户端与上游协议不一致时自动翻译请求与流式事件
- 按权重随机或按权重优先级两种策略调度,可按工具调用、结构化输出、多模态路由
- 记录 TraceID、延迟分段、TPS、token 用量与按百万 token 单价计算的单次费用
- 内置 React 管理台,覆盖仪表盘、分析、供应商、模型路由、日志、配额与密钥
容易踩的坑
- v0.10.3最新正式版2026-10-29 · 2026-10-29 17:40 UTC
修复:打开「分析」页不再可能导致服务重启
此前在日志量较大的实例上,打开分析页会把所选时间范围内的全部记录一次性读进内存。 时间范围选得越宽,占得越多;内存较小的部署会因此被撑爆、服务重启。
现在改为逐条读取、边读边算,内存占用基本不再随时间范围增长:30 天窗口的实测占用 从约 49 MiB 降到约 2.5 MiB。
更快:分析页出数明显变快
- 纠正了一条长期被查询选错的索引,统计查询不再退化成全表扫描。
- 读取记录时只取需要的字段,省去逐行转换的开销。
同样的时间范围,取数耗时约降到原来的一半;时间范围越宽,差别越明显。
更好用:加载过程看得见了
- 慢的时候显示已经等了多少秒,并且可以随时取消。
- 超过 10 秒会提示「可以先看近 24 小时」,一键切换过去。
- 取消之后不会误报「没有数据」,而是给出「重新加载」的按钮。
- 切换筛选条件时,上一次的结果不会盖掉新的结果。
- 最近看过的时间范围会暂存一分钟,切回来立刻显示。
- 几百毫秒就完成的加载不会再闪一下加载提示。
构建
镜像同时提供 amd64 与 arm64 两个架构。
- v0.10.22026-10-28 · 2026-10-28 13:55 UTC
查看更新说明
- 新增备用关联机制:主关联全部不可用时,由备用关联接管;未启用备用关联的现有部署行为不变
- 新增「提供商 × 模型」联合用量视图,可查看每家提供商下各上游模型的 token 消耗
- 新增错误阶段标记(upstream_request / response_stream / log_persist),并将客户端主动断开单独归类
- 启动时为日志压缩、空间回收、请求自愈三项策略补齐默认配置,消除重复的 record not found 日志
- 为 chat_ios.log_id 增加索引,回填响应体不再全表扫描(生产环境观测单次 5895 ms)
- 修正 CI 锁释放断言,改为以语句能否成功执行为判据
- 新增压缩语料回归用例,覆盖存量压缩数据的读取路径
升级提示:升级前建议备份 db/llmio.db。启动时会自动补充 model_with_providers.backup、chat_logs.backup 列,并建立 chat_ios.log_id 索引。ChatLog.Error 可能带有阶段前缀,按裸文本精确匹配的外部告警需改为包含匹配。
- v0.10.12026-10-24 · 2026-10-24 17:21 UTC
查看更新说明
- 修复 Anthropic 流式用量统计:输入与缓存桶从 message_start 起按字段累计,成功请求不再把 prompt 侧记成 0
- 缓存创建 token 计入总输入量,原生响应与协议互转两条链路同口径
- OpenAI 流式收尾的全零用量快照不再被丢弃;Anthropic→OpenAI 互转等到真实 stop 事件才收尾,中间的累计用量更新不再提前断流
- v0.10.02026-10-24 · 2026-10-24 16:18 UTC
查看更新说明
- 分析页下钻表新增 Token 列;主页与分析页新增成本趋势图
- 请求体参数自愈:上游点名不认识的参数时,同一家上游删参重发(预算 3 次,可在系统配置 self_heal_policy 关闭);每次删参在日志记账(self_heal_dropped:*)
- 重试耗尽时,错误信息带上最后一次失败的真实原因,不再只给一句 All retry failed
- 工具结果里的图片在协议互转时双向搬运(OpenAI/Anthropic 均支持);上游不收时自动降级为纯文本并记账
- OpenAI 的 response_format 翻译为 Anthropic 的 output_config.format(原生结构化输出)
- 默认峰谷时段模板与 DeepSeek 现行方案同步(工作日 09:00–12:00 / 14:00–18:00 ×2,其余 ×1);模板仍默认关闭
- v0.9.72026-10-20 · 2026-10-20 07:23 UTC
查看更新说明
Changelog
- d51e8cf13f3e5f56497c5efa412d0eadd17d5efb Merge pull request #89 from tktogether/feat/quota-commandcode
- e5411cf802feddc3f83f56826edc64b8253d27b7 feat(quota): 新增 Command Code 内置适配器
用户点评
登录用户的真实使用记录,可按版本和硬件筛选还没有人点评。第一个说出真实体验的人最有价值。
LLMIO
English | 中文
LLMIO is a Go-based LLM load‑balancing gateway that provides a unified REST API, weighted scheduling, observability, and a modern admin UI for LLM clients (openclaw / claude code / codex / gemini cli / cherry studio / open webui). It helps you integrate OpenAI, Anthropic, Gemini, and other model capabilities in a single service.
QQ group: 1083599685
Live Demo
A demo instance is deployed on Vercel: https://llmio-rust.vercel.app/
Console token: demo
It is a showcase, not a service to rely on. The console is fully interactive — three
providers, two models with weighted routing, and a day of request logs are seeded so
every page has something to show. Two things are deliberately not real: every
provider credential is a placeholder (sk-demo-*), so "test connectivity" fails; and
the database lives in the function's temporary storage, so it resets whenever the
instance is recycled. Background scheduling (log retention, compression, space
reclamation) does not run there. See docs/vercel-demo.md for
how the deployment is put together and why it is demo-only.
Architecture
Features
- Unified API: Compatible with OpenAI Chat Completions, OpenAI Responses, Gemini Native, and Anthropic Messages. Supports both streaming and non‑streaming passthrough.
- Protocol bridge: When the client protocol differs from the upstream protocol, requests are translated automatically (OpenAI ⇄ Anthropic, streaming events included). Anything that cannot be carried across is either rejected outright (the router moves on) or recorded field by field — never dropped silently. See Protocol Bridge.
- Custom headers & session keys: Each model–provider association can carry headers with
{{...}}placeholders, and a session key is derived in stages from the request body, an inbound session header or a conversation root hash, always non-empty — so upstreams that require a session header (opencode, for example) work out of the box. See Custom Headers & Session Keys. - Weighted scheduling:
balancers/provides two strategies (random by weight / priority by weight). You can route based on tool calling, structured output, and multimodal capability. - Admin Web UI: React + TypeScript + Tailwind + Vite console covering the dashboard, analytics, quick start, providers, model routing, request logs, session comparison, quota, auth keys, and system configuration.
- Quota & balance: Normalizes the many different usage / balance APIs of upstreams into one structure, with three kinds of data source: built-in adapters, HTTP, and sandboxed JavaScript. See Quota & Balance.
- Time-of-day pricing: Multipliers for the input / cache-read / output prices by period, configured per model–provider association, with one global workday calendar and holiday syncing. See Time-of-Day Pricing.
- Usage analytics: Five views — time series, multi-dimensional breakdown, latency distribution, error grouping and model performance — with a custom time range and multi-value filters. See Usage Analytics.
- Request logs & session comparison: Multi-select filters on the log page, full request / response inspection per entry, and a comparison page that analyzes up to 6 logs at once in the order a cache actually builds a prefix.
- Access keys: Issue a separate key per client, each with its own enable / disable state, expiry, model allow-list and whether request bodies are recorded. Usage count and last-used time are tracked per key, and both the log and analytics pages can filter by it.
- Log retention: The Settings page sets the retention window and the scheduled-cleanup switch (checked hourly in the background); the log page also offers a manual cleanup, and past runs are listed in a cleanup history.
- Rate limiting & failure handling: Built‑in rate‑limit fallback, a per-model circuit breaker, and provider connectivity checks for fault isolation.
- Local persistence: Pure Go SQLite (
db/llmio.db) for config and request logs, ready to use out of the box. - Database compression & space reclamation: Request bodies are content-defined chunked and globally deduplicated; historical rows can be migrated in place, paused, resumed, and rolled back. Freed space can be returned to the filesystem while the service keeps running, and a large hole can be reclaimed in one pass with a full rebuild (which needs an offline window and about twice the database size on disk). Measured on a copy of a production database: 7.06 GiB → 1.47 GiB. See Database Compression & Space Reclamation.
- Session tracking: Pass
session_idin any request body (works withextra_bodyin OpenAI SDK) to tag logs with a session identifier. Filter and search bysession_idin the admin UI or viaGET /api/logs?session_id=. - Observability: Every request is recorded with TraceID, latency breakdown (proxy / first-chunk / completion time), TPS, token usage (input / cached / output), and optional full IO logging. Per-request cost is calculated from configurable per-million-token prices (CNY / USD) and shown in the log detail view alongside provider and model metadata.
Deployment
Docker Compose (Recommended)
services:
llmio:
image: atopos31/llmio:latest
ports:
- 7070:7070
volumes:
- ./db:/app/db
environment:
- GIN_MODE=release
- TOKEN=<YOUR_TOKEN>
- TZ=Asia/Shanghai
docker compose up -d
Docker
docker run -d \
--name llmio \
-p 7070:7070 \
-v $(pwd)/db:/app/db \
-e GIN_MODE=release \
-e TOKEN=<YOUR_TOKEN> \
-e TZ=Asia/Shanghai \
atopos31/llmio:latest
Local Run
Download the release package for your OS/arch from releases (version > 0.5.13). Example for linux amd64:
wget https://github.com/atopos31/llmio/releases/download/v0.5.13/llmio_0.5.13_linux_amd64.tar.gz
Extract:
tar -xzf ./llmio_0.5.13_linux_amd64.tar.gz
Start:
GIN_MODE=release TOKEN=<YOUR_TOKEN> ./llmio
The service will create ./db/llmio.db in the current directory as the SQLite persistence file.
Environment Variables
| Variable | Description | Default | Notes |
|---|---|---|---|
TOKEN | Console login and API auth for /openai /anthropic /gemini /v1 | None | Required for public access |
GIN_MODE | Gin runtime mode | debug | Use release in production |
LLMIO_SERVER_PORT | Server listen port | 7070 | Service listen port |
TZ | Timezone for logs and scheduling | Host default | Recommend explicit setting in containers (e.g. Asia/Shanghai) |
DB_COMPRESS | Store newly written request bodies in compressed form | true | Setting it to false affects new rows only; previously compressed rows remain readable |
DB_VACUUM | Run SQLite VACUUM once on startup | Disabled | Set to true to enable. VACUUM takes an exclusive write lock and needs free disk of about 2× the database size |
DB_AUTO_VACUUM_REBUILD | Rebuild an existing database on startup to enable incremental space reclamation | auto | auto sets auto_vacuum on empty databases only; on converts an existing database with a single VACUUM (minutes, exclusive write lock, 2× disk required); off never modifies it. Any other value is treated as auto |
LLMIO_QUOTA_CONFIG | Path of the quota configuration file | ./db/quota.config.json | Changes only the file location; its contents (credentials included) are read and written by the console and never stored in the database |
LLMIO_QUOTA_ALLOW_WRITE | Whether quota write endpoints are enabled | Writes allowed | Only the exact value false (case-sensitive) switches to read-only mode, where every write endpoint under /api/quota returns 403; test runs change no state and are unaffected |
For the full description, console entry point and admin endpoints, see Database Compression & Space Reclamation.
Protocol Bridge
When the client protocol and the upstream protocol differ, the gateway inserts a translation layer between them; when both ends speak the same protocol the request is forwarded unchanged and never enters that layer.
- The direction is determined automatically: it follows the client protocol and the upstream type
actually selected for that attempt, and is re-evaluated for each candidate. The candidate pool is built
from the client protocol: an OpenAI client may use
openai/openai-res/anthropicupstreams, an Anthropic clientanthropic/openai; Gemini and OpenAI Responses upstreams never take part. - Prefer the same protocol: the per-model "Prefer the same protocol" switch (on by default) decides whether both protocols join the same draw. When it is on, upstreams speaking the client protocol are used whenever the model has any, and translation is only a fallback for models without one; when it is off, the whole pool is drawn by weight.
- What is translated: request bodies, non-streaming responses and streaming events — Anthropic's
message_start/content_block_*/message_delta/message_stopand OpenAI's chunk sequence can be produced and consumed in either direction, including tool calls, stop reasons and token-usage accounting. - What cannot be carried over is never dropped silently: when the request as a whole cannot be
expressed it is rejected and the router moves on to the next provider; when only individual fields are
lost they are recorded as short codes in the request log. Rejection covers
n>1, structured output, non-zero penalties and logprobs; recording coverstop_k,thinkingandreasoning_content. - Where to look: when the upstream protocol differs from the client's, the log detail view shows an
"Upstream Protocol" field and a "Protocol Bridge" section that translates each short code into a sentence;
the backend also emits one
protocol bridgedwarning.
A cross-protocol upstream request gets max_tokens defaulted to 8192 (required on the Anthropic side, and
recorded as defaulted_max_tokens when the client did not provide it). The complete field-by-field mapping,
the trade-off behind that default and the streaming state machine are in
docs/protocol-bridge.md; the end-to-end acceptance run is e2e/run_matrix.py.
Custom Headers & Session Keys
- Custom headers: each model–provider association can carry request headers whose values support
{{...}}placeholders. A value without{{is sent literally (existing configurations keep their behaviour); one with placeholders is evaluated per request. Available placeholders are{{session}},{{session_id}},{{model}},{{provider_model}},{{trace_id}},{{auth_key_id}}and{{uuid}}. Unrecognised placeholders are left as they are, so a typo shows up instead of silently losing a value. An empty result is replaced by a random string, so a configured header always carries a non-empty value. - Session key derivation:
{{session}}is resolved by priority and is always non-empty — request-bodysession_id→ inbound session header (x-opencode-session/x-session-id/session_id) → conversation root hash → random string. The root hash fingerprints the first user message in the body, so with no cooperation from the client every turn of the same conversation still gets the same value, while different conversations get different ones. - Why it is needed: some upstreams (opencode, for example) require a session header on every request and return 400 when it is missing or empty; hardcoding one value collapses every client and every conversation on that upstream into a single session, causing context bleed and cache overwrites.
- Header precedence: provider configuration > custom headers > passthrough client headers. The
passthrough switch decides whether the client's original headers are carried over; either way auth headers
and
Accept-Encodingare removed — the former so credentials never leak, the latter because Go's transport must negotiate it itself or the response will not be transparently decompressed.
Database Compression & Space Reclamation
Almost all of the request-log volume comes from three body columns of the chat_ios table (input,
of_string, of_string_array). Coding clients resend the entire conversation history on every turn, so
the request body of round N contains the request body of round N−1 almost byte for byte, and the same
passage reappears hundreds of times across rounds. Row-wise compression cannot exploit this (each row is
an independent compression window), and whole-row hashing does not hit either (byte-identical rows are
less than 1% of the table). The effective approach is content-defined chunking followed by global
deduplication: identical content is stored once.
Migration: rewriting historical bodies into compressed form
- Chunking and deduplication: Bodies are split on content-defined boundaries into chunks averaging 4 KiB (FastCDC rolling hash); chunks with identical content are stored once, and each row keeps only its own sequence of chunk IDs. Chunks are packed into 256 KiB groups with one compressed frame per group; reads decompress by group and keep a 32 MiB group cache in memory.
- Row-wise frames: The
of_stringandof_string_arraycolumns do not use the chunk table; they are stored as row-wise compressed frames. - Lossless: Chunk contents plus each row's chunk-ID sequence reconstruct the original bytes exactly.
A frame starts with a 16-byte self-describing header (magic, version, codec, raw length), and plain text
and frames may be mixed in the same column. The read path trusts only the header and falls back to
plain text whenever the answer is uncertain: a valid JSON body can never begin with
0x00, which is the first byte of the frame magic. - How migration runs: Historical rows are rewritten in place and committed in batches (64 rows /
32 MiB by default, one transaction per batch). The service stays up throughout: reads are unaffected and
a write request waits at most one batch. Migration can be paused at any time; after an interruption
(process killed, power loss) it resumes from its watermark, and already migrated rows are not processed
twice. Newly written rows are stored compressed as soon as
DB_COMPRESSis enabled, without waiting for the historical migration to finish. - Quiescence window: Rows written within the last 60 seconds are skipped by default (
quiesce_sec) so that rows still being written are not touched. - Rollback: All compressed bodies can be restored to plain text. This resets migration progress and makes the database file significantly larger; returning to the compressed form then requires a full migration again.
Space reclamation: returning freed pages to the filesystem
Deleting rows in SQLite only returns pages to the free list; with the default configuration
(auto_vacuum=0) the file never shrinks. There are two ways to give those pages back to the filesystem,
and they need different things:
- Incremental reclamation needs the database to be in
auto_vacuum=INCREMENTAL(value 2). On a database withauto_vacuum=0it is a no-op, so its controls are shown disabled with that reason.- New databases: Set to INCREMENTAL before any table is created, at no extra cost.
- Existing databases: Left untouched by default. To convert, set
DB_AUTO_VACUUM_REBUILD=on; the service then runs a single VACUUM before it starts listening (about 1 minute for a 7 GiB database on the development machine). The conversion holds an exclusive write lock and needs free disk of about 2× the database size; if disk space is insufficient it is skipped and the reason is recorded, without preventing the service from starting. - What a round does: Returns free pages to the filesystem one at a time (the driver ignores the argument the pragma is given, so a "batch" is a number of statements inside one transaction). A batch starts at 64 statements and adapts between 16 and 512 to stay near 2.5 seconds — batches must follow the database, because the same 512 statements took 1.1 seconds on one database and 8.9 seconds on another, and anything past 5 seconds makes write requests fail. It does not modify data and can be repeated or interrupted. A single round runs for at most 90 seconds and leaves the remainder for the next round; in continuous mode every batch is followed by a 0.1 second pause, so write requests queue and slow down rather than fail. Reads are unaffected, writes are slower while it runs.
- Full rebuild (VACUUM): Needs no
auto_vacuumsetting. Rewrites the whole database in one statement and returns every free page at once. It is much faster than incremental reclamation (measured on the same 7 GiB database: 895 ms against a file that was almost entirely free pages, 56 seconds against 7 GiB of live pages — the time follows the live data, not the file size), but it holds the database exclusively for the whole run: reads and writes cannot get in, and a write request fails once its 5-second busy timeout runs out. It needs free disk of about 2× the database size (checked before it starts) and cannot be stopped once started, because a single statement has no point at which to stop. Use it after the holes are large (a migration, a rollback, a bulk delete). - Choosing between the two: Use VACUUM for a large amount of free space (one pass, but requires downtime and 2× disk); use incremental reclamation for the small holes that accumulate day to day (online, no extra disk, but it needs the conversion above).
Console
The Database Compression card on the Settings page shows migration progress and compression ratio, database file size and reclaimable space, and provides five actions: "Start migration", "Pause", "Revert to plaintext", "Reclaim space", and "Vacuum now". Each of the two reclamation actions states its own cost before it is confirmed: reclamation says write requests queue and slow down, a rebuild says reads and writes cannot get in and write requests fail. Migration policy (background auto-advance, rows per batch, bytes per batch, pause between batches, quiescence window) is configured under "Adjust policy", and reclamation policy (background auto-reclaim, minimum reclaimable space, check interval) under "Reclaim policy". The "?" next to each field label shows the accepted range and out-of-range behaviour on hover or keyboard focus.
Admin endpoints
| Path | Method | Description |
|---|---|---|
/api/logs/compression | GET | Migration status and progress, migration policy, reclamation policy, database statistics, the last reclamation result, and which maintenance task is running right now |
/api/logs/compression/policy | PUT | Set migration policy (batch size, pause between batches, quiescence window, background auto-advance) |
/api/logs/compression/run | POST | Start migration |
/api/logs/compression/pause | POST | Pause migration (takes effect after the current batch completes) |
/api/logs/compression/decompress | GET | Query rollback progress |
/api/logs/compression/decompress | POST | Roll back to plain text |
/api/logs/compression/reclaim | POST | Reclaim space. Body {"continuous": true} reclaims until finished; an empty body runs a single round (up to 90 seconds) |
/api/logs/compression/reclaim/stop | POST | Stop reclamation (takes effect after the current batch completes) |
/api/logs/compression/reclaim/policy | PUT | Set reclamation policy (minimum reclaimable space, check interval) |
/api/logs/compression/vacuum | POST | Rebuild the whole database (VACUUM) in the background. Rejected before it starts unless free disk is at least 2× the database size, and while another reclamation is running |
All of these require console authentication (Authorization: Bearer <TOKEN>). Out-of-range values are
clamped to the accepted range instead of being rejected:
| Parameter | Accepted range | Out-of-range behaviour |
|---|---|---|
Rows per batch batch_rows | 1–4096 | Below 1 falls back to the default 64; above 4096 becomes 4096 |
Bytes per batch batch_bytes | up to 1 GiB | Below 1 falls back to the default 32 MiB; above becomes 1 GiB |
Pause between batches batch_interval_ms | 0–5000 ms | Negative values become 0 |
Quiescence window quiesce_sec | 0–86400 s | Negative values become 0 |
Minimum reclaimable space min_bytes | 0–1 TiB | Negative values become 0 |
Check interval check_interval_sec | 60–86400 s | Below 60 becomes 60 |
Caveats
- Body columns must not be used for SQL text comparison. A compressed row stores a binary frame, so
LIKE,json_extractand similar queries against the body will not match. To read a single chat IO entry, useGET /api/logs/:id/chat-io, which transparently restores the body. - Migration and reclamation share one maintenance lock and never run at the same time. Scheduled log cleanup skips its round while either of them is running.
- Confirm that a usable backup exists before migrating.
db/llmio.db.bakis detected automatically; if it is missing or smaller than the current database, the console asks for confirmation and records the run as performed without a backup. DB_COMPRESS=falsestops compression of newly written rows only; previously compressed rows remain readable.
Compression ratio and duration depend on the corpus. On a copy of a production database of 12,483 rows (5.64 GiB of bodies) the development machine measured: 36 seconds for the migration, 7.06 GiB → 1.47 GiB for the database file after VACUUM, and roughly 1,500 pages/second for incremental reclamation. The full set of measurements and trade-offs is in docs/db-compression-phase0.md (read-path baseline), docs/db-compression-phase3.md (chunk table and deduplication), docs/db-compression-phase4.md (migration and scheduling), docs/db-compression-phase5.md (storage layer and incremental reclamation), docs/db-compression-phase6.md (backfilling the two response body columns), docs/db-compression-phase7.md (rebuild, adaptive reclamation batches, and what a rebuild costs concurrent write requests), and docs/db-compression-safety.md (frame format and safety boundaries).
Quota & Balance
Normalizes the many different "usage / balance" APIs of upstreams into one structure: any two of used, total and remaining derive the third, the status follows from the used percentage and the remaining value, and the tightest item is surfaced as a page-level summary. Display text is rendered server-side.
Three kinds of data source:
- Built-in adapters:
deepseek(balance),moonshot(balance),scnet(National Supercomputing Center TokenPlan, login-based),opencode(plan, login-based),commandcode(Command Code plan quotas plus the subscription period, Bearer API key) andcustom(a generic endpoint whose path you supply, read throughitemsPathplus a field mapping). Login-based adapters need their credentials in the source's ownenv(keysSCNET_USER/SCNET_PASSandOPENCODE_COOKIE/OPENCODE_ORG_ID): those upstreams require a real login and session, which a script cannot reproduce, so they live in Go.commandcodereads its API key from the source'sapiKeyand hits two endpoints in one refresh —/alpha/billing/creditsfor the 5-hour, weekly and monthly credit windows, and/alpha/billing/subscriptionsfor the plan id, status and period end (the monthly cap is derived from the plan id). Usage scripts that may issue a single request have to pick one of the two; this adapter shows all three used windows and the plan period on the same card. The subscription call is best-effort: when it fails the monthly row degrades to a remaining-only entry. - HTTP: configuration only, no code — URL, method, query, headers, body and auth style
(
bearer/header/basic/none), with{{apiKey}},{{baseUrl}},{{id}}and{{name}}placeholders. - Script: a piece of JavaScript (goja, ES5.1 baseline) run in a separate child process, with a
30-second timeout by default. The only globals available are
console,output(), the source's ownenvallow-list and, when explicitly enabled,fetch; there is norequire, no filesystem and no child process.fetchis off by default; when on it allows http/https only, rejects loopback and private addresses, and re-validates on every redirect (at most 5 hops). The engine has no heap cap — isolation comes from the process boundary, so a runaway script only affects its own child process and is killed on timeout, leaving the gateway unaffected.
Credentials live in ./db/quota.config.json (change the location with LLMIO_QUOTA_CONFIG; mode 0600,
written atomically) and are never stored in the database. Configuration returned by the API is always
masked, and masks are resolved back to the stored values on save.
Results are cached server-side per source with a TTL of the global refresh interval (10 seconds minimum), and only successful results are cached — a failure never fills the cache. There is no background refresh thread: data is fetched only when a request asks for it. Auto-refresh in the page is a separate layer (0–600 seconds, off by default) that does not call upstreams while the page is hidden and refreshes immediately when it returns to the foreground.
Admin endpoints (Authorization: Bearer <TOKEN>):
| Path | Method | Description | Write permission |
|---|---|---|---|
/api/quota/config | GET | Configuration (credentials masked), the built-in adapter list, the config file path, whether writes are enabled, the default refresh interval and warning threshold | Not required |
/api/quota/config | PUT | Change the global refresh interval and warning threshold | Required |
/api/quota/sources | POST | Add a data source | Required |
/api/quota/sources | PUT | Update a source by id; an unknown id is not silently turned into an insert | Required |
/api/quota/sources/:id | DELETE | Delete a source | Required |
/api/quota/run | POST | Fetch every enabled source; force=true bypasses the cache, ids=a,b runs only the listed sources | Not required |
/api/quota/sources/:id/refresh | POST | Force-refresh a single source | Not required |
/api/quota/test | POST | Test-run a source that has not been saved; nothing is written to disk or cache | Not required (stays available in read-only mode) |
The defaults are a 120-second refresh interval and an 80% warning threshold. The console's "Quota" page provides the balance panel (source cards, status badges, latency, collapsible hidden items) and the source editor. Card style can be a progress bar, a usage ring, a bar comparison or plain text; display names and chart styles are kept per source and per item in the browser, not on the server.
Time-of-Day Pricing
- Terms live on the model–provider association: one upstream discounting while another charges peak rates at the same moment is a commercial term of that upstream, so the multiplier is stored on the same row as the base prices it multiplies. Only the workday calendar is global.
- What a term contains: a multiplier per period (1 meaning unchanged), optionally restricted to days of the week and to workdays / rest days. Terms are matched in order, the first hit wins, and no hit means a multiplier of 1.
- How cost is computed: when a request starts, the multiplier is applied to all three prices (input, cache read, output) at once, and the resulting effective prices plus the name of the matched period are stored with the log — so editing terms later never rewrites historical cost.
- Workday calendar: maintained on a card of the console's Settings page; defaults to
Asia/Shanghai, Monday to Friday. Holidays and adjusted workdays can be synced per year (an external fetch, falling back to built-in data; when neither is available the endpoint returns 502 and the calendar is left untouched). Re-syncing the same year replaces it wholesale and is idempotent. - Admin endpoints:
GET|PUT /api/peak-calendar,POST /api/peak-calendar/preview(replays the terms in the request body over the coming days, 7 by default, range 1–31) andPOST /api/peak-calendar/holidays/sync(syncs the given year, the current one by default).
Usage Analytics
- One endpoint:
GET /api/metrics/statsreturns the whole slice in a single request, so charts cannot drift apart by asking separately;GET /api/metrics/granularitieslists the available granularities. - Time: granularities
5m / 15m / 30m / 1h / 2h / 6h / 12h / 1d / 7d;autopicks one from the span (targeting about 60 buckets). Ranges include presets (today / yesterday / last 24 hours / last 7 days / last 30 days) and a custom range; the default is the last 7 days. - Five views: trend (requests and tokens), breakdown (model / provider / model × provider / key / request name / User-Agent), latency (first-chunk distribution and percentiles, TPS and proxy time, TPS and slowest leaderboards), errors (grouped by category with the affected upstreams, models and samples), and model performance (nine sortable columns, switchable between model / provider / model × provider).
- Filters: provider, model, key, request name, User-Agent and status; status and key match exactly, the rest by substring. Filter options are fetched once for the same window and do not change with the current selection.
- Detail limit: at most 200,000 logs are loaded per request; when the limit is hit, the page shows a truncation notice at the top.
Development
Clone:
git clone https://github.com/atopos31/llmio.git
cd llmio
Build frontend (pnpm required):
make webui
Run backend (Go >= 1.26.1):
TOKEN=<YOUR_TOKEN> make run
Web UI: http://localhost:7070/
Tests
go test ./...
cd webui && pnpm run test:coverage
python e2e/run_matrix.py --upstream stub
go test ./...runs the backend suite; frontend tests and the coverage gate run throughpnpm run test:coverage, with the threshold inwebui/vitest.config.ts— below it the run fails.e2e/run_matrix.pyis the end-to-end acceptance run for protocol bridging. It uses a local stub upstream by default and needs no external credentials;--upstream opencoderuns a smaller matrix against a real upstream (consuming quota).e2e/check_compression.pydoes not trust what the API reports about itself — it opens the database file and checks that the compressed form andauto_vacuumreally landed on disk.e2e/check_vacuum.pymeasures what a rebuild costs concurrent traffic: it copies a database, deletes rows to open a hole, rebuilds it, and counts how many write requests failed and how long the longest one waited. It refuses to point at a database under the repository and only ever writes to its own copy (--leaveprepares an instance with an un-rebuilt hole for manual testing).- CI is
.github/workflows/test.yml: on every push and pull request it runs the frontend lint, the frontend tests with the coverage gate, the frontend build, agofmtcheck,go vet,go testand a single-binary build check.
API Endpoints
LLMIO provides a multi‑provider REST API with the following endpoints:
| Provider | Path | Method | Description | Auth |
|---|---|---|---|---|
| OpenAI | /openai/v1/models | GET | List available models | Bearer Token |
| OpenAI | /openai/v1/chat/completions | POST | Create chat completion | Bearer Token |
| OpenAI | /openai/v1/responses | POST | Create response | Bearer Token |
| Anthropic | /anthropic/v1/models | GET | List available models | x-api-key or Bearer Token |
| Anthropic | /anthropic/v1/messages | POST | Create message | x-api-key or Bearer Token |
| Anthropic | /anthropic/v1/messages/count_tokens | POST | Count tokens | x-api-key or Bearer Token |
| Anthropic | /anthropic/api/event_logging/batch | POST | Accept Claude Code batch event reports and acknowledge them (contents are not stored) | No auth |
| Gemini | /gemini/v1beta/models | GET | List available models | x-goog-api-key |
| Gemini | /gemini/v1beta/models/{model}:generateContent | POST | Generate content | x-goog-api-key |
| Gemini | /gemini/v1beta/models/{model}:streamGenerateContent | POST | Stream content | x-goog-api-key |
| Generic | /v1/models | GET | List models (compat) | Bearer Token |
| Generic | /v1/chat/completions | POST | Create chat completion (compat) | Bearer Token |
| Generic | /v1/responses | POST | Create response (compat) | Bearer Token |
| Generic | /v1/messages | POST | Create message (compat) | x-api-key or Bearer Token |
| Generic | /v1/messages/count_tokens | POST | Count tokens (compat) | x-api-key or Bearer Token |
Authentication
LLMIO uses different auth headers depending on the endpoint:
1. OpenAI‑style endpoints (Bearer Token)
Applies to /openai/v1/* and OpenAI‑compatible endpoints under /v1/*.
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:7070/openai/v1/models
2. Anthropic‑style endpoints (x-api-key or Bearer Token)
Accepts both: Claude Code sends x-api-key when ANTHROPIC_API_KEY is set and Authorization: Bearer when ANTHROPIC_AUTH_TOKEN is set. If both headers are present, x-api-key wins.
Applies to /anthropic/v1/* and Anthropic‑compatible endpoints under /v1/*.
curl -H "x-api-key: YOUR_TOKEN" http://localhost:7070/anthropic/v1/messages
# same token, sent the way ANTHROPIC_AUTH_TOKEN sends it:
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:7070/anthropic/v1/messages
3. Gemini Native endpoints (x-goog-api-key)
Applies to /gemini/v1beta/* endpoints.
curl -H "x-goog-api-key: YOUR_TOKEN" http://localhost:7070/gemini/v1beta/models
For claude code or codex, use these environment variables:
export OPENAI_API_KEY=<YOUR_TOKEN>
export ANTHROPIC_API_KEY=<YOUR_TOKEN>
# optional: ANTHROPIC_AUTH_TOKEN is also accepted (it is sent as Authorization: Bearer)
export ANTHROPIC_AUTH_TOKEN=<YOUR_TOKEN>
export GEMINI_API_KEY=<YOUR_TOKEN>
Note:
/v1/*paths are kept for compatibility. Prefer the provider‑specific routes.
Screenshots
License
This project is released under the MIT License.