| 1 |
"""Typed errors for the Ollama integration path.""" |
| 2 |
|
| 3 |
from __future__ import annotations |
| 4 |
|
| 5 |
|
| 6 |
class OllamaError(Exception): |
| 7 |
"""Base for `dlm.export.ollama` errors.""" |
| 8 |
|
| 9 |
|
| 10 |
class OllamaBinaryNotFoundError(OllamaError): |
| 11 |
"""`ollama` not found on PATH or standard install locations. |
| 12 |
|
| 13 |
Remediation: install from https://ollama.com/download, then re-run. |
| 14 |
""" |
| 15 |
|
| 16 |
|
| 17 |
class OllamaVersionError(OllamaError): |
| 18 |
"""Installed Ollama is older than `OLLAMA_MIN_VERSION` (audit F16). |
| 19 |
|
| 20 |
Carries the detected and required versions so the CLI can render a |
| 21 |
specific upgrade message. |
| 22 |
""" |
| 23 |
|
| 24 |
def __init__( |
| 25 |
self, |
| 26 |
*, |
| 27 |
detected: tuple[int, int, int], |
| 28 |
required: tuple[int, int, int], |
| 29 |
) -> None: |
| 30 |
def _fmt(v: tuple[int, int, int]) -> str: |
| 31 |
return f"{v[0]}.{v[1]}.{v[2]}" |
| 32 |
|
| 33 |
super().__init__( |
| 34 |
f"Ollama {_fmt(detected)} is below the minimum supported version " |
| 35 |
f"{_fmt(required)}. Upgrade from https://ollama.com/download." |
| 36 |
) |
| 37 |
self.detected = detected |
| 38 |
self.required = required |
| 39 |
|
| 40 |
|
| 41 |
class OllamaCreateError(OllamaError): |
| 42 |
"""`ollama create` exited non-zero. |
| 43 |
|
| 44 |
Captures the subprocess stdout + stderr so the CLI can surface the |
| 45 |
real remediation (often "base GGUF missing" or "duplicate name"). |
| 46 |
""" |
| 47 |
|
| 48 |
def __init__(self, *, stdout: str, stderr: str) -> None: |
| 49 |
tail = stderr.strip() or stdout.strip() or "(no output)" |
| 50 |
super().__init__(f"`ollama create` failed:\n{tail}") |
| 51 |
self.stdout = stdout |
| 52 |
self.stderr = stderr |
| 53 |
|
| 54 |
|
| 55 |
class OllamaSmokeError(OllamaError): |
| 56 |
"""`ollama run` produced no coherent output or exited non-zero. |
| 57 |
|
| 58 |
Smoke failures are a hard stop for the default `dlm export` flow; |
| 59 |
users who know the model works but want to skip smoke can pass |
| 60 |
`--no-smoke`. |
| 61 |
""" |
| 62 |
|
| 63 |
def __init__(self, *, stdout: str, stderr: str) -> None: |
| 64 |
super().__init__( |
| 65 |
f"smoke test failed — `ollama run` returned empty or errored:\n{stderr.strip() or stdout.strip() or '(no output)'}" |
| 66 |
) |
| 67 |
self.stdout = stdout |
| 68 |
self.stderr = stderr |
| 69 |
|
| 70 |
|
| 71 |
class ModelfileError(OllamaError): |
| 72 |
"""Modelfile generation or validation failed. |
| 73 |
|
| 74 |
Typically means the adapter dir is missing tokenizer metadata the |
| 75 |
Modelfile needs (stops, chat template). Training should |
| 76 |
have written these; surfacing this at export is the fail-fast gate. |
| 77 |
""" |
| 78 |
|
| 79 |
|
| 80 |
class TemplateRegistryError(OllamaError): |
| 81 |
"""Requested template dialect not in the registry. |
| 82 |
|
| 83 |
Registry ships one entry per `BaseModelSpec.template` Literal value |
| 84 |
(`chatml`, `gemma2`, `smollm3`, `olmo2`, `llama3`, `phi3`, |
| 85 |
`phi4mini`, |
| 86 |
`mistral`). |
| 87 |
Unknown dialect usually means an hf:-escape-hatch base whose |
| 88 |
template inference picked a dialect we haven't templated — remedy |
| 89 |
is to add it to the registry. |
| 90 |
""" |
| 91 |
|
| 92 |
|
| 93 |
class VerificationError(OllamaError): |
| 94 |
"""Go↔Jinja closed-loop verification detected drift. |
| 95 |
|
| 96 |
Raised when Ollama's `prompt_eval_count` (Go template output) |
| 97 |
disagrees with HuggingFace's `apply_chat_template` token count for |
| 98 |
the same message set. A mismatch means the dialect's Go `.gotmpl` |
| 99 |
file is out of sync with the base model's Jinja reference; the |
| 100 |
remedy is to regenerate the golden via `scripts/refresh-chat- |
| 101 |
template-goldens.py` and, if the delta is real, fix the template. |
| 102 |
""" |
| 103 |
|
| 104 |
def __init__( |
| 105 |
self, |
| 106 |
*, |
| 107 |
ollama_name: str, |
| 108 |
hf_count: int, |
| 109 |
go_count: int, |
| 110 |
scenario: str | None = None, |
| 111 |
) -> None: |
| 112 |
where = f" on scenario {scenario!r}" if scenario else "" |
| 113 |
super().__init__( |
| 114 |
f"template drift on {ollama_name}{where}: HF Jinja produced " |
| 115 |
f"{hf_count} prompt tokens, Ollama Go template produced " |
| 116 |
f"{go_count}. Delta: {go_count - hf_count:+d}. Regenerate " |
| 117 |
"the golden via scripts/refresh-chat-template-goldens.py, " |
| 118 |
"then diff the .gotmpl if the delta is real." |
| 119 |
) |
| 120 |
self.ollama_name = ollama_name |
| 121 |
self.hf_count = hf_count |
| 122 |
self.go_count = go_count |
| 123 |
self.scenario = scenario |