mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 14:21:06 -10:00
Merge f-dsv41-setup 86ba33845 into f-dsv41-rel (serve: DeepSeek turn without </think> returns its text as content; pack recipe noise token/markov rank; exec bits in packages; console-safe smoke)
This commit is contained in:
commit
0be62ccad7
9 files changed
+124
-16
No files matched your search
@@ -139,7 +139,9 @@ def build(src: Path, backend: str, version: str, target: str, out: Path, *, entr
|
||||
manifest["notes"] = notes
|
||||
(staging / ec.MANIFEST).write_text(json.dumps(manifest, indent=1) + "\n", encoding="utf-8")
|
||||
if os.name != "nt":
|
||||
(staging / entrypoint).chmod(0o755)
|
||||
for exe_name in (entrypoint, DSV41_EXE, "strata-vision", "strata-vision-hip"): # the programs setup starts: executable in the tar
|
||||
if (staging / exe_name).is_file():
|
||||
(staging / exe_name).chmod(0o755)
|
||||
ec.verify(ec.read_manifest(staging), full=True)
|
||||
staging.rename(folder)
|
||||
except BaseException:
|
||||
|
||||
+24
-4
@@ -22,7 +22,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from serve.frontend import OutputParser
|
||||
from serve.frontend import Event, OutputParser
|
||||
|
||||
DSML = "|DSML|"
|
||||
_OPEN = "<" + DSML
|
||||
@@ -168,6 +168,19 @@ class DsmlOutputParser(OutputParser):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.dsml = DsmlTranslator(self._on_param)
|
||||
# The 2-bit sets often write the whole answer after <think> and stop (EOS) without </think>: everything would be reasoning and
|
||||
# the reply empty (P2003 install test, 4 of 4 prompts). The reasoning text is kept here; finish() hands it over as the answer
|
||||
# when the turn ended on a stop with no answer and no call.
|
||||
self._reasoning: list[str] = []
|
||||
self._answered = False
|
||||
|
||||
def _watch(self, evs):
|
||||
for ev in evs:
|
||||
if ev.kind == "reasoning":
|
||||
self._reasoning.append(ev.text)
|
||||
elif ev.kind in ("tool_start", "tool_call") or (ev.kind == "content" and ev.text.strip()):
|
||||
self._answered = True
|
||||
return evs
|
||||
|
||||
def _on_param(self, tool, name, is_string) -> None:
|
||||
"""string="true": the value is a string whatever it looks like ("123", "true"), as the template wrote it."""
|
||||
@@ -186,12 +199,19 @@ class DsmlOutputParser(OutputParser):
|
||||
self.sdeclared[name] = "string"
|
||||
|
||||
def feed(self, delta: str):
|
||||
return super().feed(self.dsml.feed(delta))
|
||||
return self._watch(super().feed(self.dsml.feed(delta)))
|
||||
|
||||
def finish(self, reason: str | None = None):
|
||||
rest = self.dsml.flush()
|
||||
out = super().feed(rest) if rest else []
|
||||
return out + super().finish(reason)
|
||||
out = self._watch(super().feed(rest) if rest else [])
|
||||
out += self._watch(super().finish(reason))
|
||||
text = "".join(self._reasoning).strip()
|
||||
if reason in (None, "stop") and text and not self._answered:
|
||||
# no </think> and no answer: the text IS the answer. A streaming client has already seen it as reasoning and now gets
|
||||
# it as content; the non-streaming collectors (server.py) drop the identical reasoning.
|
||||
self._answered = True
|
||||
out.append(Event("content", text))
|
||||
return out
|
||||
|
||||
@property
|
||||
def clean(self) -> bool:
|
||||
|
||||
@@ -5557,6 +5557,8 @@ def openai_collect(chunks) -> dict:
|
||||
if last["choices"][0]["finish_reason"] != "tool_calls":
|
||||
calls = [c for c in calls if _is_json(c["function"]["arguments"])] # a call the output ended inside (#211)
|
||||
msg = {"role": "assistant", "content": "".join(content) or None}
|
||||
if "".join(reasoning).strip() and "".join(reasoning).strip() == "".join(content).strip():
|
||||
reasoning = [] # a turn with no </think> (serve/dsml.py): the text is the answer; it is not also reasoning
|
||||
if "".join(reasoning):
|
||||
msg["reasoning_content"] = "".join(reasoning)
|
||||
if calls:
|
||||
@@ -5695,6 +5697,11 @@ def anthropic_collect(events) -> dict:
|
||||
msg["stop_reason"] = e["delta"]["stop_reason"]
|
||||
msg["stop_sequence"] = e["delta"].get("stop_sequence")
|
||||
msg["usage"].update(e["usage"])
|
||||
for i in range(len(blocks) - 1): # a turn with no </think> (serve/dsml.py): the text is the answer; it is not also a thinking block
|
||||
a, b = blocks[i], blocks[i + 1]
|
||||
if a.get("type") == "thinking" and b.get("type") == "text" and a.get("thinking", "").strip() == b.get("text", "").strip():
|
||||
del blocks[i]
|
||||
break
|
||||
msg["content"] = blocks
|
||||
return msg
|
||||
|
||||
|
||||
@@ -345,6 +345,56 @@ class Api(unittest.TestCase):
|
||||
self.assertEqual(msg["content"].strip(), "Hello!")
|
||||
self.assertEqual(r["choices"][0]["finish_reason"], "stop")
|
||||
|
||||
ANSWER = "17 x 23 = 391. The answer is 391."
|
||||
|
||||
def test_no_closing_think_tag_makes_the_text_the_answer(self):
|
||||
"""The 2-bit sets write the answer after <think> and stop without </think>: it must not arrive as reasoning only (P2003)."""
|
||||
for effort in (None, "low", "medium", "high"):
|
||||
with self.subTest(effort=effort, api="openai"):
|
||||
self.start(self.ANSWER)
|
||||
status, r = self.chat(**({"reasoning_effort": effort} if effort else {}))
|
||||
self.assertEqual(status, 200, r)
|
||||
msg = r["choices"][0]["message"]
|
||||
self.assertEqual(msg["content"].strip(), self.ANSWER)
|
||||
self.assertFalse(msg.get("reasoning_content"))
|
||||
self.assertEqual(r["choices"][0]["finish_reason"], "stop")
|
||||
with self.subTest(effort=effort, api="anthropic"):
|
||||
self.start(self.ANSWER)
|
||||
status, r = self.post("/v1/messages", {"model": "x", "max_tokens": 400,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
**({"output_config": {"effort": effort}} if effort else {})})
|
||||
self.assertEqual(status, 200, r)
|
||||
self.assertEqual([b["type"] for b in r["content"]], ["text"])
|
||||
self.assertEqual(r["content"][0]["text"].strip(), self.ANSWER)
|
||||
|
||||
def test_no_closing_think_tag_streaming_ends_with_the_answer_as_content(self):
|
||||
self.start(self.ANSWER)
|
||||
status, raw = self.post("/v1/chat/completions", {"messages": [{"role": "user", "content": "hi"}], "max_tokens": 400,
|
||||
"stream": True}, raw=True)
|
||||
self.assertEqual(status, 200)
|
||||
chunks = [json.loads(l[6:]) for l in raw.splitlines() if l.startswith("data: ") and l != "data: [DONE]"]
|
||||
content = "".join(c["choices"][0]["delta"].get("content") or "" for c in chunks if c["choices"])
|
||||
self.assertEqual(content.strip(), self.ANSWER)
|
||||
status, raw = self.post("/v1/messages", {"model": "x", "max_tokens": 400, "stream": True,
|
||||
"messages": [{"role": "user", "content": "hi"}]}, raw=True)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn('"text_delta"', raw)
|
||||
self.assertIn("391", raw)
|
||||
|
||||
def test_a_turn_cut_by_max_tokens_is_not_turned_into_an_answer(self):
|
||||
self.start(self.ANSWER * 30)
|
||||
status, r = self.chat(max_tokens=8)
|
||||
msg = r["choices"][0]["message"]
|
||||
self.assertEqual(r["choices"][0]["finish_reason"], "length")
|
||||
self.assertFalse(msg.get("content"))
|
||||
self.assertTrue(msg.get("reasoning_content"))
|
||||
|
||||
def test_a_normal_closed_think_is_unchanged(self):
|
||||
self.start("Reasoning here.</think>The answer.")
|
||||
status, r = self.chat()
|
||||
msg = r["choices"][0]["message"]
|
||||
self.assertEqual((msg["reasoning_content"], msg["content"].strip()), ("Reasoning here.", "The answer."))
|
||||
|
||||
def test_thinking_off(self):
|
||||
self.start("Hello!")
|
||||
for extra in ({"chat_template_kwargs": {"enable_thinking": False}}, {"reasoning_effort": "none"}):
|
||||
|
||||
@@ -706,7 +706,7 @@ DSV41_CONTEXT = 8192 # the context the engine was checked at (4096 and
|
||||
MULTI_FILE_FAMILIES = {
|
||||
"deepseek41": {"title": "DeepSeek-V4.1-Flash", "by": "DeepSeek; the 2-bit GGUFs by IST-DAS, UD-Q8_K_XL by Unsloth", "arch": "deepseek41",
|
||||
"about": "a big model that runs on a PC with a 12 GB card and 64 GB of RAM (the two official 2-bit versions: 257 or "
|
||||
"274 GB of disk, about 5-7 words a second) or, in Unsloth's 508 GB version, on several graphics cards",
|
||||
"274 GB of disk, about 5-7 tokens a second) or, in Unsloth's 508 GB version, on several graphics cards",
|
||||
"hf": hf(DSV41_REPO) + "UD-Q8_K_XL/", "tag": "deepseek-v4.1-flash-", "name": "deepseek-v4.1-flash",
|
||||
"license": "DeepSeek's model license: https://huggingface.co/unsloth/DeepSeek-V4.1-Flash-1017-GGUF",
|
||||
"models": {"UD-Q8_K_XL": {"about": "Unsloth Dynamic 8-bit: the experts in MXFP4 as DeepSeek made them, the rest "
|
||||
@@ -720,7 +720,7 @@ MULTI_FILE_FAMILIES["deepseek41"]["models"] = {
|
||||
"IST-DAS-2.00bpw": {
|
||||
"label": "IST-DAS 2.00 bpw",
|
||||
"about": "the smaller official version: 138 GB of model + 111 GB n-gram table + 8 GB drafter (257 GB of disk). Runs on a PC with "
|
||||
"a 12 GB card and 40-64 GB of RAM at about 5-7 words a second; quality about 16% above the original model's "
|
||||
"a 12 GB card and 40-64 GB of RAM at about 5-7 tokens a second; quality about 16% above the original model's "
|
||||
"perplexity",
|
||||
"ram_gb": 40, "recipe": "ista", "subdir": "2.00bpw", "profile": "profile-2.00.bin", "hf": hf(DSV41_ISTA_REPO) + "2.00bpw/",
|
||||
"shards": [DSV41_ISTA_SHARD.format(bpw="2.00bpw", i=i) for i in range(1, 5)],
|
||||
@@ -4729,7 +4729,7 @@ def get_prebuilt(url_base, gpu, vision, updating=False, toolkit=13) -> Path | No
|
||||
if not (eng / EXE).exists():
|
||||
fail("the ready-made engine archive has no " + EXE)
|
||||
if not WIN:
|
||||
for x in (EXE, VEXE):
|
||||
for x in (EXE, VEXE, "strata-dsv41"):
|
||||
if (eng / x).exists():
|
||||
(eng / x).chmod(0o755)
|
||||
ok(f"ready-made engine {meta.get('version', '')} for {', '.join('sm_' + str(a) for a in archs)} (CUDA "
|
||||
@@ -5638,7 +5638,7 @@ def data_folder(requested: str | None, move: bool = False, yes: bool = True, set
|
||||
["keep", "move"], "keep", False) == "move"
|
||||
if held and not move:
|
||||
for folder in held:
|
||||
say(f" your model files stay in {folder}; {dest} is used for new downloads")
|
||||
say(f" Models you already have in {folder} stay there; new downloads go to {dest}.")
|
||||
if set_default:
|
||||
save_settings({**settings, "data_dir": str(dest)})
|
||||
return dest, held
|
||||
@@ -7288,6 +7288,11 @@ def dsv41_exe(eng: Path) -> Path | None:
|
||||
if own:
|
||||
return Path(own) if Path(own).is_file() else None
|
||||
exe = Path(eng) / ("strata-dsv41.exe" if WIN else "strata-dsv41")
|
||||
if exe.is_file() and not WIN and not os.access(exe, os.X_OK): # a package copied without its modes (scp / tar from Windows): fix it, do not stop
|
||||
try:
|
||||
exe.chmod(0o755)
|
||||
except OSError:
|
||||
pass
|
||||
return exe if exe.is_file() else None
|
||||
|
||||
|
||||
@@ -7338,10 +7343,12 @@ def dsv41_find_shared(a, name: str, folder: Path, shared: Path) -> Path:
|
||||
|
||||
def dsv41_pack_cmd(folder: Path, shards: list, engram: Path, meta: Path, dspark: Path, pack: Path) -> list:
|
||||
"""The pack recipe for an official IST-DAS set - this and nothing else: another one (--skip-tensors / --dense-override + engram_fold)
|
||||
packs without an error and gives garbage text (P2003, 2026-10-23). --shards names the four files, so an Engram or another
|
||||
packs without an error and gives garbage text (P2003, 2026-10-23). The drafter's noise token (128799) and markov rank (256) are stated:
|
||||
the IST-DAS GGUFs do not carry them, and a pack with noise id 0 drafts deep less often (decode ~10% slower with DSpark). --shards names the four files, so an Engram or another
|
||||
set in the same folder is not read as part of the model."""
|
||||
return [sys.executable, str(ROOT / "tools" / "dsv41" / "pack_gguf.py"), "--model", str(folder), "--shards", *[Path(n).name for n in shards],
|
||||
"--engram", str(engram), "--engram-meta", str(meta), "--dspark-gguf", str(dspark), "--out", str(pack)]
|
||||
"--engram", str(engram), "--engram-meta", str(meta), "--dspark-gguf", str(dspark),
|
||||
"--dspark-noise-token", "128799", "--dspark-markov-rank", "256", "--out", str(pack)]
|
||||
|
||||
|
||||
def dsv41_engine_env(lib_dirs) -> dict:
|
||||
@@ -7375,7 +7382,8 @@ def dsv41_smoke(exe: Path, pack: Path, lib_dirs, gpus=None, timeout: int = 5400)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
return False, f"the test run did not finish: {e}"
|
||||
text = ((r.stdout or "") + (r.stderr or "")).strip()
|
||||
return r.returncode == 0, text[-1500:]
|
||||
enc = getattr(sys.stdout, "encoding", None) or "utf-8" # the reply is printed by say()/fail(): a Windows console code page cannot show DeepSeek's special tokens
|
||||
return r.returncode == 0, text[-1500:].encode(enc, "replace").decode(enc, "replace")
|
||||
|
||||
|
||||
def dsv41_cards(gpu: dict, all_cards: list | None, asked: bool) -> list:
|
||||
@@ -7436,6 +7444,14 @@ def install_dsv41(a, family, gpu, hip, amd, cuda_tk, multi, port, ram, adopted,
|
||||
if left:
|
||||
say(f" Download: {left:.0f} GB to fetch (checked against pinned sizes and SHA-256; it resumes if it stops). At 50 MB/s that "
|
||||
f"is {left * 1e9 / 50e6 / 3600:.1f} hours. Files you already have: --gguf-dir DIR.")
|
||||
else:
|
||||
say(" Nothing to download: the files are already here.")
|
||||
unchecked = [f for f in [*shards, *shared] if f.exists() and f"sha256 {fam['sha256'][f.name][1]}" not in
|
||||
(f.with_name(f.name + ".done").read_text(encoding="utf-8", errors="replace") if done(f) else "")]
|
||||
if unchecked:
|
||||
gb = sum(fam["sha256"][f.name][0] for f in unchecked) / 1e9
|
||||
say(f" The files already here ({gb:.0f} GB) are checked against their SHA-256 once, now: about {max(1, round(gb / 55))} minutes on a fast "
|
||||
"drive (4-5 minutes for the 257 GB of 2.00), longer on a slow one. The next runs skip it.")
|
||||
for w in plan["warnings"]:
|
||||
if "free disk space" in w:
|
||||
continue # the disk is a hard limit, said below
|
||||
@@ -8259,7 +8275,7 @@ def main() -> int:
|
||||
for mname in ("IST-DAS-2.00bpw", "IST-DAS-2.25bpw", "UD-Q8_K_XL"):
|
||||
ds = dsv41_fit.plan(cards_ok, ram, None, model=mname)
|
||||
tail = {"fits": f"fits ({ds['experts_in_vram']:,} of {ds['experts_total']:,} experts in graphics memory, the rest on the CPU)",
|
||||
"tight": ("runs, with the experts RAM cannot hold read from the SSD (about 5-7 words a second measured)"
|
||||
"tight": ("runs, with the experts RAM cannot hold read from the SSD (about 5-7 tokens a second measured)"
|
||||
if ds.get("variant") else "fits, but RAM is tight"),
|
||||
"spills": (f"works but slow: less RAM than it was measured with (46-64 GB)" if ds.get("variant")
|
||||
else f"about {ds['spill_gb']:.0f} GB of its experts would be read from the SSD (slow)"),
|
||||
|
||||
@@ -227,7 +227,7 @@ def _plan_ista(model: str, cards: list[dict] | None, ram_gb: float | None, disk_
|
||||
elif float(ram_gb) >= v["ram_ok_gb"]:
|
||||
out["verdict"] = "tight"
|
||||
lines.append(f"This computer has {ram_gb:.0f} GB of RAM: Strata keeps the experts you use most in RAM (it sizes this itself) and "
|
||||
"reads the rest from the SSD. Measured: about 4.7 words a second on 64 GB of RAM and a 12 GB card, about 7 on "
|
||||
"reads the rest from the SSD. Measured: about 4.7 tokens a second on 64 GB of RAM and a 12 GB card, about 7 on "
|
||||
"46 GB with a faster drive. More RAM or a faster NVMe drive makes it quicker.")
|
||||
else:
|
||||
out["verdict"] = "spills"
|
||||
|
||||
@@ -90,6 +90,18 @@ class Manifest(unittest.TestCase):
|
||||
folder = self.package()
|
||||
self.assertNotIn("link.so", {f[0] for f in ec.read_manifest(folder).files})
|
||||
|
||||
def test_linux_programs_are_executable_in_the_tar_even_from_a_windows_packer(self):
|
||||
import feed_tool
|
||||
folder = self.package()
|
||||
elf = bytes([0x7f]) + b"ELF" + bytes(60)
|
||||
for name in ("strata-dsv41", "strata-vision"): # ELF programs without execute bits (copied over from Windows)
|
||||
(folder / name).write_bytes(elf)
|
||||
os.chmod(folder / name, 0o644)
|
||||
(folder / "libx.so").write_bytes(elf)
|
||||
if WINDOWS: # a Linux package packed on Windows: the ELF header decides
|
||||
self.assertEqual(feed_tool._mode(folder / "strata-dsv41", "strata-dsv41", "strata"), 0o755)
|
||||
self.assertEqual(feed_tool._mode(folder / "strata-vision", "strata-vision", "strata"), 0o755)
|
||||
|
||||
def test_strata_dsv41_travels_beside_the_engine_and_can_be_required(self):
|
||||
dsv = "strata-dsv41.exe" if WINDOWS else "strata-dsv41"
|
||||
with self.assertRaises(SystemExit):
|
||||
|
||||
+1
-1
@@ -1247,7 +1247,7 @@ class Strata:
|
||||
return (f"slow here: this computer has {pl['ram_gb']:.0f} GB of RAM and the set was measured with 46-64 GB, so most of its "
|
||||
"experts are read from the SSD; it can still be installed")
|
||||
if v == "tight":
|
||||
return ("tight: runs, with the experts RAM cannot hold read from the SSD (about 5-7 words a second measured on 46-64 GB "
|
||||
return ("tight: runs, with the experts RAM cannot hold read from the SSD (about 5-7 tokens a second measured on 46-64 GB "
|
||||
"of RAM and a 12 GB card)")
|
||||
if v == "fits":
|
||||
return (f"fits: {pl['experts_in_vram']:,} of {pl['experts_total']:,} experts in graphics memory, the rest cached in RAM "
|
||||
|
||||
@@ -360,7 +360,8 @@ class OfficialSets(unittest.TestCase):
|
||||
cmd = setup.dsv41_pack_cmd(folder, names, Path("/s/engram-IQ4_NL.gguf"), Path("/s/meta.gguf"), Path("/s/dspark.gguf"), pack)
|
||||
self.assertTrue(cmd[1].endswith("pack_gguf.py"))
|
||||
self.assertEqual(cmd[2:], ["--model", str(folder), "--shards", *names, "--engram", str(Path("/s/engram-IQ4_NL.gguf")),
|
||||
"--engram-meta", str(Path("/s/meta.gguf")), "--dspark-gguf", str(Path("/s/dspark.gguf")), "--out", str(pack)])
|
||||
"--engram-meta", str(Path("/s/meta.gguf")), "--dspark-gguf", str(Path("/s/dspark.gguf")),
|
||||
"--dspark-noise-token", "128799", "--dspark-markov-rank", "256", "--out", str(pack)])
|
||||
for bad in ("--skip-tensors", "--dense-override", "--override-experts", "--tq-experts"): # these give garbage text on the official sets
|
||||
self.assertNotIn(bad, cmd)
|
||||
|
||||
|
||||
Reference in new issue
Block a user