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.
@@ -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,4 @@
1
+ """Local intelligence: a job queue in front of a model server on this machine.
2
+
3
+ Built into the bridge (`builtins.py`). Stdlib-only, like the rest of the package.
4
+ """
@@ -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")