Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/cpu-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ jobs:
python -m pip install "pytest>=8,<9" packaging
python -m pip install -r requirements.txt
python -m pip install pyyaml numpy aiohttp
# The `web` extra, plus the HTTP client fastapi's TestClient needs.
python -m pip install fastapi uvicorn pydantic httpx

- name: Validate test classification
run: python tools/check_test_classification.py
Expand Down
59 changes: 59 additions & 0 deletions docs/WEB_API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# kvcached control API

`kvweb` serves over HTTP what `kvctl` and `kvtop` show on the terminal: the KV
cache accounting each engine publishes to `/dev/shm`, plus the limit changes
`kvctl limit` performs.

## Install and run

```bash
pip install 'kvcached[web]'

kvweb # or: kvctl web
kvweb --port 9000
```

Interactive documentation is generated by FastAPI at `/docs`, and the OpenAPI
schema at `/openapi.json`.

## Endpoints

| Method | Path | Purpose |
| -------- | -------------------------------- | ------------------------------------------------ |
| `GET` | `/api/status` | GPU memory plus every detected segment |
| `GET` | `/api/ipcs` | Names of the detected segments |
| `GET` | `/api/ipcs/{name}` | Accounting for one segment |
| `POST` | `/api/ipcs/{name}/limit` | Set the limit, e.g. `{"size": "2G"}` |
| `POST` | `/api/ipcs/{name}/limit-percent` | Set the limit, e.g. `{"percent": 40}` |
| `DELETE` | `/api/ipcs/{name}` | Delete the segment and its backing file |
| `GET` | `/api/stream` | Server-sent events repeating `/api/status` |

The API never creates a segment. Segments belong to the engine that started
them, so writing to a name nothing owns returns `404` rather than leaving
behind a segment that `kvtop` would report as a model that does not exist.

```bash
curl localhost:8000/api/status
curl -X POST localhost:8000/api/ipcs/kvcached/limit -d '{"size": "2G"}' \
-H 'Content-Type: application/json'
```

## Security

The mutating endpoints can shrink or delete the KV cache of a running engine,
so the server binds to `127.0.0.1` and sends no CORS headers by default.

Before exposing it more widely:

- Set `KVCACHED_WEB_API_KEY`. Requests then need that value in an `X-API-Key`
header. `/api/stream` also accepts it as an `api_key` query parameter,
because `EventSource` cannot set headers; no other endpoint does, since
query strings end up in access and proxy logs. Leaving the variable unset
disables the check, which is why `--host` warns when it binds to anything
other than loopback.
- Pass `--cors-origin ORIGIN` (repeatable) only if a separate front-end has to
call the API from a browser.

The API key is a single shared secret sent in clear text. It keeps a stray
`curl` on the same network from deleting a segment; it is not a substitute for
putting the server behind a reverse proxy with TLS.
18 changes: 18 additions & 0 deletions kvcached/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,20 @@
# SPDX-FileCopyrightText: Copyright contributors to the kvcached project
# SPDX-License-Identifier: Apache-2.0


def kvweb_main() -> None:
"""Console-script entry point for ``kvweb``.

Kept out of ``kvweb`` itself: that module imports fastapi and uvicorn at
module scope, so without the ``web`` extra the script would fail with an
ImportError traceback instead of saying what to install.
"""
try:
from kvcached.cli.kvweb import main
except ImportError as exc:
raise SystemExit(
f"kvweb could not start: {exc}\n"
"Install the optional dependencies with `pip install kvcached[web]`."
) from exc

main()
90 changes: 87 additions & 3 deletions kvcached/cli/kvctl.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,13 @@ def _clr(text: str, color: Optional[str] = None, *, bold: bool = False) -> str:
return f"{seq}{text}{_ANSI_COLOR_CODES['reset']}"


# Defaults for the `web` subcommand. Loopback, because the API can shrink or
# delete the KV cache of a running engine and ships without authentication.
WEB_DEFAULT_HOST = '127.0.0.1'
WEB_DEFAULT_PORT = 8000

COMMANDS = [
'list', 'limit', 'limit-percent', 'watch', 'kvtop', 'delete', 'help',
'list', 'limit', 'limit-percent', 'watch', 'kvtop', 'web', 'delete', 'help',
'exit', 'quit'
]

