llmio

atopos31MIT活跃维护

统一的多模型 LLM 网关,把 Claude、Gemini、DeepSeek 等上游统一接入,提供权重负载均衡、可观测性和调用费用追踪,适合多模型混用场景。

Unified LLM gateway with weighted load balancing, observability & cost tracking. 统一的 LLM 网关,提供权重负载均衡、可观测性与费用追踪。

v0.10.3发布于 2026-10-29 17:40 UTC324star +2 / 7 天44fork同步于 2026-11-11 00:00 UTC

怎么上手

由 README 提炼,执行前请确认
docker compose up -d
硬件要求按 README 说明

README 未说明;为 Go 单二进制加纯 Go SQLite,本地持久化到 ./db/llmio.db,未提及 GPU 或显存要求

有 Docker 镜像
下载:源码 zip·GitHub Releases(安装包)本站不镜像安装包

项目说明

由 README 自动翻译提炼

统一 LLM 网关,把多家模型接入一个服务,做权重路由、可观测与费用追踪

统一的多模型 LLM 网关,把 Claude、Gemini、DeepSeek 等上游统一接入,提供权重负载均衡、可观测性和调用费用追踪,适合多模型混用场景。

核心能力

  • 统一 OpenAI、Anthropic、Gemini 原生协议入口,支持流式与非流式透传
  • 客户端与上游协议不一致时自动翻译请求与流式事件
  • 按权重随机或按权重优先级两种策略调度,可按工具调用、结构化输出、多模态路由
  • 记录 TraceID、延迟分段、TPS、token 用量与按百万 token 单价计算的单次费用
  • 内置 React 管理台,覆盖仪表盘、分析、供应商、模型路由、日志、配额与密钥

容易踩的坑

公开访问必须设置 TOKEN 环境变量,否则控制台与 API 无鉴权;默认监听 7070 端口,需用 LLMIO_SERVER_PORT 修改;本地从源码运行需 Go >= 1.26.1,构建前端还需 pnpm

内容由模型根据 README 生成,可能不完整或不准确,以官方文档为准。看原文 README

最近发版

5 个版本v0.10.3
  1. v0.10.3最新正式版2026-10-29 · 2026-10-29 17:40 UTC

    修复:打开「分析」页不再可能导致服务重启

    此前在日志量较大的实例上,打开分析页会把所选时间范围内的全部记录一次性读进内存。 时间范围选得越宽,占得越多;内存较小的部署会因此被撑爆、服务重启。

    现在改为逐条读取、边读边算,内存占用基本不再随时间范围增长:30 天窗口的实测占用 从约 49 MiB 降到约 2.5 MiB。

    更快:分析页出数明显变快

    • 纠正了一条长期被查询选错的索引,统计查询不再退化成全表扫描。
    • 读取记录时只取需要的字段,省去逐行转换的开销。

    同样的时间范围,取数耗时约降到原来的一半;时间范围越宽,差别越明显。

    更好用:加载过程看得见了

    • 慢的时候显示已经等了多少秒,并且可以随时取消。
    • 超过 10 秒会提示「可以先看近 24 小时」,一键切换过去。
    • 取消之后不会误报「没有数据」,而是给出「重新加载」的按钮。
    • 切换筛选条件时,上一次的结果不会盖掉新的结果。
    • 最近看过的时间范围会暂存一分钟,切回来立刻显示。
    • 几百毫秒就完成的加载不会再闪一下加载提示。

    构建

    镜像同时提供 amd64 与 arm64 两个架构。

  2. 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 可能带有阶段前缀,按裸文本精确匹配的外部告警需改为包含匹配。

  3. v0.10.12026-10-24 · 2026-10-24 17:21 UTC
    查看更新说明
    • 修复 Anthropic 流式用量统计:输入与缓存桶从 message_start 起按字段累计,成功请求不再把 prompt 侧记成 0
    • 缓存创建 token 计入总输入量,原生响应与协议互转两条链路同口径
    • OpenAI 流式收尾的全零用量快照不再被丢弃;Anthropic→OpenAI 互转等到真实 stop 事件才收尾,中间的累计用量更新不再提前断流
  4. 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);模板仍默认关闭
  5. 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 内置适配器

