annotuneagent 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.
- annotuneagent/__init__.py +68 -0
- annotuneagent/__main__.py +6 -0
- annotuneagent/_version.py +19 -0
- annotuneagent/annotate.py +93 -0
- annotuneagent/arbitrate.py +141 -0
- annotuneagent/cli.py +366 -0
- annotuneagent/decide.py +725 -0
- annotuneagent/evaluate.py +121 -0
- annotuneagent/evidence.py +606 -0
- annotuneagent/granularity/__init__.py +28 -0
- annotuneagent/granularity/cl_dag.py +179 -0
- annotuneagent/granularity/ontology_map.py +108 -0
- annotuneagent/granularity/relations.py +96 -0
- annotuneagent/hypotheses.py +38 -0
- annotuneagent/local_lora.py +706 -0
- annotuneagent/marker_plot.py +172 -0
- annotuneagent/markers.py +206 -0
- annotuneagent/markers.tsv +378 -0
- annotuneagent/mltools.py +128 -0
- annotuneagent/ontology/cl_isa.json +1 -0
- annotuneagent/ontology/granularity_policy.yaml +8 -0
- annotuneagent/ontology/ontology_map.jsonl +198 -0
- annotuneagent/prepare.py +1385 -0
- annotuneagent/py.typed +0 -0
- annotuneagent/read.py +76 -0
- annotuneagent/tokenizer/LICENSE +202 -0
- annotuneagent/tokenizer/merges.txt +247587 -0
- annotuneagent/tokenizer/tokenizer.json +495920 -0
- annotuneagent/tokenizer/tokenizer_config.json +305 -0
- annotuneagent/tokenizer/vocab.json +248046 -0
- annotuneagent/votes.py +83 -0
- annotuneagent/workflow.py +2037 -0
- annotuneagent-0.1.0.dist-info/METADATA +208 -0
- annotuneagent-0.1.0.dist-info/RECORD +38 -0
- annotuneagent-0.1.0.dist-info/WHEEL +5 -0
- annotuneagent-0.1.0.dist-info/entry_points.txt +2 -0
- annotuneagent-0.1.0.dist-info/licenses/LICENSE +201 -0
- annotuneagent-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
# Copyright 2026 Zhao Li (李钊)
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""One Q6_K base, two LoRA adapters, one enabled per request.
|
|
5
|
+
|
|
6
|
+
llama-server loads name and ml together and leaves them off.
|
|
7
|
+
A request sets that task's scale to 1 and the other to 0. Sequences
|
|
8
|
+
past ``maxlen`` raise. They are not truncated.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import getpass
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import stat
|
|
18
|
+
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
_TASKS = ("name", "ml")
|
|
27
|
+
# One private directory per user, so two users never share a state file or a log.
|
|
28
|
+
# os.getuid does not exist on Windows. The package must still import there.
|
|
29
|
+
_USER = str(os.getuid()) if hasattr(os, "getuid") else getpass.getuser()
|
|
30
|
+
_RUN_DIR = (
|
|
31
|
+
Path(os.environ["XDG_RUNTIME_DIR"]) / "annotune"
|
|
32
|
+
if os.environ.get("XDG_RUNTIME_DIR")
|
|
33
|
+
else Path(tempfile.gettempdir()) / f"annotune-{_USER}"
|
|
34
|
+
)
|
|
35
|
+
_STATE = _RUN_DIR / "llama-server.json"
|
|
36
|
+
_LOG = _RUN_DIR / "llama-server.log"
|
|
37
|
+
_DEFAULT_SERVICE = "http://127.0.0.1:8080"
|
|
38
|
+
_SERVER: dict | None = None
|
|
39
|
+
_LAST: dict = {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Running:
|
|
43
|
+
"""A llama-server started by another process. ``poll`` is None while it lives."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, pid: int) -> None:
|
|
46
|
+
self.pid = pid
|
|
47
|
+
|
|
48
|
+
def poll(self) -> int | None:
|
|
49
|
+
return None if _alive(self.pid) else 1
|
|
50
|
+
|
|
51
|
+
def wait(self, timeout: float | None = None) -> int:
|
|
52
|
+
deadline = None if timeout is None else time.time() + timeout
|
|
53
|
+
while _alive(self.pid):
|
|
54
|
+
if deadline is not None and time.time() > deadline:
|
|
55
|
+
raise subprocess.TimeoutExpired(cmd="llama-server", timeout=timeout)
|
|
56
|
+
time.sleep(0.2)
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _Remote:
|
|
61
|
+
"""An HTTP server this process did not start."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, url: str) -> None:
|
|
64
|
+
self.url = url
|
|
65
|
+
self.pid = -1
|
|
66
|
+
|
|
67
|
+
def poll(self) -> int | None:
|
|
68
|
+
return None if _probe(self.url) is not None else 1
|
|
69
|
+
|
|
70
|
+
def wait(self, timeout: float | None = None) -> int:
|
|
71
|
+
raise subprocess.TimeoutExpired(cmd=self.url, timeout=timeout)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _alive(pid: int) -> bool:
|
|
75
|
+
try:
|
|
76
|
+
os.kill(pid, 0)
|
|
77
|
+
except ProcessLookupError:
|
|
78
|
+
return False
|
|
79
|
+
except PermissionError:
|
|
80
|
+
return True
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SequenceTooLong(RuntimeError):
|
|
85
|
+
def __init__(self, tokens: int, maxlen: int):
|
|
86
|
+
super().__init__(f"sequence is {tokens} tokens, MAXLEN={maxlen}; do not truncate")
|
|
87
|
+
self.tokens = tokens
|
|
88
|
+
self.maxlen = maxlen
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def scales_for(task: str) -> list[dict[str, float]]:
|
|
92
|
+
"""Enable one adapter. The other stays at scale 0 for this request."""
|
|
93
|
+
if task not in _TASKS:
|
|
94
|
+
raise ValueError(f"unknown task {task}")
|
|
95
|
+
return [
|
|
96
|
+
{"id": index, "scale": 1.0 if name == task else 0.0} for index, name in enumerate(_TASKS)
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def last_call() -> dict:
|
|
101
|
+
return dict(_LAST)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def packaged_tokenizer() -> str:
|
|
105
|
+
"""Qwen tokenizer shipped with the package."""
|
|
106
|
+
return str(Path(__file__).resolve().parent / "tokenizer")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _tokenizer_dir(path: str | None) -> str:
|
|
110
|
+
return packaged_tokenizer() if path is None else str(path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def service_url() -> str:
|
|
114
|
+
"""Server root. ``ANNOTUNE_URL`` overrides ``http://127.0.0.1:8080``."""
|
|
115
|
+
return _base_url(os.environ.get("ANNOTUNE_URL", _DEFAULT_SERVICE))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def attach(url: str | None = None, *, tokenizer: str | None = None) -> str:
|
|
119
|
+
"""Use a server that is already running. Does not load weights."""
|
|
120
|
+
target = _base_url(url) if url else service_url()
|
|
121
|
+
tokenizer = _tokenizer_dir(tokenizer)
|
|
122
|
+
if _server_live() and _SERVER is not None and _SERVER.get("url") == target:
|
|
123
|
+
_SERVER["tokenizer"] = tokenizer
|
|
124
|
+
_SERVER["tok"] = None
|
|
125
|
+
return target
|
|
126
|
+
if _server_live() and _SERVER is not None and _SERVER.get("owned"):
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
f"this process already started {_SERVER['url']}; release() before using {target}"
|
|
129
|
+
)
|
|
130
|
+
_clear_client()
|
|
131
|
+
if not _use_existing(target, tokenizer):
|
|
132
|
+
raise RuntimeError(f"server is not ready: {target}")
|
|
133
|
+
return target
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def open_adapters(
|
|
137
|
+
adapters: dict[str, str],
|
|
138
|
+
*,
|
|
139
|
+
gguf: str,
|
|
140
|
+
tokenizer: str | None = None,
|
|
141
|
+
ctx: int = 8192,
|
|
142
|
+
url: str | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Use the server at ``url``, or load one base and two adapters there.
|
|
145
|
+
|
|
146
|
+
A server that is already up is reused. This does not start a second one.
|
|
147
|
+
"""
|
|
148
|
+
global _SERVER
|
|
149
|
+
target = _base_url(url) if url else service_url()
|
|
150
|
+
tokenizer = _tokenizer_dir(tokenizer)
|
|
151
|
+
missing = [name for name in _TASKS if not adapters.get(name)]
|
|
152
|
+
if missing:
|
|
153
|
+
raise ValueError(f"shared base needs adapters for {', '.join(missing)}")
|
|
154
|
+
paths = {name: str(Path(adapters[name]).resolve()) for name in _TASKS}
|
|
155
|
+
base = str(Path(gguf).resolve())
|
|
156
|
+
if _server_live() and _SERVER.get("url") == target:
|
|
157
|
+
_require_matching_model(_SERVER, base, paths, ctx)
|
|
158
|
+
if _SERVER["tokenizer"] != tokenizer:
|
|
159
|
+
raise RuntimeError("server already uses a different tokenizer")
|
|
160
|
+
return
|
|
161
|
+
if _server_live() and _SERVER.get("owned"):
|
|
162
|
+
raise RuntimeError(
|
|
163
|
+
f"this process already started {_SERVER['url']}; release() before using {target}"
|
|
164
|
+
)
|
|
165
|
+
_clear_client()
|
|
166
|
+
if _use_existing(target, tokenizer, gguf=base, adapters=paths, ctx=ctx):
|
|
167
|
+
return
|
|
168
|
+
other = _other_local(target)
|
|
169
|
+
if other is not None:
|
|
170
|
+
raise RuntimeError(
|
|
171
|
+
f"a model is already loaded at {other}; not loading another. "
|
|
172
|
+
f"Service address is {target}."
|
|
173
|
+
)
|
|
174
|
+
host, port = _split_host(target)
|
|
175
|
+
if host not in {"127.0.0.1", "localhost"}:
|
|
176
|
+
raise RuntimeError(f"server is not ready: {target}")
|
|
177
|
+
base = _gguf_path(base)
|
|
178
|
+
binary = _binary()
|
|
179
|
+
_private_dir(_LOG.parent)
|
|
180
|
+
log = _LOG.open("w")
|
|
181
|
+
proc = subprocess.Popen(
|
|
182
|
+
[
|
|
183
|
+
binary,
|
|
184
|
+
"-m",
|
|
185
|
+
base,
|
|
186
|
+
"--lora",
|
|
187
|
+
",".join(paths[name] for name in _TASKS),
|
|
188
|
+
"--lora-init-without-apply",
|
|
189
|
+
"-c",
|
|
190
|
+
str(ctx),
|
|
191
|
+
"-ngl",
|
|
192
|
+
"99",
|
|
193
|
+
"-np",
|
|
194
|
+
"1",
|
|
195
|
+
"--no-cont-batching",
|
|
196
|
+
"--no-cache-prompt",
|
|
197
|
+
"--slot-prompt-similarity",
|
|
198
|
+
"0",
|
|
199
|
+
"--no-jinja",
|
|
200
|
+
"--host",
|
|
201
|
+
host,
|
|
202
|
+
"--port",
|
|
203
|
+
str(port),
|
|
204
|
+
],
|
|
205
|
+
stdout=log,
|
|
206
|
+
stderr=subprocess.STDOUT,
|
|
207
|
+
env=_child_env(binary),
|
|
208
|
+
start_new_session=True,
|
|
209
|
+
)
|
|
210
|
+
_SERVER = _runtime(
|
|
211
|
+
proc,
|
|
212
|
+
url=target,
|
|
213
|
+
port=port,
|
|
214
|
+
gguf=base,
|
|
215
|
+
adapters=paths,
|
|
216
|
+
ctx=ctx,
|
|
217
|
+
tokenizer=tokenizer,
|
|
218
|
+
mode="llama",
|
|
219
|
+
owned=True,
|
|
220
|
+
log=log,
|
|
221
|
+
)
|
|
222
|
+
try:
|
|
223
|
+
_wait_ready(proc, target, _LOG)
|
|
224
|
+
except Exception:
|
|
225
|
+
release()
|
|
226
|
+
raise
|
|
227
|
+
_write_state()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def complete(task: str, system: str, payload: dict | str, *, maxlen: int) -> str:
|
|
231
|
+
"""Run one greedy request with only ``task`` applied."""
|
|
232
|
+
if task not in _TASKS:
|
|
233
|
+
raise ValueError(f"unknown task {task}")
|
|
234
|
+
if not _server_live():
|
|
235
|
+
raise RuntimeError("shared base is not loaded")
|
|
236
|
+
from .decide import user_text
|
|
237
|
+
|
|
238
|
+
user = user_text(task, payload)
|
|
239
|
+
prompt = _render(system, user)
|
|
240
|
+
tokens = len(_tokenizer()(prompt, add_special_tokens=False).input_ids)
|
|
241
|
+
if tokens + 32 > maxlen:
|
|
242
|
+
raise SequenceTooLong(tokens, maxlen)
|
|
243
|
+
runtime = _SERVER
|
|
244
|
+
if runtime.get("mode") == "chat":
|
|
245
|
+
from .decide import post_chat
|
|
246
|
+
|
|
247
|
+
text = str(post_chat(runtime["url"], system, payload, 180, task=task)).strip()
|
|
248
|
+
scales: list[dict[str, float]] = []
|
|
249
|
+
evaluated = None
|
|
250
|
+
predicted = None
|
|
251
|
+
else:
|
|
252
|
+
scales = scales_for(task)
|
|
253
|
+
raw = _post(
|
|
254
|
+
"/completion",
|
|
255
|
+
{
|
|
256
|
+
"prompt": prompt,
|
|
257
|
+
"n_predict": 32,
|
|
258
|
+
"temperature": 0,
|
|
259
|
+
"top_k": 1,
|
|
260
|
+
"top_p": 1,
|
|
261
|
+
"min_p": 0,
|
|
262
|
+
"cache_prompt": False,
|
|
263
|
+
"lora": scales,
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
text = str(raw.get("content") or "").strip()
|
|
267
|
+
evaluated = raw.get("tokens_evaluated")
|
|
268
|
+
predicted = raw.get("tokens_predicted")
|
|
269
|
+
global _LAST
|
|
270
|
+
_LAST = {
|
|
271
|
+
"task": task,
|
|
272
|
+
"lora": scales,
|
|
273
|
+
"tokens": tokens,
|
|
274
|
+
"tokens_evaluated": evaluated,
|
|
275
|
+
"tokens_predicted": predicted,
|
|
276
|
+
}
|
|
277
|
+
return text
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def local_server() -> str | None:
|
|
281
|
+
"""Base URL of a loaded server. None when nothing is running."""
|
|
282
|
+
if _server_live():
|
|
283
|
+
return str(_SERVER.get("url") or "")
|
|
284
|
+
record = _read_state()
|
|
285
|
+
if record is not None and _alive(int(record.get("pid", -1))):
|
|
286
|
+
url = str(record.get("url") or f"http://127.0.0.1:{int(record['port'])}")
|
|
287
|
+
if _health_ok(url):
|
|
288
|
+
return url
|
|
289
|
+
found = _loaded_servers()
|
|
290
|
+
if len(found) == 1:
|
|
291
|
+
return str(found[0]["url"])
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def release() -> None:
|
|
296
|
+
"""Stop a server this process started."""
|
|
297
|
+
global _SERVER
|
|
298
|
+
runtime = _SERVER
|
|
299
|
+
_SERVER = None
|
|
300
|
+
if runtime is None:
|
|
301
|
+
return
|
|
302
|
+
proc = runtime["proc"]
|
|
303
|
+
log = runtime.get("log")
|
|
304
|
+
if runtime.get("owned") and proc.poll() is None and getattr(proc, "pid", 0) > 0:
|
|
305
|
+
try:
|
|
306
|
+
os.killpg(proc.pid, 15)
|
|
307
|
+
except (ProcessLookupError, PermissionError):
|
|
308
|
+
pass
|
|
309
|
+
try:
|
|
310
|
+
proc.wait(timeout=10)
|
|
311
|
+
except subprocess.TimeoutExpired:
|
|
312
|
+
try:
|
|
313
|
+
os.killpg(proc.pid, 9)
|
|
314
|
+
except ProcessLookupError:
|
|
315
|
+
pass
|
|
316
|
+
proc.wait(timeout=5)
|
|
317
|
+
if log is not None:
|
|
318
|
+
log.close()
|
|
319
|
+
if not runtime.get("owned"):
|
|
320
|
+
return
|
|
321
|
+
record = _read_state()
|
|
322
|
+
pid = getattr(proc, "pid", None)
|
|
323
|
+
if record is None or int(record.get("pid", -2)) == pid:
|
|
324
|
+
_STATE.unlink(missing_ok=True)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _private_dir(path: Path) -> None:
|
|
328
|
+
"""Create ``path`` for this user alone. Refuse one that someone else made or can use."""
|
|
329
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
330
|
+
info = path.lstat()
|
|
331
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
|
332
|
+
raise RuntimeError(f"{path} must be a plain directory")
|
|
333
|
+
if hasattr(os, "getuid") and (info.st_uid != os.getuid() or info.st_mode & 0o077):
|
|
334
|
+
raise RuntimeError(f"{path} is not private to this user; remove it or set XDG_RUNTIME_DIR")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _read_state() -> dict | None:
|
|
338
|
+
if _STATE.parent.exists():
|
|
339
|
+
_private_dir(_STATE.parent)
|
|
340
|
+
try:
|
|
341
|
+
record = json.loads(_STATE.read_text())
|
|
342
|
+
except (OSError, json.JSONDecodeError):
|
|
343
|
+
return None
|
|
344
|
+
return record if isinstance(record, dict) else None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _health_ok(url: str) -> bool:
|
|
348
|
+
try:
|
|
349
|
+
with urllib.request.urlopen(f"{url}/health", timeout=2) as resp:
|
|
350
|
+
payload = json.loads(resp.read().decode())
|
|
351
|
+
except (OSError, urllib.error.URLError, json.JSONDecodeError, TimeoutError):
|
|
352
|
+
return False
|
|
353
|
+
return isinstance(payload, dict) and payload.get("status") == "ok"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _models_ok(url: str) -> bool:
|
|
357
|
+
try:
|
|
358
|
+
with urllib.request.urlopen(f"{url}/v1/models", timeout=2) as resp:
|
|
359
|
+
return 200 <= getattr(resp, "status", 200) < 300
|
|
360
|
+
except (OSError, urllib.error.URLError, TimeoutError):
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _probe(url: str) -> str | None:
|
|
365
|
+
"""``llama`` for this server's health route, ``chat`` for an OpenAI-style API."""
|
|
366
|
+
if _health_ok(url):
|
|
367
|
+
return "llama"
|
|
368
|
+
if _models_ok(url):
|
|
369
|
+
return "chat"
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _base_url(url: str) -> str:
|
|
374
|
+
text = url.strip().rstrip("/")
|
|
375
|
+
if "://" not in text:
|
|
376
|
+
text = "http://" + text
|
|
377
|
+
return text
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _split_host(url: str) -> tuple[str, int]:
|
|
381
|
+
parsed = urllib.parse.urlparse(url)
|
|
382
|
+
host = parsed.hostname or "127.0.0.1"
|
|
383
|
+
if parsed.port is not None:
|
|
384
|
+
return host, parsed.port
|
|
385
|
+
return host, 443 if parsed.scheme == "https" else 80
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _server_live() -> bool:
|
|
389
|
+
runtime = _SERVER
|
|
390
|
+
return runtime is not None and runtime["proc"].poll() is None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _clear_client() -> None:
|
|
394
|
+
global _SERVER
|
|
395
|
+
runtime = _SERVER
|
|
396
|
+
_SERVER = None
|
|
397
|
+
if runtime is None:
|
|
398
|
+
return
|
|
399
|
+
log = runtime.get("log")
|
|
400
|
+
if log is not None:
|
|
401
|
+
log.close()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _runtime(
|
|
405
|
+
proc,
|
|
406
|
+
*,
|
|
407
|
+
url: str,
|
|
408
|
+
port: int,
|
|
409
|
+
gguf: str,
|
|
410
|
+
adapters: dict[str, str],
|
|
411
|
+
ctx: int,
|
|
412
|
+
tokenizer: str,
|
|
413
|
+
mode: str,
|
|
414
|
+
owned: bool,
|
|
415
|
+
log,
|
|
416
|
+
) -> dict:
|
|
417
|
+
return {
|
|
418
|
+
"proc": proc,
|
|
419
|
+
"port": port,
|
|
420
|
+
"log": log,
|
|
421
|
+
"log_path": _LOG,
|
|
422
|
+
"gguf": gguf,
|
|
423
|
+
"adapters": adapters,
|
|
424
|
+
"ctx": ctx,
|
|
425
|
+
"tokenizer": tokenizer,
|
|
426
|
+
"tok": None,
|
|
427
|
+
"owned": owned,
|
|
428
|
+
"url": url,
|
|
429
|
+
"mode": mode,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _use_existing(
|
|
434
|
+
url: str,
|
|
435
|
+
tokenizer: str,
|
|
436
|
+
*,
|
|
437
|
+
gguf: str | None = None,
|
|
438
|
+
adapters: dict[str, str] | None = None,
|
|
439
|
+
ctx: int = 0,
|
|
440
|
+
) -> bool:
|
|
441
|
+
"""Attach to ``url`` when it is already serving. Does not start a process."""
|
|
442
|
+
mode = _probe(url)
|
|
443
|
+
if mode is None:
|
|
444
|
+
return False
|
|
445
|
+
loaded = _server_on(url) if mode == "llama" else None
|
|
446
|
+
if adapters is not None:
|
|
447
|
+
if loaded is None:
|
|
448
|
+
raise RuntimeError(
|
|
449
|
+
f"server at {url} is already running but its GGUF and adapters cannot be verified"
|
|
450
|
+
)
|
|
451
|
+
_require_matching_model(loaded, str(gguf), adapters, ctx)
|
|
452
|
+
if loaded is not None:
|
|
453
|
+
_connect_loaded(loaded, tokenizer)
|
|
454
|
+
else:
|
|
455
|
+
_connect_remote(url, tokenizer, mode)
|
|
456
|
+
print(f"server already loaded at {url}; not loading again", flush=True)
|
|
457
|
+
return True
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _require_matching_model(loaded: dict, gguf: str, adapters: dict[str, str], ctx: int) -> None:
|
|
461
|
+
if (
|
|
462
|
+
loaded.get("gguf") != gguf
|
|
463
|
+
or loaded.get("adapters") != adapters
|
|
464
|
+
or int(loaded.get("ctx") or 0) < ctx
|
|
465
|
+
):
|
|
466
|
+
raise RuntimeError("server already runs different GGUF, adapters, or context")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _connect_loaded(loaded: dict, tokenizer: str) -> None:
|
|
470
|
+
global _SERVER
|
|
471
|
+
_SERVER = _runtime(
|
|
472
|
+
_Running(int(loaded["pid"])),
|
|
473
|
+
url=str(loaded["url"]),
|
|
474
|
+
port=int(loaded["port"]),
|
|
475
|
+
gguf=str(loaded.get("gguf") or ""),
|
|
476
|
+
adapters=dict(loaded.get("adapters") or {}),
|
|
477
|
+
ctx=int(loaded.get("ctx") or 0),
|
|
478
|
+
tokenizer=tokenizer,
|
|
479
|
+
mode="llama",
|
|
480
|
+
owned=False,
|
|
481
|
+
log=None,
|
|
482
|
+
)
|
|
483
|
+
_write_state()
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _connect_remote(url: str, tokenizer: str, mode: str) -> None:
|
|
487
|
+
global _SERVER
|
|
488
|
+
_host, port = _split_host(url)
|
|
489
|
+
_SERVER = _runtime(
|
|
490
|
+
_Remote(url),
|
|
491
|
+
url=url,
|
|
492
|
+
port=port,
|
|
493
|
+
gguf="",
|
|
494
|
+
adapters={},
|
|
495
|
+
ctx=0,
|
|
496
|
+
tokenizer=tokenizer,
|
|
497
|
+
mode=mode,
|
|
498
|
+
owned=False,
|
|
499
|
+
log=None,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _other_local(target: str) -> str | None:
|
|
504
|
+
for item in _loaded_servers():
|
|
505
|
+
if item["url"] != target:
|
|
506
|
+
return str(item["url"])
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _server_on(url: str) -> dict | None:
|
|
511
|
+
for item in _loaded_servers():
|
|
512
|
+
if item["url"] == url:
|
|
513
|
+
return item
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _loaded_servers() -> list[dict]:
|
|
518
|
+
"""llama-server processes that are listening and healthy."""
|
|
519
|
+
root = Path("/proc")
|
|
520
|
+
if not root.is_dir():
|
|
521
|
+
return []
|
|
522
|
+
found = []
|
|
523
|
+
for entry in root.iterdir():
|
|
524
|
+
if not entry.name.isdigit():
|
|
525
|
+
continue
|
|
526
|
+
argv = _cmdline(int(entry.name))
|
|
527
|
+
if not argv or Path(argv[0]).name != "llama-server":
|
|
528
|
+
continue
|
|
529
|
+
port = _listen_port(int(entry.name))
|
|
530
|
+
if port is None:
|
|
531
|
+
continue
|
|
532
|
+
host = _arg(argv, "--host") or "127.0.0.1"
|
|
533
|
+
url = f"http://{host}:{port}"
|
|
534
|
+
if not _health_ok(url):
|
|
535
|
+
continue
|
|
536
|
+
lora = _arg(argv, "--lora") or ""
|
|
537
|
+
names = [part for part in lora.split(",") if part]
|
|
538
|
+
adapters = dict(zip(_TASKS, names, strict=True)) if len(names) == 2 else {}
|
|
539
|
+
ctx_text = _arg(argv, "-c") or _arg(argv, "--ctx-size")
|
|
540
|
+
found.append(
|
|
541
|
+
{
|
|
542
|
+
"pid": int(entry.name),
|
|
543
|
+
"port": port,
|
|
544
|
+
"url": url,
|
|
545
|
+
"gguf": _arg(argv, "-m") or _arg(argv, "--model") or "",
|
|
546
|
+
"adapters": adapters,
|
|
547
|
+
"ctx": int(ctx_text) if ctx_text and ctx_text.isdigit() else 0,
|
|
548
|
+
}
|
|
549
|
+
)
|
|
550
|
+
return found
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _cmdline(pid: int) -> list[str]:
|
|
554
|
+
try:
|
|
555
|
+
raw = Path(f"/proc/{pid}/cmdline").read_bytes()
|
|
556
|
+
except OSError:
|
|
557
|
+
return []
|
|
558
|
+
return [part.decode(errors="replace") for part in raw.split(b"\0") if part]
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _arg(argv: list[str], flag: str) -> str | None:
|
|
562
|
+
if flag not in argv:
|
|
563
|
+
return None
|
|
564
|
+
index = argv.index(flag)
|
|
565
|
+
if index + 1 >= len(argv):
|
|
566
|
+
return None
|
|
567
|
+
return argv[index + 1]
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _listen_port(pid: int) -> int | None:
|
|
571
|
+
inodes: set[str] = set()
|
|
572
|
+
try:
|
|
573
|
+
for entry in Path(f"/proc/{pid}/fd").iterdir():
|
|
574
|
+
try:
|
|
575
|
+
target = os.readlink(entry)
|
|
576
|
+
except OSError:
|
|
577
|
+
continue
|
|
578
|
+
if target.startswith("socket:[") and target.endswith("]"):
|
|
579
|
+
inodes.add(target[8:-1])
|
|
580
|
+
except OSError:
|
|
581
|
+
return None
|
|
582
|
+
if not inodes:
|
|
583
|
+
return None
|
|
584
|
+
for name in ("/proc/net/tcp", "/proc/net/tcp6"):
|
|
585
|
+
try:
|
|
586
|
+
lines = Path(name).read_text().splitlines()[1:]
|
|
587
|
+
except OSError:
|
|
588
|
+
continue
|
|
589
|
+
for line in lines:
|
|
590
|
+
fields = line.split()
|
|
591
|
+
if len(fields) < 10 or fields[3] != "0A" or fields[9] not in inodes:
|
|
592
|
+
continue
|
|
593
|
+
return int(fields[1].rsplit(":", 1)[-1], 16)
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _write_state() -> None:
|
|
598
|
+
runtime = _SERVER
|
|
599
|
+
if runtime is None:
|
|
600
|
+
return
|
|
601
|
+
pid = getattr(runtime["proc"], "pid", -1)
|
|
602
|
+
if not isinstance(pid, int) or pid <= 0:
|
|
603
|
+
return
|
|
604
|
+
_private_dir(_STATE.parent)
|
|
605
|
+
_STATE.write_text(
|
|
606
|
+
json.dumps(
|
|
607
|
+
{
|
|
608
|
+
"pid": pid,
|
|
609
|
+
"port": runtime["port"],
|
|
610
|
+
"url": runtime["url"],
|
|
611
|
+
"gguf": runtime["gguf"],
|
|
612
|
+
"adapters": runtime["adapters"],
|
|
613
|
+
"ctx": runtime["ctx"],
|
|
614
|
+
"tokenizer": runtime["tokenizer"],
|
|
615
|
+
}
|
|
616
|
+
)
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _gguf_path(gguf: str) -> str:
|
|
621
|
+
path = Path(gguf)
|
|
622
|
+
if not path.is_file():
|
|
623
|
+
raise RuntimeError(f"Q6_K base is missing: {path}")
|
|
624
|
+
return str(path)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _binary() -> str:
|
|
628
|
+
env = os.environ.get("LLAMA_SERVER")
|
|
629
|
+
found = env or shutil.which("llama-server")
|
|
630
|
+
if not found or not Path(found).is_file():
|
|
631
|
+
raise RuntimeError("llama-server is not on PATH; set LLAMA_SERVER")
|
|
632
|
+
return str(Path(found).resolve())
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _child_env(binary: str) -> dict[str, str]:
|
|
636
|
+
env = os.environ.copy()
|
|
637
|
+
root = Path(binary).resolve().parent
|
|
638
|
+
libs = [str(root)]
|
|
639
|
+
parent = root.parent
|
|
640
|
+
libs.extend(str(path) for path in parent.glob("cudart-*") if path.is_dir())
|
|
641
|
+
previous = env.get("LD_LIBRARY_PATH")
|
|
642
|
+
if previous:
|
|
643
|
+
libs.append(previous)
|
|
644
|
+
env["LD_LIBRARY_PATH"] = ":".join(libs)
|
|
645
|
+
return env
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _wait_ready(proc: subprocess.Popen, url: str, log_path: Path) -> None:
|
|
649
|
+
deadline = time.time() + 180
|
|
650
|
+
while time.time() < deadline:
|
|
651
|
+
if proc.poll() is not None:
|
|
652
|
+
raise RuntimeError(f"llama-server exited {proc.returncode}\n{_tail(log_path)}")
|
|
653
|
+
if _health_ok(url):
|
|
654
|
+
return
|
|
655
|
+
time.sleep(0.4)
|
|
656
|
+
raise RuntimeError(f"llama-server did not become ready\n{_tail(log_path)}")
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _tail(path: Path) -> str:
|
|
660
|
+
try:
|
|
661
|
+
lines = path.read_text(errors="replace").splitlines()
|
|
662
|
+
except OSError:
|
|
663
|
+
return ""
|
|
664
|
+
return "\n".join(lines[-40:])
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _tokenizer():
|
|
668
|
+
runtime = _SERVER
|
|
669
|
+
if runtime is None:
|
|
670
|
+
raise RuntimeError("shared base is not loaded")
|
|
671
|
+
if runtime["tok"] is None:
|
|
672
|
+
from transformers import AutoTokenizer
|
|
673
|
+
|
|
674
|
+
runtime["tok"] = AutoTokenizer.from_pretrained(runtime["tokenizer"])
|
|
675
|
+
return runtime["tok"]
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _render(system: str, user: str) -> str:
|
|
679
|
+
return _tokenizer().apply_chat_template(
|
|
680
|
+
[{"role": "system", "content": system}, {"role": "user", "content": user}],
|
|
681
|
+
tokenize=False,
|
|
682
|
+
add_generation_prompt=True,
|
|
683
|
+
enable_thinking=False,
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _post(path: str, payload: dict) -> dict:
|
|
688
|
+
runtime = _SERVER
|
|
689
|
+
if runtime is None:
|
|
690
|
+
raise RuntimeError("shared base is not loaded")
|
|
691
|
+
data = json.dumps(payload).encode()
|
|
692
|
+
request = urllib.request.Request(
|
|
693
|
+
f"{runtime['url']}{path}",
|
|
694
|
+
data=data,
|
|
695
|
+
headers={"Content-Type": "application/json"},
|
|
696
|
+
method="POST",
|
|
697
|
+
)
|
|
698
|
+
try:
|
|
699
|
+
with urllib.request.urlopen(request, timeout=180) as resp:
|
|
700
|
+
body = json.loads(resp.read().decode())
|
|
701
|
+
except urllib.error.HTTPError as exc:
|
|
702
|
+
detail = exc.read().decode(errors="replace")
|
|
703
|
+
raise RuntimeError(f"llama-server {exc.code}: {detail}") from exc
|
|
704
|
+
if not isinstance(body, dict):
|
|
705
|
+
raise RuntimeError("llama-server reply is not an object")
|
|
706
|
+
return body
|