Expand All @@ -72,6 +77,7 @@ def _clr(text: str, color: Optional[str] = None, *, bold: bool = False) -> str:
limit-percent <ipc> <pct> Set limit as percentage of total GPU RAM
watch [-n sec] [ipc ...] Continuously display usage table
kvtop [ipc ...] [--refresh r] Launch curses kvtop UI (q to quit)
web [--host H] [--port P] Serve the control API (loopback by default)
!<shell cmd> Run command in system shell
help Show this help message
delete <ipc> Delete IPC segment and its limit entry
Expand Down Expand Up @@ -173,7 +179,7 @@ def _complete(text: str, state: int): # noqa: D401 – simple fn
}


def _parse_size(size_str: str) -> int:
def parse_size(size_str: str) -> int:
"""
Convert human-friendly size strings such as ``512M``, ``1g`` or
``100_000`` into a byte count.
Expand Down Expand Up @@ -266,7 +272,7 @@ def cmd_limit(ipc: str, size_str: str):
print("Active IPC names:", ", ".join(avail), file=sys.stderr)
return

size_bytes = _parse_size(size_str)
size_bytes = parse_size(size_str)
update_kv_cache_limit(ipc, size_bytes)


Expand Down Expand Up @@ -303,6 +309,35 @@ def cmd_top(ipcs: Optional[List[str]] = None, refresh: float = 1.0):
kvtop_ui(ipcs, refresh)


# ---------------------------------------------------------------------------
# Web API command
# ---------------------------------------------------------------------------


def cmd_web(host: str = WEB_DEFAULT_HOST,
port: int = WEB_DEFAULT_PORT,
cors_origins: Optional[List[str]] = None):
"""Serve the control API (blocks until interrupted).

kvweb is imported lazily so that the rest of kvctl keeps working without
the optional web dependencies installed.
"""
try:
from kvcached.cli.kvweb import serve
except ImportError as exc:
print(_clr(f"Cannot start the web API: {exc}", 'red', bold=True),
file=sys.stderr)
print("Install the optional dependencies with "
"`pip install kvcached[web]`.",
file=sys.stderr)
return

try:
serve(host, port, cors_origins)
except KeyboardInterrupt:
pass


# ---------------------------------------------------------------------------
# Delete IPC command
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -397,6 +432,35 @@ def interactive_shell():
ipcs_top.append(tok)
i += 1
cmd_top(ipcs_top if ipcs_top else None, refresh)
elif cmd == 'web':
# Syntax: web [--host H] [--port P] [--cors-origin O ...]
# Keep the accepted flags in step with the `web` subparser.
host_web: str = WEB_DEFAULT_HOST
port_web: int = WEB_DEFAULT_PORT
cors_web: List[str] = []
i = 1
while i < len(tokens):
tok = tokens[i]
if tok == '--host':
i += 1
if i >= len(tokens):
raise ValueError("Expected a host after '--host'")
host_web = tokens[i]
elif tok == '--port':
i += 1
if i >= len(tokens):
raise ValueError("Expected a port after '--port'")
port_web = int(tokens[i])
elif tok == '--cors-origin':
i += 1
if i >= len(tokens):
raise ValueError(
"Expected an origin after '--cors-origin'")
cors_web.append(tokens[i])
else:
raise ValueError(f"Unexpected argument '{tok}'")
i += 1
cmd_web(host_web, port_web, cors_web or None)
elif cmd == 'delete' and len(tokens) == 2:
cmd_delete(tokens[1])
else:
Expand Down Expand Up @@ -454,6 +518,24 @@ def main():
p_del = sub.add_parser('delete', help='Delete IPC segment')
p_del.add_argument('ipc')

# web
p_web = sub.add_parser('web', help='Serve the kvcached control API')
p_web.add_argument(
'--host',
default=WEB_DEFAULT_HOST,
help=f'Bind address (default: {WEB_DEFAULT_HOST}). Binding to a '
'reachable address is only safe with KVCACHED_WEB_API_KEY set.')
p_web.add_argument('--port',
type=int,
default=WEB_DEFAULT_PORT,
help=f'Bind port (default: {WEB_DEFAULT_PORT})')
p_web.add_argument(
'--cors-origin',
action='append',
dest='cors_origins',
metavar='ORIGIN',
help='Allow browser requests from ORIGIN. Repeatable.')

# shell
sub.add_parser('shell', help='Start interactive shell')

Expand All @@ -469,6 +551,8 @@ def main():
cmd_watch(args.interval, args.ipc if args.ipc else None)
elif args.command == 'kvtop':
cmd_top(args.ipc if args.ipc else None, args.refresh)
elif args.command == 'web':
cmd_web(args.host, args.port, args.cors_origins)
elif args.command == 'delete':
cmd_delete(args.ipc)
elif args.command == 'shell' or args.command is None:
Expand Down
Loading