Skip to main content

llmhotspot

CI nightly MIT Python 3.10+

Share an LLM API key across your devices. Run a tiny proxy on the device that has the key, and every other device on the same Wi-Fi, hotspot, or Tailscale network can hit it as if it were a regular OpenAI endpoint. No /etc/hosts edits, no reverse proxy, no firewall holes. The wire protocol is the same POST /v1/chat/completions the OpenAI SDK already speaks.

 ┌──────────┐    hotspot / LAN / Tailscale     ┌──────────┐    internet    ┌──────────┐
 │ Receiver │  ─── OpenAI-shape over HTTP ───► │   Host   │  ── LiteLLM ── │ Upstream │
 │ (any)    │                                  │  (CLI)   │                │ provider │
 └──────────┘                                  └──────────┘                └──────────┘

The host handles pairing (one 6-digit PIN, expires in 10 minutes), per-device token quota, a tamper-evident admin audit log, and auto-discovery via mDNS or Tailscale magic DNS. The receiver side is just "give me my OpenAI-shaped URL and bearer token" — every existing SDK works untouched.


v0.8 highlights

  • Pause-allPOST /admin/hotspot/pause (or the dashboard toggle) stops every device-facing surface at once: chat, embeddings, models, and pairing return 503 hotspot_paused until you unpause. Pairs with the idle auto-off (host.idle_shutdown_minutes): a host left unused for N minutes shuts itself down gracefully to save tokens and power.
  • Private modePOST /admin/hotspot/private stops the mDNS announcement, so the hotspot stops advertising itself on the LAN. Devices that already know the host URL keep working.
  • QR connect card — the admin UI's card page renders a scannable QR that encodes the host URL + pairing page, with the current PIN and the copy-pasteable llmhs join one-liner. Print it and stick it on the router.
  • Guest quota meterGET /v1/me (bearer-authenticated) returns the caller's own per-minute and per-day limits and usage, so a phone can show "1200 / 10000 tokens left today" without admin access.
  • Manual blocklistPOST /admin/devices/{id}/block bans a device permanently (by id and receiver fingerprint), distinct from revoke: a blocked device can never re-pair until an admin unblocks it.
  • Provider auto-failover + alert webhook — with host.failover_enabled, a request that fails on the selected provider with a retryable upstream error (429, 502/503, connection failure, timeout) is retried once on a backup provider. And when a new device pairs, a detached device_paired POST fires to host.alert_webhook_url so you learn about it without watching the dashboard.

Who is this for?

llmhotspot solves a specific problem: you have one device with an LLM API key (laptop, server, Raspberry Pi) and several other devices that want to use it (phone, tablet, another laptop, Home Assistant, an Arduino-class device with an HTTP client, etc.) without each device holding its own copy of the key. Pick the one that matches you:

You want to ... Start here
Try it in 30 seconds with a real API key, no install 30-second try below
Share across two laptops on the same Wi-Fi 5-minute quickstart — host on one, llmhs join on the other
Use the key from a phone, no Python install on the phone iOS Shortcut recipe — the phone only needs an HTTP client
Run on a VPS / share with devices on different networks docs/tailscale.md — Tailscale's auth is the trust boundary; the host auto-detects it
Expose a public-internet endpoint (your own ChatGPT proxy) docs/SECURITY.md — mTLS is mandatory for public exposure
Audit every request for compliance (HIPAA, SOC 2, FERPA, PCI-DSS) docs/COMPLIANCE.md — KMS-backed, hash-chained, RFC 3161 timestampable
Watch live per-device spend llmhs top — the dashboard shows $/window with the v0.7.3 cost column
Skip the CLI entirely Browse to http://<host>:8080/admin/ui/ for the web admin (rotate admin token, revoke devices, see the audit log, the getting-started page has the copy-pasteable llmhs join one-liner)

If none of these match, you probably want a hosted API gateway (Cloudflare AI Gateway, Portkey, OpenRouter) instead. llmhotspot is for the case where one device holds the key and the others want to use it without holding the key themselves.


