Python · 9382 bytes Raw Blame History
1 """llama-server launch artifact generation."""
2
3 from __future__ import annotations
4
5 import json
6 from datetime import datetime
7 from pathlib import Path
8
9 import pytest
10
11 from dlm.base_models import BASE_MODELS
12 from dlm.export.dispatch import DispatchResult
13 from dlm.export.errors import ExportError
14 from dlm.export.manifest import ExportManifest, load_export_manifest
15 from dlm.export.targets.base import TargetResult
16 from dlm.export.targets.llama_server import (
17 LLAMA_SERVER_TARGET,
18 _find_artifact,
19 _optional_int_extra,
20 _optional_path_extra,
21 _optional_prepared_path,
22 _read_chat_template,
23 _require_path_extra,
24 _require_prepared_int,
25 _require_prepared_path,
26 _require_spec_extra,
27 _script_dir_arg,
28 prepare_llama_server_export,
29 )
30
31
32 def _vendor_tree(tmp_path: Path) -> Path:
33 vendor = tmp_path / "vendor" / "llama.cpp"
34 (vendor / "build" / "bin").mkdir(parents=True)
35 server = vendor / "build" / "bin" / "llama-server"
36 server.write_text("#!/bin/sh\n")
37 server.chmod(0o755)
38 (vendor / "convert_hf_to_gguf.py").write_text("# mock\n")
39 (vendor / "convert_lora_to_gguf.py").write_text("# mock\n")
40 return vendor
41
42
43 def _adapter_dir(tmp_path: Path) -> Path:
44 adapter_dir = tmp_path / "adapter"
45 adapter_dir.mkdir()
46 (adapter_dir / "tokenizer_config.json").write_text(
47 json.dumps({"chat_template": "{% for m in messages %}{{ m['content'] }}{% endfor %}"})
48 )
49 return adapter_dir
50
51
52 def _seed_manifest(export_dir: Path) -> None:
53 manifest = ExportManifest(
54 target="llama-server",
55 quant="Q4_K_M",
56 created_at=datetime(2026, 4, 23, 12, 0, 0),
57 created_by="dlm-test",
58 base_model_hf_id="org/base",
59 base_model_revision="a" * 40,
60 adapter_version=1,
61 artifacts=[],
62 )
63 (export_dir / "export_manifest.json").write_text(
64 manifest.model_dump_json(indent=2) + "\n",
65 encoding="utf-8",
66 )
67
68
69 class TestPrepareLlamaServerExport:
70 def test_writes_template_and_launch_script_for_unmerged_export(self, tmp_path: Path) -> None:
71 export_dir = tmp_path / "exports" / "Q4_K_M"
72 export_dir.mkdir(parents=True)
73 manifest_path = export_dir / "export_manifest.json"
74 _seed_manifest(export_dir)
75 base = export_dir / "base.Q4_K_M.gguf"
76 adapter_gguf = export_dir / "adapter.gguf"
77 base.write_bytes(b"base")
78 adapter_gguf.write_bytes(b"adapter")
79 adapter_dir = _adapter_dir(tmp_path)
80 vendor = _vendor_tree(tmp_path)
81
82 prepared = prepare_llama_server_export(
83 export_dir=export_dir,
84 manifest_path=manifest_path,
85 artifacts=[base, adapter_gguf],
86 adapter_dir=adapter_dir,
87 spec=BASE_MODELS["smollm2-135m"],
88 training_sequence_len=4096,
89 vendor_override=vendor,
90 )
91
92 assert prepared.name == "llama-server"
93 assert prepared.launch_script_path is not None
94 assert prepared.launch_script_path.exists()
95 assert prepared.config_path is not None
96 assert prepared.config_path.exists()
97
98 script = prepared.launch_script_path.read_text(encoding="utf-8")
99 assert script.startswith("#!/usr/bin/env bash\nset -euo pipefail\n")
100 assert 'SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"' in script
101 assert '--model "$SCRIPT_DIR/base.Q4_K_M.gguf"' in script
102 assert '--chat-template-file "$SCRIPT_DIR/chat-template.jinja"' in script
103 assert '--lora "$SCRIPT_DIR/adapter.gguf"' in script
104 assert "--ctx-size 4096" in script
105 assert "--host 127.0.0.1 --port 8000" in script
106 assert "llama-server" in script
107
108 manifest = load_export_manifest(export_dir)
109 assert [artifact.path for artifact in manifest.artifacts] == [
110 "chat-template.jinja",
111 "llama-server_launch.sh",
112 ]
113
114 def test_merged_export_omits_lora_flag(self, tmp_path: Path) -> None:
115 export_dir = tmp_path / "exports" / "Q4_K_M"
116 export_dir.mkdir(parents=True)
117 manifest_path = export_dir / "export_manifest.json"
118 _seed_manifest(export_dir)
119 base = export_dir / "base.Q4_K_M.gguf"
120 base.write_bytes(b"base")
121 adapter_dir = _adapter_dir(tmp_path)
122 vendor = _vendor_tree(tmp_path)
123
124 prepared = prepare_llama_server_export(
125 export_dir=export_dir,
126 manifest_path=manifest_path,
127 artifacts=[base],
128 adapter_dir=adapter_dir,
129 spec=BASE_MODELS["smollm2-135m"],
130 training_sequence_len=512,
131 vendor_override=vendor,
132 )
133
134 assert prepared.launch_script_path is not None
135 script = prepared.launch_script_path.read_text(encoding="utf-8")
136 assert "--lora " not in script
137 assert "--ctx-size 512" in script
138
139
140 class TestLlamaServerHelpers:
141 def test_read_chat_template_rejects_invalid_json(self, tmp_path: Path) -> None:
142 adapter_dir = tmp_path / "adapter"
143 adapter_dir.mkdir()
144 (adapter_dir / "tokenizer_config.json").write_text("not json {{{", encoding="utf-8")
145
146 with pytest.raises(ExportError, match="cannot load chat template"):
147 _read_chat_template(adapter_dir)
148
149 def test_read_chat_template_rejects_blank_template(self, tmp_path: Path) -> None:
150 adapter_dir = tmp_path / "adapter"
151 adapter_dir.mkdir()
152 (adapter_dir / "tokenizer_config.json").write_text(
153 json.dumps({"chat_template": " "}),
154 encoding="utf-8",
155 )
156
157 with pytest.raises(ExportError, match="has no non-empty chat_template"):
158 _read_chat_template(adapter_dir)
159
160 def test_find_artifact_missing_prefix_raises(self, tmp_path: Path) -> None:
161 with pytest.raises(ExportError, match="missing export artifact with prefix"):
162 _find_artifact([tmp_path / "adapter.gguf"], prefix="base.")
163
164 def test_script_dir_arg_requires_path(self) -> None:
165 with pytest.raises(ExportError, match="missing a required path"):
166 _script_dir_arg(None)
167
168 def test_dispatch_extra_validators_raise_on_wrong_types(self, tmp_path: Path) -> None:
169 ctx = DispatchResult(
170 export_dir=tmp_path,
171 manifest_path=tmp_path / "export_manifest.json",
172 artifacts=[],
173 banner_lines=[],
174 extras={
175 "adapter_dir": "bad",
176 "training_sequence_len": "bad",
177 "spec": "bad",
178 "vendor_override": "bad",
179 },
180 )
181
182 with pytest.raises(ExportError, match="missing Path extra 'adapter_dir'"):
183 _require_path_extra(ctx, "adapter_dir")
184 with pytest.raises(ExportError, match="must be an int"):
185 _optional_int_extra(ctx, "training_sequence_len")
186 with pytest.raises(ExportError, match="missing BaseModelSpec extra 'spec'"):
187 _require_spec_extra(ctx, "spec")
188 with pytest.raises(ExportError, match="must be a Path"):
189 _optional_path_extra(ctx, "vendor_override")
190
191 empty_ctx = DispatchResult(
192 export_dir=tmp_path,
193 manifest_path=tmp_path / "export_manifest.json",
194 artifacts=[],
195 banner_lines=[],
196 extras={},
197 )
198 assert _optional_path_extra(empty_ctx, "vendor_override") is None
199 assert _optional_int_extra(empty_ctx, "training_sequence_len") is None
200
201 def test_prepared_extra_validators_raise_on_wrong_types(self, tmp_path: Path) -> None:
202 prepared = TargetResult(
203 name="llama-server",
204 export_dir=tmp_path,
205 manifest_path=tmp_path / "export_manifest.json",
206 config_path=tmp_path / "chat-template.jinja",
207 extras={
208 "model_path": tmp_path / "base.gguf",
209 "adapter_gguf_path": "bad",
210 "context_length": 512,
211 },
212 )
213
214 assert _require_prepared_path(prepared, "model_path") == tmp_path / "base.gguf"
215 with pytest.raises(ExportError, match="must be a Path"):
216 _optional_prepared_path(prepared, "adapter_gguf_path")
217 with pytest.raises(ExportError, match="must be a Path"):
218 LLAMA_SERVER_TARGET.launch_command(prepared)
219
220 bad_int = TargetResult(
221 name="llama-server",
222 export_dir=tmp_path,
223 manifest_path=tmp_path / "export_manifest.json",
224 config_path=tmp_path / "chat-template.jinja",
225 extras={
226 "model_path": tmp_path / "base.gguf",
227 "context_length": "bad",
228 },
229 )
230 with pytest.raises(ExportError, match="missing int extra 'context_length'"):
231 _require_prepared_int(bad_int, "context_length")
232
233 def test_smoke_failure_from_runtime_command_is_reported(self, tmp_path: Path) -> None:
234 prepared = TargetResult(
235 name="llama-server",
236 export_dir=tmp_path,
237 manifest_path=tmp_path / "export_manifest.json",
238 extras={"model_path": "bad"},
239 config_path=tmp_path / "chat-template.jinja",
240 )
241
242 result = LLAMA_SERVER_TARGET.smoke_test(prepared)
243
244 assert result.attempted is True
245 assert result.ok is False
246 assert "missing Path extra 'model_path'" in result.detail