README
¶
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

Dashboard — Overview of request volume, token usage and provider metrics

Model Associations — Configure multiple providers per model with weight, capability filters and per-token pricing

Request Logs — Multi-dimensional search and filtering by model, status, TraceID, Session ID and more

Session IO — Inspect full request / response, latency breakdown and per-token billing detail for any log entry
License
This project is released under the MIT License.
Star History
Documentation
¶
There is no documentation for this package.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package bridge 在 OpenAI Chat Completions 与 Anthropic Messages 两套协议之间做翻译。
|
Package bridge 在 OpenAI Chat Completions 与 Anthropic Messages 两套协议之间做翻译。 |
|
pkg
|
|
|
compress
Package compress 提供 llmio 数据库 body 列的压缩帧。
|
Package compress 提供 llmio 数据库 body 列的压缩帧。 |
|
sqlitex
Package sqlitex 在 glebarez/sqlite 之上补一条 database/sql 不会替你做的事: **事务结束后保证连接是干净的**。
|
Package sqlitex 在 glebarez/sqlite 之上补一条 database/sql 不会替你做的事: **事务结束后保证连接是干净的**。 |
|
Package quota 实现配额/余量数据的采集、归一与缓存。
|
Package quota 实现配额/余量数据的采集、归一与缓存。 |
|
modelmeta
Package modelmeta 从公开的聚合模型数据库取「模型能力」与「每百万 token 价格」, 为「模型 × 上游」关联弹窗生成预填建议。
|
Package modelmeta 从公开的聚合模型数据库取「模型能力」与「每百万 token 价格」, 为「模型 × 上游」关联弹窗生成预填建议。 |
|
tools
|
|
|
versiondb
command
Command versiondb 用「某个历史版本自己的代码」建出一个该版本的数据库文件。
|
Command versiondb 用「某个历史版本自己的代码」建出一个该版本的数据库文件。 |