用户点评

登录用户的真实使用记录,可按版本和硬件筛选
共 0 条

    还没有人点评。第一个说出真实体验的人最有价值。

    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

    LLMIO 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_id in any request body (works with extra_body in OpenAI SDK) to tag logs with a session identifier. Filter and search by session_id in the admin UI or via GET /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

    VariableDescriptionDefaultNotes
    TOKENConsole login and API auth for /openai /anthropic /gemini /v1NoneRequired for public access
    GIN_MODEGin runtime modedebugUse release in production
    LLMIO_SERVER_PORTServer listen port7070Service listen port
    TZTimezone for logs and schedulingHost defaultRecommend explicit setting in containers (e.g. Asia/Shanghai)
    DB_COMPRESSStore newly written request bodies in compressed formtrueSetting it to false affects new rows only; previously compressed rows remain readable
    DB_VACUUMRun SQLite VACUUM once on startupDisabledSet to true to enable. VACUUM takes an exclusive write lock and needs free disk of about 2× the database size
    DB_AUTO_VACUUM_REBUILDRebuild an existing database on startup to enable incremental space reclamationautoauto 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_CONFIGPath of the quota configuration file./db/quota.config.jsonChanges only the file location; its contents (credentials included) are read and written by the console and never stored in the database
    LLMIO_QUOTA_ALLOW_WRITEWhether quota write endpoints are enabledWrites allowedOnly 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 / anthropic upstreams, an Anthropic client anthropic / 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_stop and 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 covers top_k, thinking and reasoning_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 bridged warning.

    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-body session_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-Encoding are 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_string and of_string_array columns 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_COMPRESS is 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 with auto_vacuum=0 it 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_vacuum setting. 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

    PathMethodDescription
    /api/logs/compressionGETMigration status and progress, migration policy, reclamation policy, database statistics, the last reclamation result, and which maintenance task is running right now
    /api/logs/compression/policyPUTSet migration policy (batch size, pause between batches, quiescence window, background auto-advance)
    /api/logs/compression/runPOSTStart migration
    /api/logs/compression/pausePOSTPause migration (takes effect after the current batch completes)
    /api/logs/compression/decompressGETQuery rollback progress
    /api/logs/compression/decompressPOSTRoll back to plain text
    /api/logs/compression/reclaimPOSTReclaim space. Body {"continuous": true} reclaims until finished; an empty body runs a single round (up to 90 seconds)
    /api/logs/compression/reclaim/stopPOSTStop reclamation (takes effect after the current batch completes)
    /api/logs/compression/reclaim/policyPUTSet reclamation policy (minimum reclaimable space, check interval)
    /api/logs/compression/vacuumPOSTRebuild 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:

    ParameterAccepted rangeOut-of-range behaviour
    Rows per batch batch_rows1–4096Below 1 falls back to the default 64; above 4096 becomes 4096
    Bytes per batch batch_bytesup to 1 GiBBelow 1 falls back to the default 32 MiB; above becomes 1 GiB
    Pause between batches batch_interval_ms0–5000 msNegative values become 0
    Quiescence window quiesce_sec0–86400 sNegative values become 0
    Minimum reclaimable space min_bytes0–1 TiBNegative values become 0
    Check interval check_interval_sec60–86400 sBelow 60 becomes 60

    Caveats

    • Body columns must not be used for SQL text comparison. A compressed row stores a binary frame, so LIKE, json_extract and similar queries against the body will not match. To read a single chat IO entry, use GET /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.bak is 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=false stops 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) and custom (a generic endpoint whose path you supply, read through itemsPath plus a field mapping). Login-based adapters need their credentials in the source's own env (keys SCNET_USER / SCNET_PASS and OPENCODE_COOKIE / OPENCODE_ORG_ID): those upstreams require a real login and session, which a script cannot reproduce, so they live in Go. commandcode reads its API key from the source's apiKey and hits two endpoints in one refresh — /alpha/billing/credits for the 5-hour, weekly and monthly credit windows, and /alpha/billing/subscriptions for 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 own env allow-list and, when explicitly enabled, fetch; there is no require, no filesystem and no child process. fetch is 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>):

    PathMethodDescriptionWrite permission
    /api/quota/configGETConfiguration (credentials masked), the built-in adapter list, the config file path, whether writes are enabled, the default refresh interval and warning thresholdNot required
    /api/quota/configPUTChange the global refresh interval and warning thresholdRequired
    /api/quota/sourcesPOSTAdd a data sourceRequired
    /api/quota/sourcesPUTUpdate a source by id; an unknown id is not silently turned into an insertRequired
    /api/quota/sources/:idDELETEDelete a sourceRequired
    /api/quota/runPOSTFetch every enabled source; force=true bypasses the cache, ids=a,b runs only the listed sourcesNot required
    /api/quota/sources/:id/refreshPOSTForce-refresh a single sourceNot required
    /api/quota/testPOSTTest-run a source that has not been saved; nothing is written to disk or cacheNot 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) and POST /api/peak-calendar/holidays/sync (syncs the given year, the current one by default).

    Usage Analytics

    • One endpoint: GET /api/metrics/stats returns the whole slice in a single request, so charts cannot drift apart by asking separately; GET /api/metrics/granularities lists the available granularities.
    • Time: granularities 5m / 15m / 30m / 1h / 2h / 6h / 12h / 1d / 7d; auto picks 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 through pnpm run test:coverage, with the threshold in webui/vitest.config.ts — below it the run fails.
    • e2e/run_matrix.py is the end-to-end acceptance run for protocol bridging. It uses a local stub upstream by default and needs no external credentials; --upstream opencode runs a smaller matrix against a real upstream (consuming quota). e2e/check_compression.py does not trust what the API reports about itself — it opens the database file and checks that the compressed form and auto_vacuum really landed on disk. e2e/check_vacuum.py measures 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 (--leave prepares 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, a gofmt check, go vet, go test and a single-binary build check.

    API Endpoints

    LLMIO provides a multi‑provider REST API with the following endpoints:

    ProviderPathMethodDescriptionAuth
    OpenAI/openai/v1/modelsGETList available modelsBearer Token
    OpenAI/openai/v1/chat/completionsPOSTCreate chat completionBearer Token
    OpenAI/openai/v1/responsesPOSTCreate responseBearer Token
    Anthropic/anthropic/v1/modelsGETList available modelsx-api-key or Bearer Token
    Anthropic/anthropic/v1/messagesPOSTCreate messagex-api-key or Bearer Token
    Anthropic/anthropic/v1/messages/count_tokensPOSTCount tokensx-api-key or Bearer Token
    Anthropic/anthropic/api/event_logging/batchPOSTAccept Claude Code batch event reports and acknowledge them (contents are not stored)No auth
    Gemini/gemini/v1beta/modelsGETList available modelsx-goog-api-key
    Gemini/gemini/v1beta/models/{model}:generateContentPOSTGenerate contentx-goog-api-key
    Gemini/gemini/v1beta/models/{model}:streamGenerateContentPOSTStream contentx-goog-api-key
    Generic/v1/modelsGETList models (compat)Bearer Token
    Generic/v1/chat/completionsPOSTCreate chat completion (compat)Bearer Token
    Generic/v1/responsesPOSTCreate response (compat)Bearer Token
    Generic/v1/messagesPOSTCreate message (compat)x-api-key or Bearer Token
    Generic/v1/messages/count_tokensPOSTCount 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.

    Star History

    Stargazers over time