Python · 2515 bytes Raw Blame History
1 """`ollama run` wrapper — post-export smoke test.
2
3 Default prompt is `"Hello."` — just enough to verify the model loads
4 and generates one coherent reply. Empty output or non-zero exit
5 raises `OllamaSmokeError`. The runner appends the first line of
6 stdout to `manifest.exports[-1].smoke_output_first_line` so the
7 smoke is auditable from `dlm show` without re-running inference.
8
9 `--no-smoke` skips this entirely at the runner level; this module
10 exists only to be called on the happy path.
11 """
12
13 from __future__ import annotations
14
15 import subprocess # nosec B404
16 from pathlib import Path
17
18 from dlm.export.ollama.binary import locate_ollama
19 from dlm.export.ollama.errors import OllamaSmokeError
20
21 _DEFAULT_TIMEOUT_SECONDS = 120.0
22 _DEFAULT_PROMPT = "Hello."
23
24
25 def ollama_run(
26 *,
27 name: str,
28 prompt: str = _DEFAULT_PROMPT,
29 binary: Path | None = None,
30 timeout: float = _DEFAULT_TIMEOUT_SECONDS,
31 ) -> str:
32 """Invoke `ollama run <name> <prompt>`, return stdout.
33
34 Raises `OllamaSmokeError` on:
35 - Non-zero exit code.
36 - Empty stdout (the model "succeeded" but produced nothing — a
37 runaway-stop scenario this smoke test is specifically guarding against).
38 - Subprocess timeout.
39 """
40 exe = binary or locate_ollama()
41 try:
42 result = subprocess.run( # nosec B603 — caller-controlled argv
43 [str(exe), "run", name, prompt],
44 capture_output=True,
45 text=True,
46 check=False,
47 timeout=timeout,
48 )
49 except subprocess.TimeoutExpired as exc:
50 raise OllamaSmokeError(
51 stdout=(exc.stdout or b"").decode("utf-8", errors="replace"),
52 stderr=(exc.stderr or b"").decode("utf-8", errors="replace")
53 + f"\n(timed out after {timeout}s)",
54 ) from exc
55
56 if result.returncode != 0:
57 raise OllamaSmokeError(stdout=result.stdout, stderr=result.stderr)
58 if not result.stdout.strip():
59 raise OllamaSmokeError(
60 stdout=result.stdout,
61 stderr=result.stderr
62 + "\n(empty stdout — the model produced no output for the smoke prompt)",
63 )
64 return result.stdout
65
66
67 def first_line(text: str) -> str:
68 """Return the first non-empty line of `text`, truncated to 200 chars.
69
70 Used to stamp `ExportSummary.smoke_output_first_line` without
71 bloating the manifest JSON.
72 """
73 for line in text.splitlines():
74 stripped = line.strip()
75 if stripped:
76 return stripped[:200]
77 return ""