30-second try

The fastest way to know if llmhotspot is for you: a throwaway host in a temp dir, with your real API key, ending in a chat REPL.

pip install llmhotspot
export LLMHS_TRY_API_KEY=sk-...
llmhs try

You'll see a host start, a PIN printed, the receiver pair against it, and a chat prompt. Type anything, get a model reply. Ctrl-C to clean up — your real config at ~/.config/llmhotspot/ (or %APPDATA%\llmhotspot\ on Windows) is never touched.

llmhs try is a sandbox: nothing in the sandbox survives the process exit. Use it to evaluate, demo, or write a test.

The host does a quick pre-flight before binding the port (llmhs doctor runs inline): mDNS responder parseable, the proxy port is free, the upstream is reachable. A failure surfaces as a "Continue anyway? [Y/n]" prompt in a TTY, or a hard-abort with an actionable error message in a non-TTY (CI, systemd, Docker). --skip-preflight bypasses the check for offline development.


Install

pip install llmhotspot

…or from source for development:

git clone https://github.com/patrickSevans123/llmhotspot
cd llmhotspot
pip install -e ".[dev]"

The CLI installs as llmhs. Python 3.10 or newer.


5-minute quickstart (real install)

This is the real flow: host on one machine, receiver on another on the same network. The PIN pair is the only coordination needed.

1. On the host — point llmhotspot at your upstream

# interactive: asks which provider, then stores the key in your OS keyring
llmhs init

# or fully scripted, e.g. for a custom OpenAI-compatible provider:
llmhs init \
  --provider=custom \
  --api-base=https://api.aetherapi.dev/v1 \
  --model=gpt-4o-mini

The API key is stored in the OS keyring (Windows Credential Manager, macOS Keychain, Secret Service on Linux) by default. Pass --no-keyring to keep it in config.toml instead.

2. On the host — start the proxy

llmhs host

