opencode-llmstack 0.6.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.
- llmstack/AGENTS.md +13 -0
- llmstack/__init__.py +20 -0
- llmstack/__main__.py +10 -0
- llmstack/_platform.py +420 -0
- llmstack/app.py +644 -0
- llmstack/backends/__init__.py +19 -0
- llmstack/backends/bedrock.py +790 -0
- llmstack/check_models.py +119 -0
- llmstack/cli.py +264 -0
- llmstack/commands/__init__.py +10 -0
- llmstack/commands/_helpers.py +91 -0
- llmstack/commands/activate.py +71 -0
- llmstack/commands/check.py +13 -0
- llmstack/commands/download.py +27 -0
- llmstack/commands/install.py +365 -0
- llmstack/commands/install_llama_swap.py +36 -0
- llmstack/commands/reload.py +59 -0
- llmstack/commands/restart.py +12 -0
- llmstack/commands/setup.py +146 -0
- llmstack/commands/start.py +360 -0
- llmstack/commands/status.py +260 -0
- llmstack/commands/stop.py +73 -0
- llmstack/download/__init__.py +21 -0
- llmstack/download/binary.py +234 -0
- llmstack/download/ggufs.py +164 -0
- llmstack/generators/__init__.py +37 -0
- llmstack/generators/llama_swap.py +421 -0
- llmstack/generators/opencode.py +291 -0
- llmstack/models.ini +304 -0
- llmstack/paths.py +318 -0
- llmstack/shell_env.py +927 -0
- llmstack/tiers.py +394 -0
- opencode_llmstack-0.6.0.dist-info/METADATA +693 -0
- opencode_llmstack-0.6.0.dist-info/RECORD +37 -0
- opencode_llmstack-0.6.0.dist-info/WHEEL +5 -0
- opencode_llmstack-0.6.0.dist-info/entry_points.txt +2 -0
- opencode_llmstack-0.6.0.dist-info/top_level.txt +1 -0
llmstack/paths.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Centralised path & state-dir resolution.
|
|
2
|
+
|
|
3
|
+
Three classes of path are kept apart on purpose:
|
|
4
|
+
|
|
5
|
+
*Source* paths
|
|
6
|
+
Things that ship with the package (``AGENTS.md`` template). These are
|
|
7
|
+
read-only after ``pip install`` and live next to the package modules.
|
|
8
|
+
|
|
9
|
+
*User-data* paths (writable, persistent, shared across projects)
|
|
10
|
+
The ``llama-swap`` binary, mostly. Lives under
|
|
11
|
+
``$XDG_DATA_HOME/llmstack/`` (defaults to ``~/.local/share/llmstack/``)
|
|
12
|
+
so a single download is reused regardless of which project the CLI
|
|
13
|
+
was invoked from. Override with ``$LLMSTACK_BIN_DIR`` or
|
|
14
|
+
``$LLMSTACK_DATA_DIR``.
|
|
15
|
+
|
|
16
|
+
*Work-dir* paths (writable, per-project)
|
|
17
|
+
Generated configs, pid files, channel markers, prompt rcfiles and
|
|
18
|
+
daemon logs. Lives at ``<work-dir>/.llmstack/`` so each project gets
|
|
19
|
+
its own ``opencode.json`` + ``llama-swap.yaml``. ``work-dir`` is
|
|
20
|
+
``$LLMSTACK_WORK_DIR`` if set, else ``$PWD``. The activate hook +
|
|
21
|
+
``spawn_subshell`` are responsible for exporting ``LLMSTACK_WORK_DIR``
|
|
22
|
+
when the user is "inside" a project, so ``llmstack <action>`` works
|
|
23
|
+
from any subdirectory; outside the hook, the user is expected to be
|
|
24
|
+
in the project root (or set the env var explicitly).
|
|
25
|
+
|
|
26
|
+
The state-dir resolution uses a frozen :class:`Paths` instance constructed
|
|
27
|
+
once per CLI invocation so we don't accidentally pick up a different
|
|
28
|
+
``$PWD`` mid-command.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from functools import lru_cache
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from llmstack._platform import EXE_SUFFIX, user_data_root
|
|
39
|
+
|
|
40
|
+
PACKAGE_DIR = Path(__file__).resolve().parent
|
|
41
|
+
|
|
42
|
+
AGENTS_TEMPLATE = PACKAGE_DIR / "AGENTS.md"
|
|
43
|
+
MODELS_INI_TEMPLATE = PACKAGE_DIR / "models.ini"
|
|
44
|
+
|
|
45
|
+
REPO_LLAMA_SWAP = "mostlygeek/llama-swap"
|
|
46
|
+
ROUTER_HOST = "127.0.0.1"
|
|
47
|
+
ROUTER_PORT = 10101
|
|
48
|
+
SWAP_PORT = 10102
|
|
49
|
+
|
|
50
|
+
# Default thin-client target: the local router on this host. Picked up by
|
|
51
|
+
# ``llmstack install --external`` when no URL is given. Match this to
|
|
52
|
+
# ``ROUTER_HOST`` / ``ROUTER_PORT`` so a host-local thin client and the
|
|
53
|
+
# (potential) local owner of the daemons agree on the endpoint.
|
|
54
|
+
DEFAULT_REMOTE_URL = f"http://{ROUTER_HOST}:{ROUTER_PORT}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def env_remote_url() -> str | None:
|
|
58
|
+
"""``LLMSTACK_REMOTE_URL`` from the environment, if set.
|
|
59
|
+
|
|
60
|
+
This is an *input* to ``llmstack install`` (and a one-shot fallback for
|
|
61
|
+
pre-install commands like ``setup``); it is **not** the source of
|
|
62
|
+
truth post-install. The activate hook re-exports the URL from the
|
|
63
|
+
persisted channel marker into this env var, so reading it from a
|
|
64
|
+
hook-active shell happens to agree with the marker -- but the marker
|
|
65
|
+
is canonical.
|
|
66
|
+
"""
|
|
67
|
+
raw = (os.environ.get("LLMSTACK_REMOTE_URL") or "").strip().rstrip("/")
|
|
68
|
+
return raw or None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def remote_url() -> str | None:
|
|
72
|
+
"""Authoritative remote-router URL (with no trailing slash, no ``/v1``).
|
|
73
|
+
|
|
74
|
+
Resolution order:
|
|
75
|
+
|
|
76
|
+
1. ``default-channel`` marker, when channel == ``external`` (set by
|
|
77
|
+
``llmstack install --external``). This is the post-install
|
|
78
|
+
source of truth.
|
|
79
|
+
2. ``$LLMSTACK_REMOTE_URL`` env var. Used only as a fallback for
|
|
80
|
+
commands invoked *before* an external install has happened
|
|
81
|
+
(``setup``, ``install`` itself).
|
|
82
|
+
|
|
83
|
+
Returns ``None`` when neither says we're in external mode -- i.e. we
|
|
84
|
+
own (or will own) the local daemons.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
paths = resolve()
|
|
88
|
+
mark = read_marker(paths.default_marker)
|
|
89
|
+
except OSError:
|
|
90
|
+
mark = None
|
|
91
|
+
if mark and mark.channel == "external" and mark.url:
|
|
92
|
+
return mark.url.rstrip("/") or None
|
|
93
|
+
return env_remote_url()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_remote() -> bool:
|
|
97
|
+
"""``True`` iff this project is wired as a thin client of some router.
|
|
98
|
+
|
|
99
|
+
Thin-wraps :func:`remote_url`; see there for the resolution order.
|
|
100
|
+
"""
|
|
101
|
+
return remote_url() is not None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _xdg_data_home() -> Path:
|
|
105
|
+
"""Persistent per-user data root.
|
|
106
|
+
|
|
107
|
+
POSIX: ``$XDG_DATA_HOME`` with the spec-defined fallback
|
|
108
|
+
(``~/.local/share``). Windows: ``$LOCALAPPDATA`` with the standard
|
|
109
|
+
Windows fallback. The platform shim keeps the two branches in one
|
|
110
|
+
place.
|
|
111
|
+
"""
|
|
112
|
+
return user_data_root()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def models_ini_path() -> Path:
|
|
116
|
+
"""Locate ``models.ini``.
|
|
117
|
+
|
|
118
|
+
Canonical location is ``<work-dir>/.llmstack/models.ini`` (per-project,
|
|
119
|
+
sits next to the rest of the generated state but is itself the *input*
|
|
120
|
+
to ``install``). ``$LLMSTACK_MODELS_INI`` overrides this with an
|
|
121
|
+
explicit absolute or relative path. Returns the resolved path even
|
|
122
|
+
when the file doesn't exist; callers decide whether that's an error
|
|
123
|
+
or a seed opportunity.
|
|
124
|
+
"""
|
|
125
|
+
explicit = os.environ.get("LLMSTACK_MODELS_INI")
|
|
126
|
+
if explicit:
|
|
127
|
+
return Path(explicit).expanduser().resolve()
|
|
128
|
+
return (work_dir() / ".llmstack" / "models.ini").resolve()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def work_dir() -> Path:
|
|
132
|
+
"""Where per-project state (``.llmstack/``) lives.
|
|
133
|
+
|
|
134
|
+
``$LLMSTACK_WORK_DIR`` if set, else ``$PWD``. The activate hook
|
|
135
|
+
walks up from the user's prompt to find the nearest installed
|
|
136
|
+
project (``.llmstack/opencode.json``) and exports
|
|
137
|
+
``LLMSTACK_WORK_DIR`` so the CLI works from any subdirectory; the
|
|
138
|
+
spawned subshell does the same. We don't redo that walk here -- the
|
|
139
|
+
hook is the source of truth, and a user who hasn't installed it is
|
|
140
|
+
expected to be in the project root.
|
|
141
|
+
"""
|
|
142
|
+
raw = os.environ.get("LLMSTACK_WORK_DIR")
|
|
143
|
+
return Path(raw).expanduser().resolve() if raw else Path.cwd().resolve()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def data_dir() -> Path:
|
|
147
|
+
"""Persistent user-data root for the package (binary, etc.)."""
|
|
148
|
+
raw = os.environ.get("LLMSTACK_DATA_DIR")
|
|
149
|
+
return Path(raw).expanduser().resolve() if raw else (_xdg_data_home() / "llmstack").resolve()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def bin_dir() -> Path:
|
|
153
|
+
"""Where ``llama-swap`` is installed. Falls back under ``data_dir()``."""
|
|
154
|
+
raw = os.environ.get("LLMSTACK_BIN_DIR")
|
|
155
|
+
return Path(raw).expanduser().resolve() if raw else (data_dir() / "bin").resolve()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class Paths:
|
|
160
|
+
"""Snapshot of the resolved paths for a single CLI invocation."""
|
|
161
|
+
|
|
162
|
+
work_dir: Path
|
|
163
|
+
data_dir: Path
|
|
164
|
+
bin_dir: Path
|
|
165
|
+
state_dir: Path # <work>/.llmstack
|
|
166
|
+
log_dir: Path # <state>/logs
|
|
167
|
+
llama_swap_bin: Path # <bin>/llama-swap
|
|
168
|
+
llama_swap_yaml: Path # <state>/llama-swap.yaml (was llmstack/llama-swap.yaml)
|
|
169
|
+
opencode_json: Path # <state>/opencode.json
|
|
170
|
+
agents_local: Path # <state>/AGENTS.md (copy of template)
|
|
171
|
+
active_marker: Path # <state>/active-channel
|
|
172
|
+
default_marker: Path # <state>/default-channel
|
|
173
|
+
router_pid: Path # <state>/router.pid
|
|
174
|
+
swap_pid: Path # <state>/llama-swap.pid
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def models_ini(self) -> Path:
|
|
178
|
+
return models_ini_path()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@lru_cache(maxsize=1)
|
|
182
|
+
def resolve() -> Paths:
|
|
183
|
+
"""Build (and cache) the path snapshot for this process."""
|
|
184
|
+
work = work_dir()
|
|
185
|
+
state = work / ".llmstack"
|
|
186
|
+
data = data_dir()
|
|
187
|
+
bind = bin_dir()
|
|
188
|
+
opencode_dir = Path(os.environ["OPENCODE_CONFIG_DIR"]).expanduser().resolve() \
|
|
189
|
+
if "OPENCODE_CONFIG_DIR" in os.environ else state
|
|
190
|
+
return Paths(
|
|
191
|
+
work_dir=work,
|
|
192
|
+
data_dir=data,
|
|
193
|
+
bin_dir=bind,
|
|
194
|
+
state_dir=state,
|
|
195
|
+
log_dir=state / "logs",
|
|
196
|
+
llama_swap_bin=bind / f"llama-swap{EXE_SUFFIX}",
|
|
197
|
+
llama_swap_yaml=state / "llama-swap.yaml",
|
|
198
|
+
opencode_json=opencode_dir / "opencode.json",
|
|
199
|
+
agents_local=state / "AGENTS.md",
|
|
200
|
+
active_marker=state / "active-channel",
|
|
201
|
+
default_marker=state / "default-channel",
|
|
202
|
+
router_pid=state / "router.pid",
|
|
203
|
+
swap_pid=state / "llama-swap.pid",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def ensure_state_dirs() -> Paths:
|
|
208
|
+
"""Create the per-project state dirs lazily, return the Paths snapshot.
|
|
209
|
+
|
|
210
|
+
Read-only commands (``help``, ``activate``) deliberately don't call
|
|
211
|
+
this -- otherwise running them from any directory would litter the
|
|
212
|
+
filesystem with empty ``.llmstack/`` folders.
|
|
213
|
+
"""
|
|
214
|
+
p = resolve()
|
|
215
|
+
p.state_dir.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
p.log_dir.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
return p
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def ensure_data_dirs() -> Paths:
|
|
221
|
+
"""Create the user-data dirs (for the binary install)."""
|
|
222
|
+
p = resolve()
|
|
223
|
+
p.data_dir.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
p.bin_dir.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
return p
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def require_models_ini() -> Path:
|
|
229
|
+
p = models_ini_path()
|
|
230
|
+
if not p.is_file():
|
|
231
|
+
raise SystemExit(
|
|
232
|
+
f"[!] models.ini not found at {p}\n"
|
|
233
|
+
" set $LLMSTACK_MODELS_INI or run `llmstack install` to seed one"
|
|
234
|
+
)
|
|
235
|
+
return p
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def ensure_models_ini() -> tuple[Path, bool]:
|
|
239
|
+
"""Resolve ``models.ini``, seeding the canonical location from the
|
|
240
|
+
bundled template when nothing exists yet. Returns ``(path, seeded)``
|
|
241
|
+
where ``seeded`` is ``True`` only when we just wrote the file.
|
|
242
|
+
|
|
243
|
+
Lookup follows :func:`models_ini_path`. When neither the canonical
|
|
244
|
+
nor the legacy location has a file, the seed is written to the
|
|
245
|
+
canonical path: ``<work-dir>/.llmstack/models.ini`` (or wherever
|
|
246
|
+
``$LLMSTACK_MODELS_INI`` points).
|
|
247
|
+
"""
|
|
248
|
+
p = models_ini_path()
|
|
249
|
+
if p.is_file():
|
|
250
|
+
return p, False
|
|
251
|
+
if not MODELS_INI_TEMPLATE.is_file():
|
|
252
|
+
raise SystemExit(
|
|
253
|
+
f"[!] models.ini not found at {p} and no bundled template at "
|
|
254
|
+
f"{MODELS_INI_TEMPLATE}\n"
|
|
255
|
+
" reinstall the llmstack package or set $LLMSTACK_MODELS_INI"
|
|
256
|
+
)
|
|
257
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
import shutil as _shutil
|
|
259
|
+
_shutil.copyfile(MODELS_INI_TEMPLATE, p)
|
|
260
|
+
return p, True
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# channel-marker on-disk format
|
|
265
|
+
#
|
|
266
|
+
# Both ``.llmstack/active-channel`` (live) and ``.llmstack/default-channel``
|
|
267
|
+
# (intent, written by install) use the same one-line format:
|
|
268
|
+
#
|
|
269
|
+
# <channel>[ <url>]\n
|
|
270
|
+
#
|
|
271
|
+
# For local channels (``current`` / ``next``) the line is just the
|
|
272
|
+
# channel name. For ``external`` we append the remote llmstack URL so
|
|
273
|
+
# the activate hook can re-export ``LLMSTACK_REMOTE_URL`` without the
|
|
274
|
+
# user having to set it in their shell rc -- entering a project is
|
|
275
|
+
# enough to wire the env back up.
|
|
276
|
+
#
|
|
277
|
+
# The format is deliberately whitespace-separated (not JSON / TSV) so a
|
|
278
|
+
# shell can parse it with ``read -r channel url < marker`` -- no jq, no
|
|
279
|
+
# python, just a builtin.
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True)
|
|
283
|
+
class ChannelMark:
|
|
284
|
+
"""One channel-marker file's worth of state."""
|
|
285
|
+
|
|
286
|
+
channel: str
|
|
287
|
+
url: str | None = None
|
|
288
|
+
|
|
289
|
+
def serialize(self) -> str:
|
|
290
|
+
if self.url:
|
|
291
|
+
return f"{self.channel} {self.url}\n"
|
|
292
|
+
return f"{self.channel}\n"
|
|
293
|
+
|
|
294
|
+
@classmethod
|
|
295
|
+
def parse(cls, text: str) -> ChannelMark | None:
|
|
296
|
+
line = text.strip()
|
|
297
|
+
if not line:
|
|
298
|
+
return None
|
|
299
|
+
parts = line.split(maxsplit=1)
|
|
300
|
+
return cls(parts[0], parts[1] if len(parts) > 1 else None)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def read_marker(path: Path) -> ChannelMark | None:
|
|
304
|
+
"""Return the parsed marker, or ``None`` when the file is missing/empty."""
|
|
305
|
+
if not path.is_file():
|
|
306
|
+
return None
|
|
307
|
+
try:
|
|
308
|
+
return ChannelMark.parse(path.read_text())
|
|
309
|
+
except OSError:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def write_marker(path: Path, mark: ChannelMark) -> None:
|
|
314
|
+
"""Atomically write ``mark`` to ``path`` (creates parents as needed)."""
|
|
315
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
317
|
+
tmp.write_text(mark.serialize())
|
|
318
|
+
os.replace(tmp, path)
|