llmhotspot
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/hostsedits, no reverse proxy, no firewall holes. The wire protocol is the samePOST /v1/chat/completionsthe 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-all —
POST /admin/hotspot/pause(or the dashboard toggle) stops every device-facing surface at once: chat, embeddings, models, and pairing return 503hotspot_pauseduntil 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 mode —
POST /admin/hotspot/privatestops 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
cardpage renders a scannable QR that encodes the host URL + pairing page, with the current PIN and the copy-pasteablellmhs joinone-liner. Print it and stick it on the router. - Guest quota meter —
GET /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 blocklist —
POST /admin/devices/{id}/blockbans 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 detacheddevice_pairedPOST fires tohost.alert_webhook_urlso 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:
--host <ip:port>— if you pass it, this always wins.- mDNS — looks for
_llmhs._tcp.local., 3s timeout. Works on plain Wi-Fi and Ethernet, blocked on some Android hotspots. - Hardcoded subnet probes —
192.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 thellmhotspot_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.dbcan'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 settingLLMHS_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 whoseX-LLMHotspot-Fingerprintdoesn'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-tokenif 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-pinreflexively 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.pyis hand-rolled (under 200 lines) for a cleanrelease()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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8dfcbfdc8a74a0afd7b246d3e62e706ddac65fb5618e71f35bf5aec2efd0c34e
|
|
| MD5 |
07725825601832a90f097217bdcdd038
|
|
| BLAKE2b-256 |
423ef0a5cf1d86e31eb21cef2998c213eff309286b4b6df2a6ba7765fce98a5e
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eec7e381f4a9732094aab4c731c8d42b8e5444bd33d4c3f3068875c68ef22e08
|
|
| MD5 |
71f696f0bd8c9007c38b4b4a3ca8be16
|
|
| BLAKE2b-256 |
a94bc689a696fcef5998c7f9ab103c43f8706e3a8bc8d23280ac5bbcf48ccf8a
|