You'll see something like this:

 ┌─ llmhotspot ─────────────────────────────────────────────────────────────────┐
 │                                                                              │
 │  llmhotspot host ready                                                       │
 │                                                                              │
 │   PIN               482917  (expires in 10:00)                               │
 │   URL               http://192.168.2.1:8080                                  │
 │   Admin             ah_K7mN9pQ...m4n5  (use for /admin/*)                    │
 │   Upstream          OpenAI ✓ healthy                                         │
 │   Devices           0 / 5 paired                                             │
 │                                                                              │
 └─ awaiting devices… ──────────────────────────────────────────────────────────┘

The PIN is the only thing you need to copy to the receiver.

3. On the receiver — pair and grab a token

llmhs join --host http://192.168.2.1:8080 --pin 482917
 ✓ paired
   URL     http://192.168.2.1:8080
   Token   lh_AbCdEfGhI...defg  (right-click to copy)
   TTL     30d until re-pair needed

The token persists to the receiver's config.toml (mode 0600).

4. On the receiver — use any OpenAI SDK

from openai import OpenAI
client = OpenAI(
    base_url="http://192.168.2.1:8080/v1",
    api_key="lh_AbCd...",
)
print(client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "hi"}],
).choices[0].message.content)

…or via curl / env vars / LangChain / LlamaIndex — anything that speaks OpenAI's HTTP shape works.

If llmhs join doesn't see a --host flag, it tries mDNS and common hotspot IPs automatically. See Discovery.


Use it from a phone (no Python on the phone)

iOS Shortcuts and Android Tasker can both speak HTTP, so a phone doesn't need llmhs join — it just calls the same /v1/pair and /v1/chat/completions endpoints directly. The repo ships an iOS Shortcut recipe; the Android equivalent is two HTTP Request actions. See docs/ios-shortcut.md for the exact JSON.

The web admin (http://<host>:8080/admin/ui/) is a useful companion for the phone-receiver case: the host's operator can browse to the "getting started" page, see the active PIN and the copy-pasteable llmhs join one-liner, and watch the receiver's bearer token appear in the devices list the moment it pairs. No SSH required.


What can be the upstream?

Anything that speaks the OpenAI chat-completions protocol, because llmhotspot delegates the actual upstream call to LiteLLM. Some common options:

Provider llmhs init preset Notes
OpenAI openai Default. gpt-4o-mini is the default model.
Anthropic anthropic Routes through LiteLLM.
OpenRouter openrouter One key, every model OpenRouter proxies.
Ollama (local) ollama --api-base http://localhost:11434/v1. No key needed.
vLLM / LM Studio / LocalAI custom Point --api-base at your local server.
Anything OpenAI-shaped custom --api-base <url> + --api-key <key>.

Run llmhs init --list-presets to dump the preset table as JSON.

Multiple upstreams at once

v0.7.0+ supports registering more than one provider in the same host. The receiver picks at request time with the X-LLMHotspot-Provider header:

# config.toml on the host
default_provider = "openai"

[providers.openai]
api_key       = "sk-..."
api_base      = "https://api.openai.com/v1"
default_model = "gpt-4o-mini"

[providers.anthropic]
api_key       = "sk-ant-..."
api_base      = "https://api.anthropic.com/v1"
default_model = "claude-3-5-sonnet-latest"
# on the receiver
claude = OpenAI(
    base_url="http://192.168.2.1:8080/v1",
    api_key="<bearer>",
    default_headers={"X-LLMHotspot-Provider": "anthropic"},
)

Each provider keeps its own credentials, connection pool, and /v1/models cache. The OpenAI-shaped wire is unchanged.


Operating scenarios

Pick the one that matches your setup.

"We're on the same Wi-Fi"

Nothing to configure. Default bind=0.0.0.0, no flags. The host listens on the LAN and advertises itself via mDNS.

"Host on a phone's hotspot"

Same code, no extra flags. The phone's hotspot usually gives the host a 192.168.43.x (Android) or 172.20.10.x (iOS) address. llmhs join falls back to probing those subnets if mDNS doesn't find the host. See Discovery.

"We're on different networks entirely"

Install Tailscale on both devices. The host auto-detects Tailscale and advertises itself as a Tailscale HTTPS service with a magic-DNS name. The receiver pairs against https://<host>.tailnet.ts.net and Tailscale's own auth is the trust boundary. Run llmhs net check first to see which access paths your network supports. See docs/tailscale.md.

"I'm running on a VPS with a public IP"

You must enable mTLS, otherwise the host refuses to start (this is the v0.7.0-rc1 S-09 hard-fail):

llmhs host --tls --mtls-required --bind 0.0.0.0

For zero-trust public exposure, prefer Cloudflare Tunnel or Tailscale Funnel in front of the host. See docs/cloudflare-tunnel.md and docs/SECURITY.md. Run llmhs net check first to see which access paths your network supports, then llmhs doctor --exposure to audit your config.


Discovery

llmhs join tries three things, in order, until one works:

  1. --host <ip:port> — if you pass it, this always wins.
  2. mDNS — looks for _llmhs._tcp.local., 3s timeout. Works on plain Wi-Fi and Ethernet, blocked on some Android hotspots.
  3. Hardcoded subnet probes192.168.43.1 (Android hotspot), 192.168.137.1 (Windows ICS), 172.20.10.1 (iOS hotspot), plus the host's last-known Tailscale address from ~/.config/llmhotspot/known_hosts.json.

The PIN itself is only sent in the POST /v1/pair request — it never goes out over mDNS or broadcast. See docs/architecture.md §4 for the full discovery state machine.


Operating the host

The CLI is small. Top-level commands:

llmhs
├── init           scaffold a config (interactive or scripted)
├── host           start a host                  (long-running; Ctrl-C to stop)
├── join           pair with a host              (long-running client)
├── try            one-shot sandbox: host + join + chat, in a temp dir
├── chatbox        interactive REPL against the host
├── top            live per-device rate dashboard
├── status         one-shot health snapshot
├── logs           tail the host's JSON log
├── doctor         pre-flight checks (mDNS, port, upstream, TLS)
├── doctor --exposure  audit your config for public-exposure risks
├── net            network diagnostics (remote-access feasibility)
│   └── check      which access paths does this network support?
├── explain        plain-English description of an error code or topic
├── install-service    write a systemd unit / launchd plist / nssm
├── uninstall-service  remove the service
├── self-check     local-only install verification
├── clean          sweep stale pid/port files after a crash
├── admin          inspect / rotate the host's admin token
│   ├── audit      show / verify / archive the v0.6.0 audit ledger
│   ├── unban      unblock a device revoked by the anomaly detector
│   └── kms-reinit re-key the audit-ledger KMS / CA (destructive)
├── config         show / set / edit / path the TOML config
└── version        llmhotspot, python, platform

Every flag and every default is in llmhs <subcommand> --help. The "what does this error mean" loop is llmhs explain <code>.

Common llmhs host flags

Flag Default What it does
--provider-key / LLMHS_API_KEY from config Upstream API key.
--api-base https://api.openai.com/v1 Any OpenAI-compatible base URL.
--model gpt-4o-mini Default when the client doesn't pick.
--port 8080 Proxy + pair port.
--admin-port 8081 /admin/* port. 0 disables.
--max-devices 5 Hard cap on paired devices.
--max-tokens-per-min 100000 Global upstream token cap. 0 = unlimited.
--max-tokens-per-day-per-device 0 Per-device daily cap. 0 = no cap.
--pin-ttl 10m PIN expiry.
--tls false Serve the proxy over self-signed HTTPS.
--mtls-required false Require mTLS for /v1/*. Needed for public exposure.
--global-pin-lockout 20 Max wrong PINs per 60s across all source IPs.
--manage-hotspot false Have the host bring up the sharing network itself.

Per-model and per-device limits

The host owner decides which models are shared and what the per-request token caps are, via [host.model_policy] in config.toml. A specific device can have a stricter allowlist than the host-wide default via [host.per_device_overrides]. Both are picked up on the next request — no host restart.

[host.model_policy]
allowed_models      = ["gpt-4o-mini", "gpt-4o"]    # empty = auto-discover
max_input_tokens    = 8000                          # 0 = unlimited
max_output_tokens   = 2000

[host.model_policy.max_input_tokens_per_model]
"gpt-4o"      = 32000
"gpt-4o-mini" = 16000

[host.per_device_overrides."00000000-0000-0000-0000-000000000001"]
max_tokens_per_min = 5_000
max_input_tokens   = 2_000
allowed_models     = ["gpt-4o-mini"]   # must be a subset of the host allowlist

Violations surface as OpenAI-shaped JSON errors with a stable error.code (model_not_allowed, input_too_long, output_too_long). Full reference in docs/api.md.

Observability

  • GET /healthz — deep health check. Probes the usage DB, the upstream, and the state DB. Cached for 10s. Returns 503 with the per-check breakdown on failure. curl http://host:8080/healthz.
  • GET /metrics — Prometheus text exposition. Eight metrics under the llmhotspot_ prefix: request counter, token counter (prompt/completion/total), request duration histogram, quota exceeded counter, pair-attempt counter, reimburse-failure counter, upstream-up gauge. See docs/api.md §6.
  • llmhs top — live per-device dashboard: RPM used, last request, last error. Ctrl-C to exit.
  • llmhs logs — tail the host's structured JSON log.

The v0.6.0 audit ledger (hash-chained, KMS-backed, RFC 3161 timestampable) is for regulated deployments — HIPAA, SOC 2, FERPA, PCI-DSS — not for casual use. See docs/COMPLIANCE.md.


The chatbox REPL

llmhs chatbox is an interactive terminal that talks to the host like the OpenAI SDK but adds slash commands:

$ llmhs chatbox
llmhotspot chat
  /help                list every command
  /host <url>          switch the host URL
  /pin <pin>           pair with a 6-digit PIN
  /provider <name>     switch upstream provider
  /model <name>        switch model
  Ctrl-C / /exit       leave

[default/gpt-4o-mini] > /pin 482917
  paired. token=lh_AbCd...
[default/gpt-4o-mini] > /source security
  loaded 'security' (14238 chars) as context for the next turn.
[default/gpt-4o-mini] > what's the cert-pinning workflow?

Built-in: /help, /exit, /clear, /provider, /model, /system, /source <topic>, /usage, /tokens, /save <path>, /load <path>, /history, /host, /pin.

Custom slash commands in config.toml:

[chatbox.custom_commands.translate]
body = "Translate to formal English, preserving technical terms:\n\n{arg}"

[chatbox.custom_commands.explain-config]
body = "Read the llmhotspot config docs and walk me through my setup."

{arg}, {provider}, {model}, {host} are substituted before the body goes to the model.


Security model — the 30-second version

v0.8.x ships the following on by default. See docs/SECURITY.md for the deep dive.

  • Per-device bearer tokens with a SHA-256 hash stored on the host. A dump of the host's usage.db can't be used to forge requests.
  • 6-digit PIN pair flow with a global lockout (20 wrong PINs per minute → 5 minute lockout across all source IPs). Defeats the Android-hotspot NAT-DoS attack v0.1 was vulnerable to.
  • Pairing tokens are host-bound (v0.7.0-rc1 S-02). A token captured from host A cannot be replayed against host B.
  • Self-signed TLS on the proxy port with TOFU cert pinning on the receiver (same model as SSH known_hosts).
  • Hard-fail on bind=0.0.0.0 + admin_port != 0 + mtls_required=False (v0.7.0-rc1 S-09). VPS operators literally cannot start the host in an unsafe config without setting LLMHS_ALLOW_INSECURE_ADMIN=1.
  • Tailscale integration for cross-NAT sharing with real Let's Encrypt certs and magic-DNS discovery.
  • Token-binding fingerprint (optional, --device-fingerprint): the host can refuse a request whose X-LLMHotspot-Fingerprint doesn't match what the receiver sent at pair time.

What v0.7.x does not defend against:

  • A device already on the LAN before pairing.
  • A compromised upstream API key being exfiltrated over the wire. Rotate the upstream key AND llmhs admin rotate-token if you suspect a leak.
  • ARP spoofing / evil-twin hotspots. The HTTP-only LAN flow still assumes a trusted LAN.
  • A user who runs llmhs join --reset-pin reflexively on a fingerprint-mismatch warning they didn't expect. See docs/cert-pinning.md §5 for the MITM-investigation checklist before resetting.

Programmatic / service-mode install

Don't want to keep a terminal open?

# systemd (Linux)
llmhs install-service
systemctl --user enable --now llmhotspot

# launchd (macOS)
llmhs install-service
launchctl load ~/Library/LaunchAgents/com.llmhotspot.host.plist

# NSSM (Windows; run elevated)
llmhs install-service

Use llmhs uninstall-service to remove. The unit / plist / service uses your existing config.toml and the keyring the current user can already see.


Troubleshooting

Symptom Try this
Host prints "Upstream ✗ unreachable" llmhs doctor — checks mDNS, port bind, upstream reach. The most common cause is a wrong --api-base or the upstream blocking your egress IP.
Receiver says "host_unreachable" llmhs join --host <ip:port> to bypass discovery, then llmhs status --host <ip> on the host side.
llmhs try errors with "no --api-key" export LLMHS_TRY_API_KEY=sk-... and retry. llmhs try is non-interactive; it needs the key up front.
init says "no keyring backend available" Either install one (Windows/macOS have one by default; on Linux install gnome-keyring or kwallet) or pass --no-keyring to keep the key in config.toml.
host refuses to start with "admin exposed" You set --bind 0.0.0.0 with --admin-port non-zero without --mtls-required. Either add --mtls-required, set --admin-port 0, or export LLMHS_ALLOW_INSECURE_ADMIN=1 (not recommended for production).
Token expired llmhs join --host <url> --pin <new-pin> and the new token replaces the old one. The old token is invalidated on the next request.
CI failures with empty steps array and 2-7s completion Infrastructure (no runner / quota). See docs/CI_TROUBLESHOOTING.md for make ci-local.
Need to know what an error code means llmhs explain <code> — plain-English, with the relevant doc link.

llmhs doctor is always the first thing to try; it checks every layer the host depends on and prints actionable fixes.


Development

git clone https://github.com/patrickSevans123/llmhotspot
cd llmhotspot
pip install -e ".[dev]"

make ci-local        # mirrors .github/workflows/ci.yml: lint + typecheck + security + test
make test            # pytest
make test-e2e        # full real-upstream smoke against an OpenAI-compatible URL

Test totals (v0.8.x line): ~1424 tests collected, full suite green (0 failures), mypy strict 0 errors, ruff clean.

The CI matrix runs on Linux, macOS, and Windows; the nightly job runs the full real-upstream smoke against a configured provider to catch the class of regression a fake-keyring test suite can't see (the v0.7.3 release notes describe one such bug that was caught this way — the keyring:// URI parser was splitting on the wrong /).


Project status

  • 0.9.1 is the current release line. See CHANGELOG.md for the full release history.
  • Pre-1.0; minor version bumps may include small breaking changes to the config schema. Pin to a minor in production (llmhotspot>=0.9,<1.0).
  • Spec lives in SPEC.md; architecture diagram and data flow in docs/architecture.md; the long-form threat model in docs/SECURITY.md; the regulated-deployment guide in docs/COMPLIANCE.md.

License

MIT. See LICENSE.

Acknowledgments

  • LiteLLM — the upstream call surface, with 100+ provider adapters.
  • zeroconf — mDNS announcement and browsing.
  • FastAPI + uvicorn for the proxy, rich for the terminal UI, Typer for the CLI.
  • The token-bucket rate limiter in src/llmhotspot/ratelimit/buckets.py is hand-rolled (under 200 lines) for a clean release() primitive and zero third-party deps.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

llmhotspot-0.9.1.tar.gz (1.3 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

llmhotspot-0.9.1-py3-none-any.whl (563.4 kB view details)

Uploaded Python 3

File details

Details for the file llmhotspot-0.9.1.tar.gz.

File metadata

  • Download URL: llmhotspot-0.9.1.tar.gz
  • Upload date:
  • Size: 1.3 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.6.4

File hashes

Hashes for llmhotspot-0.9.1.tar.gz
Algorithm Hash digest
SHA256 8dfcbfdc8a74a0afd7b246d3e62e706ddac65fb5618e71f35bf5aec2efd0c34e
MD5 07725825601832a90f097217bdcdd038
BLAKE2b-256 423ef0a5cf1d86e31eb21cef2998c213eff309286b4b6df2a6ba7765fce98a5e

See more details on using hashes here.

File details

Details for the file llmhotspot-0.9.1-py3-none-any.whl.

File metadata

  • Download URL: llmhotspot-0.9.1-py3-none-any.whl
  • Upload date:
  • Size: 563.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.6.4

File hashes

Hashes for llmhotspot-0.9.1-py3-none-any.whl
Algorithm Hash digest
SHA256 eec7e381f4a9732094aab4c731c8d42b8e5444bd33d4c3f3068875c68ef22e08
MD5 71f696f0bd8c9007c38b4b4a3ca8be16
BLAKE2b-256 a94bc689a696fcef5998c7f9ab103c43f8706e3a8bc8d23280ac5bbcf48ccf8a

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page