voidcraft-world-bridge 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voidcraft_world_bridge/__init__.py +13 -0
- voidcraft_world_bridge/builtins.py +19 -0
- voidcraft_world_bridge/cli.py +111 -0
- voidcraft_world_bridge/guards.py +131 -0
- voidcraft_world_bridge/local_llm/__init__.py +4 -0
- voidcraft_world_bridge/local_llm/agents.py +31 -0
- voidcraft_world_bridge/local_llm/jobs.py +147 -0
- voidcraft_world_bridge/local_llm/models.py +101 -0
- voidcraft_world_bridge/local_llm/ollama.py +146 -0
- voidcraft_world_bridge/local_llm/openai_compat.py +172 -0
- voidcraft_world_bridge/local_llm/plugin.py +365 -0
- voidcraft_world_bridge/local_llm/runtimes.py +77 -0
- voidcraft_world_bridge/local_llm/transport.py +69 -0
- voidcraft_world_bridge/plugins.py +213 -0
- voidcraft_world_bridge/server.py +307 -0
- voidcraft_world_bridge-0.1.0.dist-info/METADATA +132 -0
- voidcraft_world_bridge-0.1.0.dist-info/RECORD +20 -0
- voidcraft_world_bridge-0.1.0.dist-info/WHEEL +4 -0
- voidcraft_world_bridge-0.1.0.dist-info/entry_points.txt +3 -0
- voidcraft_world_bridge-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""voidcraft-world-bridge: a loopback server that lets voidcraft.world reach your machine.
|
|
2
|
+
|
|
3
|
+
It mounts plugins under `/plugin/<name>/` behind a Host + Origin allowlist, so a
|
|
4
|
+
page on voidcraft.world can talk to something on this machine — a local language
|
|
5
|
+
model, first — while a page anywhere else cannot.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("voidcraft-world-bridge")
|
|
12
|
+
except PackageNotFoundError: # running from a source tree that was never installed
|
|
13
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The plugins every bridge mounts without being told to.
|
|
2
|
+
|
|
3
|
+
Today that is `local-llm`: the reason a stranger runs this bridge at all is to
|
|
4
|
+
let voidcraft.world reach their own model, so it must work with zero config. A
|
|
5
|
+
plugin directory configured under the same name is skipped, never mounted twice
|
|
6
|
+
(`load_plugins`, `preloaded`).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Iterable
|
|
11
|
+
|
|
12
|
+
from voidcraft_world_bridge.local_llm.plugin import PLUGIN_NAME as LOCAL_LLM, LocalLlmPlugin
|
|
13
|
+
from voidcraft_world_bridge.plugins import PluginContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def builtin_plugins(context: PluginContext, *, agent_runtimes: Iterable[object] = ()) -> dict[str, object]:
|
|
17
|
+
"""name → plugin for every built-in. `agent_runtimes` are handed to
|
|
18
|
+
`local-llm` (see `local_llm/agents.py`); a stock bridge registers none."""
|
|
19
|
+
return {LOCAL_LLM: LocalLlmPlugin(context, agent_runtimes=agent_runtimes)}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""`voidcraft-world-bridge` — run the bridge in the foreground, or ask one how it is.
|
|
2
|
+
|
|
3
|
+
voidcraft-world-bridge serve on 127.0.0.1:7682 until Ctrl-C
|
|
4
|
+
voidcraft-world-bridge status is one running here, and what does it mount?
|
|
5
|
+
voidcraft-world-bridge --version
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
from voidcraft_world_bridge import __version__
|
|
18
|
+
from voidcraft_world_bridge.builtins import LOCAL_LLM, builtin_plugins
|
|
19
|
+
from voidcraft_world_bridge.plugins import PluginContext, discover_plugin_dirs, load_plugins
|
|
20
|
+
from voidcraft_world_bridge.server import DEFAULT_PORT, LOOPBACK, NAME, build_handler, make_server
|
|
21
|
+
|
|
22
|
+
PORT_ENV = "VOIDCRAFT_BRIDGE_PORT"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _log(message: str) -> None:
|
|
26
|
+
print(message, flush=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_port() -> int:
|
|
30
|
+
raw = os.environ.get(PORT_ENV)
|
|
31
|
+
try:
|
|
32
|
+
return int(raw) if raw else DEFAULT_PORT
|
|
33
|
+
except ValueError:
|
|
34
|
+
return DEFAULT_PORT
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def serve(port: int) -> int:
|
|
38
|
+
"""Load plugins, bind loopback, serve until Ctrl-C. Exit code: 0 on Ctrl-C,
|
|
39
|
+
1 when the port is taken."""
|
|
40
|
+
context = PluginContext(status_port=port, log=_log)
|
|
41
|
+
plugins = load_plugins(discover_plugin_dirs(os.environ), context, preloaded=builtin_plugins(context))
|
|
42
|
+
try:
|
|
43
|
+
server = make_server(port, build_handler(plugins))
|
|
44
|
+
except OSError as err:
|
|
45
|
+
_log(f"{NAME}: cannot listen on {LOOPBACK}:{port} ({err.strerror or err}).")
|
|
46
|
+
_log(f" Is another bridge already running? Check with: {NAME} status")
|
|
47
|
+
return 1
|
|
48
|
+
_log(f"{NAME} {__version__} — listening on http://{LOOPBACK}:{port}")
|
|
49
|
+
for name in plugins:
|
|
50
|
+
_log(f" plugin: {name} → http://{LOOPBACK}:{port}/plugin/{name}/…")
|
|
51
|
+
if LOCAL_LLM in plugins:
|
|
52
|
+
_log(f" local model: {describe_local_model(plugins[LOCAL_LLM])}")
|
|
53
|
+
_log(" Ctrl-C to stop")
|
|
54
|
+
try:
|
|
55
|
+
server.serve_forever()
|
|
56
|
+
except KeyboardInterrupt:
|
|
57
|
+
_log(f"\n{NAME}: stopped")
|
|
58
|
+
finally:
|
|
59
|
+
server.server_close()
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def describe_local_model(plugin) -> str:
|
|
64
|
+
"""One line on what `local-llm` found, read through its own `/status` route,
|
|
65
|
+
so the banner and voidcraft.world can never disagree."""
|
|
66
|
+
_, status = plugin.handle_get("/status", {})
|
|
67
|
+
if not status.get("reachable"):
|
|
68
|
+
return ("none found — start Ollama or LM Studio (or llama-server), "
|
|
69
|
+
"or set VOIDCRAFT_LOCAL_LLM_URL; the bridge looks again on every request")
|
|
70
|
+
picked = status.get("picked_model") or "no chat model installed yet"
|
|
71
|
+
return f"{status.get('runtime_label')} at {status.get('runtime_url')} → {picked}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fetch_snapshot(port: int, timeout: float = 2.0) -> dict | None:
|
|
75
|
+
"""GET /snapshot from a bridge on this machine, or None if nothing answers."""
|
|
76
|
+
try:
|
|
77
|
+
with urllib.request.urlopen(f"http://{LOOPBACK}:{port}/snapshot", timeout=timeout) as response:
|
|
78
|
+
payload = json.loads(response.read())
|
|
79
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
80
|
+
return None
|
|
81
|
+
return payload if isinstance(payload, dict) else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def status(port: int) -> int:
|
|
85
|
+
"""Print what a running bridge reports. Exit 1 when none answers."""
|
|
86
|
+
snapshot = fetch_snapshot(port)
|
|
87
|
+
if snapshot is None:
|
|
88
|
+
_log(f"{NAME}: nothing answering on {LOOPBACK}:{port}")
|
|
89
|
+
return 1
|
|
90
|
+
bridge = snapshot.get("bridge") if isinstance(snapshot.get("bridge"), dict) else {}
|
|
91
|
+
plugins = snapshot.get("plugins") if isinstance(snapshot.get("plugins"), dict) else {}
|
|
92
|
+
_log(f"{bridge.get('name', 'a bridge')} {bridge.get('version', '?')} — ONLINE on {LOOPBACK}:{port}")
|
|
93
|
+
_log(f" plugins: {', '.join(sorted(plugins)) or '(none)'}")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main(argv: list[str] | None = None) -> int:
|
|
98
|
+
parser = argparse.ArgumentParser(prog=NAME, description="Let voidcraft.world reach your machine.")
|
|
99
|
+
parser.add_argument("--version", action="version", version=f"{NAME} {__version__}")
|
|
100
|
+
parser.add_argument("--port", type=int, default=_default_port(),
|
|
101
|
+
help=f"loopback port (default {DEFAULT_PORT}, or ${PORT_ENV})")
|
|
102
|
+
commands = parser.add_subparsers(dest="command")
|
|
103
|
+
commands.add_parser("status", help="report on a bridge running here")
|
|
104
|
+
args = parser.parse_args(argv)
|
|
105
|
+
if args.command == "status":
|
|
106
|
+
return status(args.port)
|
|
107
|
+
return serve(args.port)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
sys.exit(main())
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Who may talk to the bridge: the Host (DNS-rebinding) and Origin guards.
|
|
2
|
+
|
|
3
|
+
Every request a browser sends to a loopback server passes through these, and
|
|
4
|
+
they are the whole reason a page on voidcraft.world may reach this machine while
|
|
5
|
+
a page anywhere else may not. Pure functions over header values, so the tests
|
|
6
|
+
need no socket.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
# The single Origin allowlist for EVERY route: no Origin (curl, CLI, native
|
|
15
|
+
# apps), a deployed VoidCraft origin, or ANY loopback origin — the dev SPA roams
|
|
16
|
+
# ports (vite falls back to 5174+ when 5173 is taken; mkcert flips the scheme),
|
|
17
|
+
# and a loopback page adds nothing an attacker doesn't already have.
|
|
18
|
+
#
|
|
19
|
+
# One guard, every route — read routes included. A host's read routes can leak
|
|
20
|
+
# real content (a terminal pane's visible rows, a process's cwd), and wildcard
|
|
21
|
+
# CORS on those means ANY page the user has open can read them cross-origin. The
|
|
22
|
+
# browser's Private Network Access preflight is no backstop: it is answered
|
|
23
|
+
# permissively for allowlisted origins, and Firefox/Safari don't implement it.
|
|
24
|
+
ALLOWED_BROWSER_ORIGINS = frozenset({
|
|
25
|
+
"https://voidcraft.world",
|
|
26
|
+
"https://www.voidcraft.world",
|
|
27
|
+
"https://dev.voidcraft.world",
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
31
|
+
|
|
32
|
+
# Extra Host values beyond loopback (`tailscale serve` remote access). The
|
|
33
|
+
# second name is the one this bridge was born under, still honoured so an
|
|
34
|
+
# existing setup keeps working.
|
|
35
|
+
ALLOWED_HOSTS_ENV = "VOIDCRAFT_BRIDGE_ALLOWED_HOSTS"
|
|
36
|
+
LEGACY_ALLOWED_HOSTS_ENV = "TERMINAL_BRIDGE_ALLOWED_HOSTS"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_loopback_origin(origin: str) -> bool:
|
|
40
|
+
"""True for http(s)://localhost|127.0.0.1|[::1] on any port; malformed → False."""
|
|
41
|
+
try:
|
|
42
|
+
parts = urlsplit(origin)
|
|
43
|
+
return parts.scheme in ("http", "https") and parts.hostname in LOOPBACK_HOSTS
|
|
44
|
+
except ValueError:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def origin_allowed(origin: str | None) -> bool:
|
|
49
|
+
"""Whether a request carrying this Origin may be served at all.
|
|
50
|
+
|
|
51
|
+
`None` passes so that non-browser clients work: curl, CLI subcommands and
|
|
52
|
+
native apps send no Origin.
|
|
53
|
+
|
|
54
|
+
An absent Origin is NOT by itself proof of a non-browser client — see
|
|
55
|
+
`is_no_cors_browser_request`, which expensive routes use to tell the two
|
|
56
|
+
apart. This function is deliberately left permissive because routes that
|
|
57
|
+
are merely *readable* also serve legitimate no-Origin browser requests (a
|
|
58
|
+
plugin page loaded in an iframe, a hand-typed debug URL).
|
|
59
|
+
"""
|
|
60
|
+
return origin is None or origin in ALLOWED_BROWSER_ORIGINS or is_loopback_origin(origin)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_no_cors_browser_request(origin: str | None, sec_fetch_site: str | None) -> bool:
|
|
64
|
+
"""True for a browser request that omitted Origin because it is `no-cors`.
|
|
65
|
+
|
|
66
|
+
`<img>`, `<script src>`, `<iframe src>` and `fetch(url, {mode:'no-cors'})`
|
|
67
|
+
all issue REAL cross-origin GETs while sending no Origin header — so the
|
|
68
|
+
"absent Origin means a native client" reading in `origin_allowed` does not
|
|
69
|
+
hold on its own. What does hold is that every browser making such a request
|
|
70
|
+
sends `Sec-Fetch-Site` (Chrome 76+, Firefox 90+, Safari 16.4+), and no
|
|
71
|
+
curl/URLSession/CLI caller sends it at all.
|
|
72
|
+
|
|
73
|
+
For routes where the request is EXPENSIVE rather than merely readable: the
|
|
74
|
+
attacker cannot read a no-cors response, so the risk there is not disclosure
|
|
75
|
+
but the work performed (a held thread, a subprocess per call).
|
|
76
|
+
"""
|
|
77
|
+
return origin is None and sec_fetch_site is not None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def host_header_hostname(host_header: str) -> str | None:
|
|
81
|
+
"""The hostname from a Host header, port and IPv6 brackets stripped."""
|
|
82
|
+
try:
|
|
83
|
+
return urlsplit(f"//{host_header}").hostname
|
|
84
|
+
except ValueError:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def allowed_remote_hosts() -> frozenset[str]:
|
|
89
|
+
"""Extra Host values accepted beyond loopback, from the environment.
|
|
90
|
+
|
|
91
|
+
Comma-separated in `VOIDCRAFT_BRIDGE_ALLOWED_HOSTS` (or the legacy
|
|
92
|
+
`TERMINAL_BRIDGE_ALLOWED_HOSTS`; both are read). A full URL is accepted and
|
|
93
|
+
reduced to its hostname, so `https://mymac.tailXXXX.ts.net` works.
|
|
94
|
+
"""
|
|
95
|
+
raw = ",".join(os.environ.get(name, "") for name in (ALLOWED_HOSTS_ENV, LEGACY_ALLOWED_HOSTS_ENV))
|
|
96
|
+
hosts: set[str] = set()
|
|
97
|
+
for token in raw.split(","):
|
|
98
|
+
token = token.strip()
|
|
99
|
+
if not token:
|
|
100
|
+
continue
|
|
101
|
+
if "//" in token:
|
|
102
|
+
token = token.split("//", 1)[1]
|
|
103
|
+
parsed = host_header_hostname(token)
|
|
104
|
+
if parsed:
|
|
105
|
+
hosts.add(parsed)
|
|
106
|
+
return frozenset(hosts)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def host_allowed(host_header: str | None) -> bool:
|
|
110
|
+
"""Whether the Host this request was addressed to may be served.
|
|
111
|
+
|
|
112
|
+
THE DNS-REBINDING GUARD, and the only defence against it — a loopback bind
|
|
113
|
+
is not one. An attacker page on `rebind.evil.com` (1s TTL, re-resolved to
|
|
114
|
+
127.0.0.1) makes the browser open a genuine same-origin connection to this
|
|
115
|
+
server, so every Origin/CORS check passes by construction: the attacker's
|
|
116
|
+
own hostname IS the origin. Only the Host tells us the request was
|
|
117
|
+
addressed to a name we do not answer to.
|
|
118
|
+
|
|
119
|
+
Absent Host passes (HTTP/1.0 clients omit it; a browser never does).
|
|
120
|
+
|
|
121
|
+
Non-loopback names must be listed explicitly in the env var. **A `*.ts.net`
|
|
122
|
+
suffix rule would be wrong here**: Tailscale *funnel* publishes
|
|
123
|
+
`<machine>.<tailnet>.ts.net` to the public internet, so an attacker with a
|
|
124
|
+
funneled node owns a perfectly real `.ts.net` name. Pin the machine.
|
|
125
|
+
"""
|
|
126
|
+
if host_header is None:
|
|
127
|
+
return True
|
|
128
|
+
hostname = host_header_hostname(host_header)
|
|
129
|
+
if hostname is None:
|
|
130
|
+
return False
|
|
131
|
+
return hostname in LOOPBACK_HOSTS or hostname in allowed_remote_hosts()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Agent runtimes: a model reached some other way than a local server.
|
|
2
|
+
|
|
3
|
+
A `/chat` body with an `agent: {runtime, …}` field goes to the agent runtime of
|
|
4
|
+
that name instead of the local model, on its own queue and worker. The plugin
|
|
5
|
+
ships with NONE; a host that embeds the bridge registers its own by passing
|
|
6
|
+
`agent_runtimes=[…]` to `LocalLlmPlugin` (or `builtin_plugins`). A body naming a
|
|
7
|
+
runtime that is not registered is a 400.
|
|
8
|
+
|
|
9
|
+
An agent runtime is duck-typed:
|
|
10
|
+
|
|
11
|
+
name: str # the `agent.runtime` value it answers to
|
|
12
|
+
default_model: str # the model a body that names none gets
|
|
13
|
+
model_error(model) -> str | None # why a model is not one it takes
|
|
14
|
+
validate_agent(raw: dict) -> (dict | None, str | None)
|
|
15
|
+
# its own `agent` fields, cleaned, or why not
|
|
16
|
+
list_agents() -> list[dict] # rows for `GET /status?agents=1`
|
|
17
|
+
run(request: dict) -> dict # the chat result, or raise AgentRefused
|
|
18
|
+
|
|
19
|
+
The plugin checks the shared contract before `validate_agent` runs: an agent
|
|
20
|
+
takes exactly one system and one user message, and no tools.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgentRefused(Exception):
|
|
26
|
+
"""The call must not run, or failed: `code` becomes the job's error code."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, code: str, message: str) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.code = code
|
|
31
|
+
self.message = message
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""A bounded, single-worker job queue — why generation is a job and not a request.
|
|
2
|
+
|
|
3
|
+
── WHY A JOB ─────────────────────────────────────────────────────────────────
|
|
4
|
+
The host rule is "never block long": plugin handlers run on the bridge's
|
|
5
|
+
request threads, which serve every other route too. One answer from a local
|
|
6
|
+
model is 5–60 s, and the FIRST one after a cold start also pages the model in
|
|
7
|
+
from disk. So `POST /chat` enqueues and returns 202 at once, and the page polls
|
|
8
|
+
`GET /job`.
|
|
9
|
+
|
|
10
|
+
── WHY ONE WORKER ────────────────────────────────────────────────────────────
|
|
11
|
+
Two concurrent generations on one Mac do not finish sooner; they split the
|
|
12
|
+
same memory bandwidth, and two DIFFERENT models loaded at once can exceed the
|
|
13
|
+
GPU budget and push both onto the CPU. Serial is the fast order here.
|
|
14
|
+
|
|
15
|
+
── WHY BOUNDED, TWICE ────────────────────────────────────────────────────────
|
|
16
|
+
`MAX_PENDING` refuses new work while four jobs wait, because the fifth
|
|
17
|
+
question would not start for minutes and the page that asked it has usually
|
|
18
|
+
moved on. `MAX_KEPT` forgets the oldest FINISHED jobs, because nothing ever
|
|
19
|
+
deletes a job otherwise and the bridge runs for weeks.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import queue
|
|
24
|
+
import secrets
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from typing import Callable
|
|
28
|
+
|
|
29
|
+
MAX_PENDING = 4
|
|
30
|
+
MAX_KEPT = 32
|
|
31
|
+
|
|
32
|
+
QUEUED, RUNNING, DONE, FAILED = "queued", "running", "done", "failed"
|
|
33
|
+
|
|
34
|
+
Runner = Callable[[dict], dict]
|
|
35
|
+
"""Takes the job's `request`, returns the result dict or raises JobFailure."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class JobFailure(Exception):
|
|
39
|
+
def __init__(self, code: str, message: str) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.code = code
|
|
42
|
+
self.message = message
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class QueueFull(Exception):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class JobStore:
|
|
50
|
+
def __init__(self, runner: Runner, *, clock: Callable[[], float] = time.time,
|
|
51
|
+
start_worker: bool = True) -> None:
|
|
52
|
+
self._runner = runner
|
|
53
|
+
self._clock = clock
|
|
54
|
+
self._lock = threading.Lock()
|
|
55
|
+
self._jobs: dict[str, dict] = {}
|
|
56
|
+
self._order: list[str] = []
|
|
57
|
+
self._pending: queue.Queue[str] = queue.Queue()
|
|
58
|
+
if start_worker:
|
|
59
|
+
threading.Thread(target=self._work_forever, name="local-llm-worker", daemon=True).start()
|
|
60
|
+
|
|
61
|
+
# -- public ---------------------------------------------------------------
|
|
62
|
+
def submit(self, request: dict) -> dict:
|
|
63
|
+
with self._lock:
|
|
64
|
+
waiting = sum(1 for j in self._jobs.values() if j["state"] == QUEUED)
|
|
65
|
+
if waiting >= MAX_PENDING:
|
|
66
|
+
raise QueueFull()
|
|
67
|
+
job_id = secrets.token_hex(8)
|
|
68
|
+
job = {"id": job_id, "state": QUEUED, "created_at": self._clock(),
|
|
69
|
+
"started_at": None, "finished_at": None,
|
|
70
|
+
"model": request.get("model"), "result": None, "error": None,
|
|
71
|
+
"request": request}
|
|
72
|
+
self._jobs[job_id] = job
|
|
73
|
+
self._order.append(job_id)
|
|
74
|
+
self._forget_oldest_finished()
|
|
75
|
+
snapshot = self._public(job)
|
|
76
|
+
self._pending.put(job_id)
|
|
77
|
+
return snapshot
|
|
78
|
+
|
|
79
|
+
def get(self, job_id: str) -> dict | None:
|
|
80
|
+
with self._lock:
|
|
81
|
+
job = self._jobs.get(job_id)
|
|
82
|
+
return self._public(job) if job else None
|
|
83
|
+
|
|
84
|
+
def counts(self) -> dict:
|
|
85
|
+
with self._lock:
|
|
86
|
+
states = [j["state"] for j in self._jobs.values()]
|
|
87
|
+
return {"queued": states.count(QUEUED), "running": states.count(RUNNING)}
|
|
88
|
+
|
|
89
|
+
def run_next(self) -> bool:
|
|
90
|
+
"""Run one queued job on THIS thread. The worker loop calls it; tests call it directly."""
|
|
91
|
+
try:
|
|
92
|
+
job_id = self._pending.get_nowait()
|
|
93
|
+
except queue.Empty:
|
|
94
|
+
return False
|
|
95
|
+
self._run(job_id)
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
# -- internals ------------------------------------------------------------
|
|
99
|
+
def _work_forever(self) -> None:
|
|
100
|
+
while True:
|
|
101
|
+
job_id = self._pending.get()
|
|
102
|
+
self._run(job_id)
|
|
103
|
+
|
|
104
|
+
def _run(self, job_id: str) -> None:
|
|
105
|
+
with self._lock:
|
|
106
|
+
job = self._jobs.get(job_id)
|
|
107
|
+
if job is None:
|
|
108
|
+
return
|
|
109
|
+
job["state"] = RUNNING
|
|
110
|
+
job["started_at"] = self._clock()
|
|
111
|
+
request = dict(job["request"])
|
|
112
|
+
try:
|
|
113
|
+
result = self._runner(request)
|
|
114
|
+
outcome = {"state": DONE, "result": result, "model": result.get("model") or request.get("model")}
|
|
115
|
+
except JobFailure as failure:
|
|
116
|
+
outcome = {"state": FAILED, "error": {"code": failure.code, "message": failure.message}}
|
|
117
|
+
except Exception as err: # a runner bug must fail ONE job, never kill the worker
|
|
118
|
+
outcome = {"state": FAILED, "error": {"code": "internal", "message": str(err)[:300]}}
|
|
119
|
+
with self._lock:
|
|
120
|
+
job.update(outcome)
|
|
121
|
+
job["finished_at"] = self._clock()
|
|
122
|
+
job["request"] = {} # the prompt is the user's content; do not keep it past the answer
|
|
123
|
+
|
|
124
|
+
def _forget_oldest_finished(self) -> None:
|
|
125
|
+
while len(self._order) > MAX_KEPT:
|
|
126
|
+
victim = next((jid for jid in self._order if self._jobs[jid]["state"] in (DONE, FAILED)), None)
|
|
127
|
+
if victim is None:
|
|
128
|
+
return
|
|
129
|
+
self._order.remove(victim)
|
|
130
|
+
del self._jobs[victim]
|
|
131
|
+
|
|
132
|
+
def _public(self, job: dict) -> dict:
|
|
133
|
+
started = job["started_at"]
|
|
134
|
+
finished = job["finished_at"]
|
|
135
|
+
now = self._clock()
|
|
136
|
+
elapsed = None
|
|
137
|
+
if started is not None:
|
|
138
|
+
elapsed = int(((finished if finished is not None else now) - started) * 1000)
|
|
139
|
+
return {
|
|
140
|
+
"id": job["id"],
|
|
141
|
+
"state": job["state"],
|
|
142
|
+
"model": job["model"],
|
|
143
|
+
"queued_ms": int(((started if started is not None else now) - job["created_at"]) * 1000),
|
|
144
|
+
"elapsed_ms": elapsed,
|
|
145
|
+
"result": job["result"],
|
|
146
|
+
"error": job["error"],
|
|
147
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Which installed model answers when the caller does not name one.
|
|
2
|
+
|
|
3
|
+
── NOBODY PICKS A MODEL TO START ─────────────────────────────────────────────
|
|
4
|
+
A choice nearly everyone makes the same way is a default, not a question.
|
|
5
|
+
Someone who has pulled `qwen3.6:35b-a3b` wants it used; asking them to also
|
|
6
|
+
select it in VoidCraft is the question again. So the
|
|
7
|
+
plugin picks the strongest installed model from an ordered list, `/status` says
|
|
8
|
+
which one it picked, and a caller that names a model (Profile's override) wins.
|
|
9
|
+
|
|
10
|
+
── WHY AN ORDERED LIST AND NOT "THE BIGGEST" ─────────────────────────────────
|
|
11
|
+
Size is the wrong axis. A 35B MoE with 3B active parameters generates several
|
|
12
|
+
times faster than a 27B dense model while being competitive on quality, and an
|
|
13
|
+
embedding model can be the largest thing installed while being unable to chat
|
|
14
|
+
at all. The order below is a judgement of quality per second on a desktop,
|
|
15
|
+
not a size sort.
|
|
16
|
+
|
|
17
|
+
── TWO NAMING SCHEMES ────────────────────────────────────────────────────────
|
|
18
|
+
Ollama names a model `family:tag` (`qwen3.6:35b-a3b`). OpenAI-compatible servers
|
|
19
|
+
use an id with no colon, often with an org prefix or a file name
|
|
20
|
+
(`qwen/qwen3.6-35b-a3b`, `Qwen3.6-35B-A3B-Q4_K_M.gguf`). `matches` reads both, so
|
|
21
|
+
one preference list serves every runtime.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
PREFERENCE = (
|
|
26
|
+
"qwen3.6:35b-a3b",
|
|
27
|
+
"qwen3.8",
|
|
28
|
+
"qwen3.6:27b",
|
|
29
|
+
"qwen3.6",
|
|
30
|
+
"qwen3.5:35b-a3b",
|
|
31
|
+
"qwen3.5:27b",
|
|
32
|
+
"qwen3.5:9b",
|
|
33
|
+
"gemma4",
|
|
34
|
+
"qwen3.5:4b",
|
|
35
|
+
"qwen3",
|
|
36
|
+
)
|
|
37
|
+
"""Most-preferred first. An entry with a tag (`qwen3.6:35b-a3b`) matches that
|
|
38
|
+
tag and its quantisation variants (`qwen3.6:35b-a3b-q8_0`); an entry without one
|
|
39
|
+
(`qwen3.8`) matches any tag of that model."""
|
|
40
|
+
|
|
41
|
+
_NOT_CHAT = ("embed", "bert", "rerank", "clip")
|
|
42
|
+
"""Name/family fragments of models that cannot hold a conversation. The
|
|
43
|
+
fallback below must never hand a question to an embedding model because it
|
|
44
|
+
happened to be the only thing installed."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_chat_model(model: dict) -> bool:
|
|
48
|
+
name = str(model.get("name", "")).lower()
|
|
49
|
+
details = model.get("details") if isinstance(model.get("details"), dict) else {}
|
|
50
|
+
family = str(details.get("family", "")).lower()
|
|
51
|
+
families = " ".join(str(f).lower() for f in details.get("families") or [])
|
|
52
|
+
haystack = f"{name} {family} {families}"
|
|
53
|
+
return not any(fragment in haystack for fragment in _NOT_CHAT)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def matches(preference: str, name: str) -> bool:
|
|
57
|
+
"""Does installed `name` satisfy `preference`? See PREFERENCE for the rule,
|
|
58
|
+
and the module note for the two naming schemes."""
|
|
59
|
+
name = name.lower()
|
|
60
|
+
preference = preference.lower()
|
|
61
|
+
if ":" not in name:
|
|
62
|
+
# An OpenAI-style id: drop an `org/` prefix and a `.gguf` suffix, then
|
|
63
|
+
# read the preference's `:` as the `-` these ids use in its place.
|
|
64
|
+
name = name.rsplit("/", 1)[-1].removesuffix(".gguf")
|
|
65
|
+
flat = preference.replace(":", "-")
|
|
66
|
+
return name == flat or name.startswith(flat + "-")
|
|
67
|
+
if ":" in preference:
|
|
68
|
+
return name == preference or name.startswith(preference + "-")
|
|
69
|
+
return name.split(":", 1)[0] == preference
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def pick_model(installed: list[dict], pinned: str | None = None) -> str | None:
|
|
73
|
+
"""The model a caller gets when it does not name one, or None.
|
|
74
|
+
|
|
75
|
+
`pinned` (the VOIDCRAFT_LOCAL_LLM_MODEL env var) wins when it is installed;
|
|
76
|
+
a pin naming a model that is NOT installed is ignored rather than obeyed,
|
|
77
|
+
because obeying it would fail every request with "model not found" while
|
|
78
|
+
a perfectly good model sits on disk.
|
|
79
|
+
"""
|
|
80
|
+
names = [str(m.get("name", "")) for m in installed if m.get("name")]
|
|
81
|
+
if pinned and pinned in names:
|
|
82
|
+
return pinned
|
|
83
|
+
for preference in PREFERENCE:
|
|
84
|
+
for name in names:
|
|
85
|
+
if matches(preference, name):
|
|
86
|
+
return name
|
|
87
|
+
chat = sorted(str(m["name"]) for m in installed if m.get("name") and is_chat_model(m))
|
|
88
|
+
return chat[0] if chat else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def resolve_requested(requested: str | None, installed: list[dict], pinned: str | None) -> tuple[str | None, str | None]:
|
|
92
|
+
"""(model, error_code). A named model must be installed — the plugin never pulls.
|
|
93
|
+
|
|
94
|
+
Pulling is a multi-gigabyte download; it belongs to the person at a
|
|
95
|
+
terminal (`ollama pull`), never to a page that happened to ask for a name.
|
|
96
|
+
"""
|
|
97
|
+
names = {str(m.get("name", "")) for m in installed}
|
|
98
|
+
if requested:
|
|
99
|
+
return (requested, None) if requested in names else (None, "model_not_installed")
|
|
100
|
+
picked = pick_model(installed, pinned)
|
|
101
|
+
return (picked, None) if picked else (None, "no_model")
|