Python · 4906 bytes Raw Blame History
1 """Locate the `ollama` binary + enforce a minimum version (audit F16).
2
3 `OLLAMA_MIN_VERSION` is a typed constant in code (not a docstring)
4 so `grep OLLAMA_MIN_VERSION` finds the single source of truth. The
5 CI job tests exactly this version for smoke passing, plus one minor
6 version above, plus one below (which must raise).
7 """
8
9 from __future__ import annotations
10
11 import re
12 import shutil
13 import subprocess # nosec B404
14 from pathlib import Path
15 from typing import Final
16
17 from dlm.export.ollama.errors import (
18 OllamaBinaryNotFoundError,
19 OllamaVersionError,
20 )
21
22 # Audit F16: ollama 0.4.2 is the first release whose Modelfile grammar
23 # matches the `TEMPLATE`/`ADAPTER`/`PARAMETER` shape we emit for text
24 # models. Bumping this requires a matching CI matrix update + grep sweep.
25 OLLAMA_MIN_VERSION: Final[tuple[int, int, int]] = (0, 4, 2)
26
27 # Vision-language Modelfiles emit `{{ .Image }}` in the TEMPLATE body —
28 # ollama 0.4.0 is the first release that honors the directive. Earlier
29 # versions silently drop it, producing a model that appears to work but
30 # never sees the image bytes. VL callers enforce this floor on top of
31 # OLLAMA_MIN_VERSION before feeding a rendered VL Modelfile to
32 # `ollama create`.
33 OLLAMA_VL_MIN_VERSION: Final[tuple[int, int, int]] = (0, 4, 0)
34
35 # Common Ollama install paths on macOS + Linux. Tried after PATH lookup.
36 _STANDARD_PATHS: Final[tuple[Path, ...]] = (
37 Path("/usr/local/bin/ollama"),
38 Path("/opt/homebrew/bin/ollama"),
39 Path("/usr/bin/ollama"),
40 Path.home() / ".local" / "bin" / "ollama",
41 )
42
43 _VERSION_RE: Final[re.Pattern[str]] = re.compile(r"(\d+)\.(\d+)\.(\d+)")
44
45
46 def locate_ollama(override: Path | None = None) -> Path:
47 """Return the path to the `ollama` binary or raise.
48
49 Lookup order:
50 1. `override` (test hook; production code never passes).
51 2. `shutil.which("ollama")` — PATH.
52 3. Known install paths.
53
54 Raises `OllamaBinaryNotFoundError` with the install link when nothing
55 resolves.
56 """
57 if override is not None:
58 if override.is_file():
59 return override
60 raise OllamaBinaryNotFoundError(f"override path {override} does not exist")
61
62 found = shutil.which("ollama")
63 if found:
64 return Path(found)
65
66 for candidate in _STANDARD_PATHS:
67 if candidate.is_file():
68 return candidate
69
70 raise OllamaBinaryNotFoundError(
71 "`ollama` is not on PATH and was not found at standard install "
72 "locations. Install from https://ollama.com/download and retry."
73 )
74
75
76 def ollama_version(binary: Path | None = None) -> tuple[int, int, int]:
77 """Parse `ollama --version` into `(major, minor, patch)`.
78
79 Accepts both historical formats:
80 - `ollama version is 0.4.2`
81 - `ollama version 0.4.2`
82 - plain `0.4.2`
83
84 Raises `OllamaBinaryNotFoundError` if the binary isn't callable,
85 or `OllamaVersionError` on unparseable output.
86 """
87 path = binary or locate_ollama()
88 try:
89 result = subprocess.run( # nosec B603 — caller-controlled path
90 [str(path), "--version"],
91 capture_output=True,
92 text=True,
93 check=True,
94 timeout=10,
95 )
96 except (subprocess.CalledProcessError, FileNotFoundError, OSError) as exc:
97 raise OllamaBinaryNotFoundError(f"cannot execute {path}: {exc}") from exc
98
99 # Ollama prints version to stdout on most platforms; some builds
100 # print to stderr. Check both.
101 blob = (result.stdout or "") + "\n" + (result.stderr or "")
102 match = _VERSION_RE.search(blob)
103 if not match:
104 raise OllamaVersionError(
105 detected=(0, 0, 0),
106 required=OLLAMA_MIN_VERSION,
107 )
108 return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
109
110
111 def check_ollama_version(binary: Path | None = None) -> tuple[int, int, int]:
112 """Assert `ollama --version >= OLLAMA_MIN_VERSION` and return the parsed tuple."""
113 detected = ollama_version(binary)
114 if detected < OLLAMA_MIN_VERSION:
115 raise OllamaVersionError(
116 detected=detected,
117 required=OLLAMA_MIN_VERSION,
118 )
119 return detected
120
121
122 def check_vl_ollama_version(binary: Path | None = None) -> tuple[int, int, int]:
123 """Assert the detected ollama supports VL's `{{ .Image }}` directive.
124
125 Callers that are about to feed a VL Modelfile to `ollama create`
126 should invoke this guard first. The VL TEMPLATE body relies on
127 `{{ .Image }}` for image injection; pre-0.4 releases parse the
128 directive as literal text and silently produce a model that
129 never sees image bytes — a hazard distinct from the generic
130 `OLLAMA_MIN_VERSION` floor since it's VL-specific.
131 """
132 detected = ollama_version(binary)
133 if detected < OLLAMA_VL_MIN_VERSION:
134 raise OllamaVersionError(
135 detected=detected,
136 required=OLLAMA_VL_MIN_VERSION,
137 )
138 return detected