Metadata-Version: 2.5
Name: llmhotspot
Version: 0.8.2
Summary: Share an LLM API quota across devices on a local network (Wi-Fi hotspot, USB tether, or LAN)
Project-URL: Homepage, https://github.com/patrickSevans123/llmhotspot
Project-URL: Issues, https://github.com/patrickSevans123/llmhotspot/issues
Author: Patrick Sevans
License: MIT
License-File: LICENSE
Keywords: anthropic,hotspot,llm,openai,proxy,tethering
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Requires-Dist: aiosqlite>=0.20
Requires-Dist: cbor2>=5.4
Requires-Dist: fastapi>=0.136.3
Requires-Dist: httpx>=0.28.1
Requires-Dist: keyring>=24.0
Requires-Dist: litellm>=1.86.2
Requires-Dist: prompt-toolkit>=3.0.52
Requires-Dist: psutil>=6.0
Requires-Dist: pydantic>=2.13.4
Requires-Dist: pyrage>=1.2
Requires-Dist: rfc3161ng>=2.0.4
Requires-Dist: rich>=13.7
Requires-Dist: tomli-w>=1.0
Requires-Dist: typer>=0.26.7
Requires-Dist: uvicorn[standard]>=0.41
Requires-Dist: zeroconf>=0.149.16
Provides-Extra: dev
Requires-Dist: freezegun>=1.5; extra == 'dev'
Requires-Dist: hypothesis>=6.0; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# llmhotspot

[![CI](https://github.com/patrickSevans123/llmhotspot/actions/workflows/ci.yml/badge.svg)](https://github.com/patrickSevans123/llmhotspot/actions/workflows/ci.yml)
[![nightly](https://github.com/patrickSevans123/llmhotspot/actions/workflows/nightly.yml/badge.svg)](https://github.com/patrickSevans123/llmhotspot/actions/workflows/nightly.yml)
[![MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/patrickSevans123/llmhotspot/blob/main/LICENSE)
[![Python 3.10+](https://img.shields.io/badge/python/3.10%2B-blue.svg)](https://www.python.org/)

> **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-all** — `POST /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 mode** — `POST /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 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}/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](https://www.home-assistant.io/), 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](#30-second-try) below |
| **Share across two laptops on the same Wi-Fi** | [5-minute quickstart](#5-minute-quickstart-real-install) — host on one, `llmhs join` on the other |
| **Use the key from a phone, no Python install on the phone** | [iOS Shortcut recipe](https://github.com/patrickSevans123/llmhotspot/blob/main/docs/ios-shortcut.md) — the phone only needs an HTTP client |
| **Run on a VPS / share with devices on different networks** | [docs/tailscale.md](https://github.com/patrickSevans123/llmhotspot/blob/main/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](https://github.com/patrickSevans123/llmhotspot/blob/main/docs/SECURITY.md) — mTLS is mandatory for public exposure |
| **Audit every request for compliance** (HIPAA, SOC 2, FERPA, PCI-DSS) | [docs/COMPLIANCE.md](https://github.com/patrickSevans123/llmhotspot/blob/main/docs/COMPLIANCE.md) — KMS-backed, hash-chained, RFC 3161 timestampable |
| **Watch live per-device spend** | [`llmhs top`](#the-chatbox-repl) — 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](http://HOST:8080/admin/ui/getting-started) has the copy-pasteable `llmhs join` one-liner) |

If none of these match, you probably want a hosted API gateway
([Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/),
[Portkey](https://portkey.ai/), [OpenRouter](https://openrouter.ai/))
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.

```bash
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

```bash
pip install llmhotspot
```

…or from source for development:

```bash
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

```bash
# 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

```bash
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

```bash
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

```python
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](#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](https://github.com/patrickSevans123/llmhotspot/blob/main/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](https://github.com/BerriAI/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:

```toml
# 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"
```

```python
# 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](#discovery).

### "We're on different networks entirely"
Install [Tailscale](https://tailscale.com/) 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. See [docs/tailscale.md](https://github.com/patrickSevans123/llmhotspot/blob/main/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):

```bash
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/SECURITY.md](https://github.com/patrickSevans123/llmhotspot/blob/main/docs/SECURITY.md) and run
`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 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](https://github.com/patrickSevans123/llmhotspot/blob/main/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
├── 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
├── 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.

```toml
[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](https://github.com/patrickSevans123/llmhotspot/blob/main/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](https://github.com/patrickSevans123/llmhotspot/blob/main/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](https://github.com/patrickSevans123/llmhotspot/blob/main/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`:

```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](https://github.com/patrickSevans123/llmhotspot/blob/main/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](https://github.com/patrickSevans123/llmhotspot/blob/main/docs/cert-pinning.md) §5 for the
  MITM-investigation checklist before resetting.

---

## Programmatic / service-mode install

Don't want to keep a terminal open?

```bash
# 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](https://github.com/patrickSevans123/llmhotspot/blob/main/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

```bash
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.1 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

* **v0.8.1** is the current release line. See [CHANGELOG.md](https://github.com/patrickSevans123/llmhotspot/blob/main/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.8,<0.9`).
* Spec lives in [SPEC.md](https://github.com/patrickSevans123/llmhotspot/blob/main/SPEC.md); architecture diagram and
  data flow in [docs/architecture.md](https://github.com/patrickSevans123/llmhotspot/blob/main/docs/architecture.md);
  the long-form threat model in [docs/SECURITY.md](https://github.com/patrickSevans123/llmhotspot/blob/main/docs/SECURITY.md);
  the regulated-deployment guide in [docs/COMPLIANCE.md](https://github.com/patrickSevans123/llmhotspot/blob/main/docs/COMPLIANCE.md).

---

## License

MIT. See [LICENSE](https://github.com/patrickSevans123/llmhotspot/blob/main/LICENSE).

## Acknowledgments

* [LiteLLM](https://github.com/BerriAI/litellm) — the upstream
  call surface, with 100+ provider adapters.
* [zeroconf](https://github.com/python-zeroconf/python-zeroconf)
  — mDNS announcement and browsing.
* [FastAPI](https://fastapi.tiangolo.com/) +
  [uvicorn](https://www.uvicorn.org/) for the proxy, [rich](https://github.com/Textualize/rich)
  for the terminal UI, [Typer](https://typer.tiangolo.com/) 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.

[LiteLLM]: https://github.com/BerriAI/litellm
[OpenAI]: https://platform.openai.com/docs/api-reference/chat
