pal-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. pal/__init__.py +29 -0
  2. pal/__main__.py +3 -0
  3. pal/access_manager/__init__.py +16 -0
  4. pal/access_manager/components/__init__.py +14 -0
  5. pal/access_manager/components/access_control.py +41 -0
  6. pal/access_manager/components/base.py +39 -0
  7. pal/access_manager/components/intervention.py +31 -0
  8. pal/access_manager/components/privilege_registry.py +30 -0
  9. pal/access_manager/pipeline.py +68 -0
  10. pal/access_manager/utils/__init__.py +2 -0
  11. pal/agent_constraints/__init__.py +3 -0
  12. pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
  13. pal/agent_constraints/components/__init__.py +3 -0
  14. pal/agent_constraints/components/constraints.py +22 -0
  15. pal/agent_constraints/pipeline.py +22 -0
  16. pal/agent_constraints/utils/__init__.py +2 -0
  17. pal/agents/__init__.py +12 -0
  18. pal/agents/coding.py +14 -0
  19. pal/agents/docs.py +15 -0
  20. pal/chat/__init__.py +4 -0
  21. pal/chat/demo.py +231 -0
  22. pal/chat/providers.py +23 -0
  23. pal/chat/service.py +591 -0
  24. pal/chat/services/__init__.py +26 -0
  25. pal/chat/services/base.py +142 -0
  26. pal/chat/services/data.py +142 -0
  27. pal/chat/services/media.py +123 -0
  28. pal/chat/services/web.py +60 -0
  29. pal/chat/sessions.py +108 -0
  30. pal/chat/tools.py +446 -0
  31. pal/chat/uploads.py +81 -0
  32. pal/cli.py +502 -0
  33. pal/compression/__init__.py +12 -0
  34. pal/compression/components/__init__.py +64 -0
  35. pal/compression/components/adverbs.py +20 -0
  36. pal/compression/components/hedges.py +54 -0
  37. pal/compression/components/interjections.py +20 -0
  38. pal/compression/components/minifier.py +56 -0
  39. pal/compression/components/output_compressor.py +13 -0
  40. pal/compression/components/pleonasms.py +54 -0
  41. pal/compression/components/punctuations.py +36 -0
  42. pal/compression/components/stopwords.py +62 -0
  43. pal/compression/history.py +346 -0
  44. pal/compression/pipeline.py +58 -0
  45. pal/compression/utils/__init__.py +30 -0
  46. pal/compression/utils/negations.py +18 -0
  47. pal/compression/utils/nltk_setup.py +20 -0
  48. pal/compression/utils/pronouns.py +27 -0
  49. pal/compression/utils/proselint_setup.py +9 -0
  50. pal/compression/utils/spacy_setup.py +22 -0
  51. pal/config/default.yaml +163 -0
  52. pal/config.py +504 -0
  53. pal/context_manager/__init__.py +7 -0
  54. pal/context_manager/chat_context.py +117 -0
  55. pal/context_manager/components/__init__.py +5 -0
  56. pal/context_manager/components/assembler.py +75 -0
  57. pal/context_manager/components/context_cache.py +45 -0
  58. pal/context_manager/components/kv_bridge.py +16 -0
  59. pal/context_manager/components/pager.py +48 -0
  60. pal/context_manager/components/simple_context_manager.py +56 -0
  61. pal/context_manager/components/turns.py +88 -0
  62. pal/context_manager/pipeline.py +27 -0
  63. pal/context_manager/utils/__init__.py +18 -0
  64. pal/context_manager/utils/hashing.py +5 -0
  65. pal/context_manager/utils/relevance.py +59 -0
  66. pal/context_manager/utils/storage.py +37 -0
  67. pal/documents/__init__.py +4 -0
  68. pal/documents/components/__init__.py +0 -0
  69. pal/documents/components/chunker.py +79 -0
  70. pal/documents/components/index.py +155 -0
  71. pal/documents/utils/__init__.py +1 -0
  72. pal/errors.py +40 -0
  73. pal/input_converters/__init__.py +8 -0
  74. pal/input_converters/components/__init__.py +0 -0
  75. pal/input_converters/components/base.py +38 -0
  76. pal/input_converters/components/pandoc_converter.py +87 -0
  77. pal/input_converters/components/pdf_converter.py +46 -0
  78. pal/input_converters/components/pptx_converter.py +52 -0
  79. pal/input_converters/components/xlsx_converter.py +38 -0
  80. pal/input_converters/pipeline.py +72 -0
  81. pal/input_converters/utils/__init__.py +1 -0
  82. pal/input_converters/workspace.py +56 -0
  83. pal/input_savers/INPUT_RULES.md +2 -0
  84. pal/input_savers/__init__.py +5 -0
  85. pal/input_savers/components/__init__.py +0 -0
  86. pal/input_savers/components/base.py +40 -0
  87. pal/input_savers/components/methods.py +41 -0
  88. pal/input_savers/components/verifier.py +39 -0
  89. pal/input_savers/pipeline.py +154 -0
  90. pal/input_savers/utils/__init__.py +0 -0
  91. pal/input_savers/utils/guards.py +94 -0
  92. pal/knowledge_graph/__init__.py +10 -0
  93. pal/knowledge_graph/components/__init__.py +0 -0
  94. pal/knowledge_graph/components/base.py +50 -0
  95. pal/knowledge_graph/components/builder.py +54 -0
  96. pal/knowledge_graph/components/retriever.py +100 -0
  97. pal/knowledge_graph/components/workspace.py +116 -0
  98. pal/knowledge_graph/pipeline.py +215 -0
  99. pal/knowledge_graph/utils/__init__.py +1 -0
  100. pal/latency_tracker/__init__.py +3 -0
  101. pal/latency_tracker/pipeline.py +86 -0
  102. pal/llm_scheduler/__init__.py +11 -0
  103. pal/llm_scheduler/api.py +137 -0
  104. pal/llm_scheduler/code.py +28 -0
  105. pal/llm_scheduler/core.py +92 -0
  106. pal/llm_scheduler/local.py +215 -0
  107. pal/llm_scheduler/pipeline.py +42 -0
  108. pal/llm_scheduler/types.py +92 -0
  109. pal/llm_wrapper/__init__.py +34 -0
  110. pal/llm_wrapper/adapter.py +316 -0
  111. pal/llm_wrapper/components/__init__.py +23 -0
  112. pal/llm_wrapper/components/adapter_types.py +90 -0
  113. pal/llm_wrapper/components/base.py +195 -0
  114. pal/llm_wrapper/components/features.py +104 -0
  115. pal/llm_wrapper/components/litellm_wrapper.py +215 -0
  116. pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
  117. pal/llm_wrapper/modes.py +73 -0
  118. pal/llm_wrapper/pipeline.py +61 -0
  119. pal/llm_wrapper/utils/__init__.py +4 -0
  120. pal/llm_wrapper/utils/registry.py +49 -0
  121. pal/llm_wrapper/utils/token_counting.py +10 -0
  122. pal/local_llm/__init__.py +5 -0
  123. pal/local_llm/components/__init__.py +0 -0
  124. pal/local_llm/components/huggingface_local.py +217 -0
  125. pal/local_llm/components/local_llm.py +60 -0
  126. pal/logging_setup.py +18 -0
  127. pal/memory/__init__.py +7 -0
  128. pal/memory/components/__init__.py +6 -0
  129. pal/memory/components/lru_k.py +56 -0
  130. pal/memory/components/manager.py +98 -0
  131. pal/memory/components/note.py +54 -0
  132. pal/memory/components/storage_manager.py +60 -0
  133. pal/memory/pipeline.py +122 -0
  134. pal/memory/utils/__init__.py +3 -0
  135. pal/memory/utils/keyword_overlap.py +3 -0
  136. pal/memory_tracker/__init__.py +3 -0
  137. pal/memory_tracker/components/__init__.py +0 -0
  138. pal/memory_tracker/components/process.py +56 -0
  139. pal/memory_tracker/pipeline.py +44 -0
  140. pal/metrics/__init__.py +4 -0
  141. pal/metrics/components/__init__.py +4 -0
  142. pal/metrics/components/collector.py +140 -0
  143. pal/metrics/components/exporter.py +28 -0
  144. pal/metrics/pipeline.py +49 -0
  145. pal/metrics/utils/__init__.py +2 -0
  146. pal/notes_manager/__init__.py +5 -0
  147. pal/notes_manager/components/__init__.py +0 -0
  148. pal/notes_manager/components/privacy.py +14 -0
  149. pal/notes_manager/components/recall.py +31 -0
  150. pal/notes_manager/components/tidy.py +28 -0
  151. pal/notes_manager/pipeline.py +138 -0
  152. pal/orchestrator/__init__.py +26 -0
  153. pal/orchestrator/components/__init__.py +26 -0
  154. pal/orchestrator/components/agent.py +105 -0
  155. pal/orchestrator/components/cgroup_manager.py +209 -0
  156. pal/orchestrator/components/context_interrupt.py +220 -0
  157. pal/orchestrator/components/limits.py +9 -0
  158. pal/orchestrator/components/scheduler.py +69 -0
  159. pal/orchestrator/components/syscall_scheduler.py +200 -0
  160. pal/orchestrator/pipeline.py +228 -0
  161. pal/orchestrator/utils/__init__.py +4 -0
  162. pal/orchestrator/utils/device.py +164 -0
  163. pal/orchestrator/utils/protocol.py +32 -0
  164. pal/os_kernel/__init__.py +5 -0
  165. pal/os_kernel/filesystem.py +80 -0
  166. pal/os_kernel/network.py +146 -0
  167. pal/os_kernel/pipeline.py +175 -0
  168. pal/os_kernel/sysinfo.py +36 -0
  169. pal/paths.py +59 -0
  170. pal/pipeline.py +177 -0
  171. pal/py.typed +0 -0
  172. pal/redact.py +19 -0
  173. pal/sdk/__init__.py +33 -0
  174. pal/sdk/adapters.py +43 -0
  175. pal/sdk/agent.py +99 -0
  176. pal/sdk/components/__init__.py +14 -0
  177. pal/sdk/components/autogen_adapter.py +65 -0
  178. pal/sdk/components/autogen_client.py +98 -0
  179. pal/sdk/components/framework_adapter_base.py +69 -0
  180. pal/sdk/components/metagpt_adapter.py +46 -0
  181. pal/sdk/components/open_interpreter_adapter.py +47 -0
  182. pal/sdk/components/tool_catalog.py +44 -0
  183. pal/sdk/os_api.py +84 -0
  184. pal/sdk/patterns.py +129 -0
  185. pal/sdk/pipeline.py +240 -0
  186. pal/sdk/queries.py +68 -0
  187. pal/sdk/savers.py +88 -0
  188. pal/sdk/utils/__init__.py +2 -0
  189. pal/storage_manager/__init__.py +17 -0
  190. pal/storage_manager/components/__init__.py +19 -0
  191. pal/storage_manager/components/base.py +50 -0
  192. pal/storage_manager/components/local_file_backend.py +84 -0
  193. pal/storage_manager/components/storage_queue.py +91 -0
  194. pal/storage_manager/components/vector_store_backend.py +122 -0
  195. pal/storage_manager/pipeline.py +153 -0
  196. pal/storage_manager/utils/__init__.py +4 -0
  197. pal/storage_manager/utils/memory_adapter.py +60 -0
  198. pal/storage_manager/utils/sdk.py +93 -0
  199. pal/studio/__init__.py +0 -0
  200. pal/studio/builder.py +166 -0
  201. pal/studio/catalog.py +90 -0
  202. pal/studio/checks.py +129 -0
  203. pal/studio/clients.py +6 -0
  204. pal/studio/editor.py +137 -0
  205. pal/studio/files.py +6 -0
  206. pal/studio/models.py +6 -0
  207. pal/studio/plan.py +149 -0
  208. pal/studio/planner.py +205 -0
  209. pal/studio/policy.py +69 -0
  210. pal/studio/routes.py +254 -0
  211. pal/studio/runs.py +210 -0
  212. pal/task_router/__init__.py +11 -0
  213. pal/task_router/components/__init__.py +0 -0
  214. pal/task_router/components/answerer.py +37 -0
  215. pal/task_router/components/base.py +34 -0
  216. pal/task_router/components/classifier.py +40 -0
  217. pal/task_router/components/gates.py +23 -0
  218. pal/task_router/pipeline.py +61 -0
  219. pal/task_router/utils/__init__.py +1 -0
  220. pal/terminal/__init__.py +1 -0
  221. pal/terminal/backend.py +141 -0
  222. pal/terminal/bench.py +173 -0
  223. pal/terminal/cli.py +826 -0
  224. pal/terminal/clients.py +170 -0
  225. pal/terminal/commands.py +401 -0
  226. pal/terminal/decide.py +242 -0
  227. pal/terminal/files.py +55 -0
  228. pal/terminal/flow_cli.py +439 -0
  229. pal/terminal/hardware.py +199 -0
  230. pal/terminal/history.py +88 -0
  231. pal/terminal/keys.py +83 -0
  232. pal/terminal/local_models.py +261 -0
  233. pal/terminal/logo.py +117 -0
  234. pal/terminal/models.py +99 -0
  235. pal/terminal/policy.py +54 -0
  236. pal/terminal/project.py +413 -0
  237. pal/terminal/review.py +65 -0
  238. pal/terminal/security.py +239 -0
  239. pal/terminal/session.py +503 -0
  240. pal/terminal/setup.py +354 -0
  241. pal/terminal/syntax.py +98 -0
  242. pal/terminal/theme.py +37 -0
  243. pal/terminal/ui.py +647 -0
  244. pal/terminal/voice.py +137 -0
  245. pal/terminal/workflow.py +624 -0
  246. pal/token_tracker/__init__.py +3 -0
  247. pal/token_tracker/pipeline.py +54 -0
  248. pal/tokens.py +21 -0
  249. pal/tool_catalog.py +107 -0
  250. pal/tool_manager/__init__.py +17 -0
  251. pal/tool_manager/builtin.py +82 -0
  252. pal/tool_manager/components/__init__.py +19 -0
  253. pal/tool_manager/components/base.py +53 -0
  254. pal/tool_manager/components/conflict_resolver.py +27 -0
  255. pal/tool_manager/components/param_validator.py +41 -0
  256. pal/tool_manager/components/tool_call_queue.py +111 -0
  257. pal/tool_manager/components/tool_registry.py +60 -0
  258. pal/tool_manager/pipeline.py +80 -0
  259. pal/tool_manager/utils/__init__.py +3 -0
  260. pal/web/__init__.py +3 -0
  261. pal/web/app.py +276 -0
  262. pal/web/static/editor.js +219 -0
  263. pal/web/static/fonts/LICENSE-Inter.txt +3 -0
  264. pal/web/static/fonts/inter-400.woff2 +0 -0
  265. pal/web/static/fonts/inter-500.woff2 +0 -0
  266. pal/web/static/fonts/inter-600.woff2 +0 -0
  267. pal/web/static/fonts/inter-700.woff2 +0 -0
  268. pal/web/static/inter.css +5 -0
  269. pal/web/static/pal-logo-256.png +0 -0
  270. pal/web/static/pal-logo-32.png +0 -0
  271. pal/web/static/pal-logo-64.png +0 -0
  272. pal/web/static/studio.css +306 -0
  273. pal/web/static/studio.js +533 -0
  274. pal/web/static/voice.js +76 -0
  275. pal/web/templates/index.html +368 -0
  276. pal/web/templates/studio.html +120 -0
  277. pal_code-0.1.0.dist-info/METADATA +379 -0
  278. pal_code-0.1.0.dist-info/RECORD +282 -0
  279. pal_code-0.1.0.dist-info/WHEEL +5 -0
  280. pal_code-0.1.0.dist-info/entry_points.txt +2 -0
  281. pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
  282. pal_code-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,88 @@
1
+ """What you typed before, per folder: ↑ and ↓ in the input box walk through it, Ctrl+R searches it.
2
+
3
+ Kept in ~/.pal/projects/<folder id>/history (newest last, at most MAX entries). A line that sets a key (/key ...) is never kept, and
4
+ key-shaped strings in any line are hidden first, so the file never holds a secret.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ from pal.paths import pal_home
12
+ from pal.terminal.security import redact_secrets
13
+ from pal.terminal.workflow import project_id
14
+
15
+ MAX = 500
16
+
17
+
18
+ class History:
19
+ def __init__(self, folder: Path, home: Path | None = None):
20
+ self.path = (home or pal_home()) / "projects" / project_id(Path(folder)) / "history"
21
+ self.items: list[str] = self._load()
22
+
23
+ def _load(self) -> list[str]:
24
+ try:
25
+ lines = self.path.read_text(encoding="utf-8").splitlines()
26
+ except OSError:
27
+ return []
28
+ out = []
29
+ for line in lines:
30
+ try:
31
+ item = json.loads(line)
32
+ except ValueError:
33
+ continue
34
+ if isinstance(item, str) and item.strip():
35
+ out.append(item)
36
+ return out[-MAX:]
37
+
38
+ def add(self, text: str) -> None:
39
+ text = text.strip()
40
+ if not text or text.split(" ", 1)[0] in ("/key",):
41
+ return
42
+ text, _ = redact_secrets(text)
43
+ if self.items and self.items[-1] == text:
44
+ return
45
+ self.items.append(text)
46
+ self.items = self.items[-MAX:]
47
+ try:
48
+ self.path.parent.mkdir(parents=True, exist_ok=True)
49
+ self.path.write_text("".join(json.dumps(x) + "\n" for x in self.items), encoding="utf-8")
50
+ except OSError:
51
+ pass
52
+
53
+ def search(self, query: str, before: int) -> int | None:
54
+ """The index of the newest entry older than `before` that contains `query` (case-insensitive), or None."""
55
+ q = query.lower()
56
+ for i in range(min(before, len(self.items)) - 1, -1, -1):
57
+ if q in self.items[i].lower():
58
+ return i
59
+ return None
60
+
61
+
62
+ class Browser:
63
+ """↑/↓ through a History while typing, keeping the unsent draft to come back to."""
64
+
65
+ def __init__(self, history: History):
66
+ self.h, self.pos, self.draft = history, len(history.items), ""
67
+
68
+ def older(self, current: str) -> str | None:
69
+ if self.pos == len(self.h.items):
70
+ self.draft = current
71
+ if self.pos == 0:
72
+ return None
73
+ self.pos -= 1
74
+ return self.h.items[self.pos]
75
+
76
+ def newer(self) -> str | None:
77
+ if self.pos >= len(self.h.items):
78
+ return None
79
+ self.pos += 1
80
+ return self.draft if self.pos == len(self.h.items) else self.h.items[self.pos]
81
+
82
+ def find(self, query: str) -> str | None:
83
+ """Ctrl+R: the next older entry containing the query."""
84
+ i = self.h.search(query, self.pos)
85
+ if i is None:
86
+ return None
87
+ self.pos = i
88
+ return self.h.items[i]
pal/terminal/keys.py ADDED
@@ -0,0 +1,83 @@
1
+ """Esc stops Pal while it works (a model reply, a check running), like Ctrl+C, and the box comes back.
2
+
3
+ While a spinner runs, a small thread watches the keyboard. A lone Esc (not the start of an arrow key's escape sequence) interrupts the main
4
+ thread, which the request code already handles as "Stopped.". Ctrl+C keeps working as before. Only in a real terminal.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import _thread
9
+ import os
10
+ import sys
11
+ import threading
12
+ import time
13
+
14
+
15
+ class EscToStop:
16
+ def __init__(self, enabled: bool | None = None):
17
+ self.enabled = (sys.stdin.isatty() and sys.stdout.isatty()) if enabled is None else enabled
18
+ self._stop = threading.Event()
19
+ self._thread: threading.Thread | None = None
20
+ self._saved: list | None = None
21
+ self.pressed = False
22
+
23
+ def __enter__(self) -> EscToStop:
24
+ if not self.enabled:
25
+ return self
26
+ if os.name != "nt":
27
+ try:
28
+ import termios
29
+ import tty
30
+ fd = sys.stdin.fileno()
31
+ self._saved = termios.tcgetattr(fd)
32
+ tty.setcbreak(fd) # keys arrive one by one; Ctrl+C still sends an interrupt
33
+ except Exception: # noqa: BLE001 (not a real terminal after all)
34
+ self.enabled = False
35
+ return self
36
+ self._thread = threading.Thread(target=self._watch, daemon=True, name="pal-esc")
37
+ self._thread.start()
38
+ return self
39
+
40
+ def __exit__(self, *exc) -> None:
41
+ self._stop.set()
42
+ if self._thread is not None:
43
+ self._thread.join(0.5)
44
+ if self._saved is not None:
45
+ try:
46
+ import termios
47
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._saved)
48
+ except Exception: # noqa: BLE001
49
+ pass
50
+
51
+ def _fire(self) -> None:
52
+ self.pressed = True
53
+ self._stop.set()
54
+ _thread.interrupt_main()
55
+
56
+ def _watch(self) -> None:
57
+ if os.name == "nt":
58
+ import msvcrt
59
+ while not self._stop.is_set():
60
+ if msvcrt.kbhit(): # type: ignore[attr-defined]
61
+ ch = msvcrt.getwch() # type: ignore[attr-defined]
62
+ if ch == "\x1b":
63
+ self._fire()
64
+ return
65
+ if ch in ("\x00", "\xe0") and msvcrt.kbhit(): # type: ignore[attr-defined]
66
+ msvcrt.getwch() # type: ignore[attr-defined] # the second half of an arrow or function key
67
+ time.sleep(0.05)
68
+ return
69
+ import select
70
+ fd = sys.stdin.fileno()
71
+ while not self._stop.is_set():
72
+ ready, _, _ = select.select([fd], [], [], 0.1)
73
+ if not ready:
74
+ continue
75
+ ch = os.read(fd, 1)
76
+ if ch != b"\x1b":
77
+ continue
78
+ more, _, _ = select.select([fd], [], [], 0.05) # an arrow key sends ESC [ A at once; a lone Esc sends nothing after
79
+ if more:
80
+ os.read(fd, 8)
81
+ continue
82
+ self._fire()
83
+ return
@@ -0,0 +1,261 @@
1
+ """Which local model to run on which computer.
2
+
3
+ The catalogue lists well-known models from the Ollama library with their 4-bit download size (Ollama's default `q4_K_M` builds; sizes are
4
+ approximate). From the size Pal works out the video memory each needs, so it can group them by GPU, and pick for this computer:
5
+
6
+ - the **main model**, which writes and plans when you choose a local model: the strongest one that fits next to the helper;
7
+ - the **helper**, the small model Pal runs for its own jobs on every request (routing, trimming what you type, checking answers): a fast one.
8
+
9
+ Models that are already installed but not in the catalogue are judged the same way, from their size. Reasoning models (deepseek-r1, qwen3) print
10
+ their thinking before the answer, so they are listed but not picked automatically.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ import re
16
+ from dataclasses import dataclass
17
+
18
+ from pal.terminal.hardware import Hardware
19
+
20
+ CONTEXT_GB = 1.0 # room for the conversation (an 8k-token context) on top of the weights
21
+ OVERHEAD = 1.1 # runtime buffers on top of the file size
22
+ USABLE = 0.92 # the share of VRAM a model can really use (the desktop and drivers keep some)
23
+ TIERS = [0, 4, 6, 8, 12, 16, 24, 48, 80] # GB of VRAM (0 = no GPU, runs on the CPU)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class LocalModel:
28
+ tag: str
29
+ params_b: float
30
+ size_gb: float
31
+ use: str # "code" | "general" | "reasoning" | "small"
32
+ rank: int # how well it builds and plans in Pal (higher is better)
33
+ helper: bool = False # fast and follows short instructions well: a good helper
34
+ thinks: bool = False # prints its reasoning first: not picked automatically
35
+ note: str = ""
36
+
37
+ @property
38
+ def need_gb(self) -> float:
39
+ """Memory to run it with a working context."""
40
+ return round(self.size_gb * OVERHEAD + CONTEXT_GB, 1)
41
+
42
+ @property
43
+ def min_vram_gb(self) -> int:
44
+ """The smallest GPU tier it fits on fully."""
45
+ need = self.need_gb / USABLE
46
+ return next((t for t in TIERS[1:] if t >= need), math.ceil(need))
47
+
48
+
49
+ CATALOGUE: list[LocalModel] = [
50
+ # tiny: helpers, and machines without a GPU
51
+ LocalModel("qwen2.5:0.5b", 0.5, 0.4, "small", 22, helper=True, note="tiny and fast; a helper for small machines"),
52
+ LocalModel("qwen3:0.6b", 0.6, 0.5, "small", 25, thinks=True),
53
+ LocalModel("gemma3:1b", 1.0, 0.8, "small", 34),
54
+ LocalModel("llama3.2:1b", 1.2, 1.3, "small", 35, helper=True),
55
+ LocalModel("qwen2.5:1.5b", 1.5, 1.0, "small", 42, helper=True, note="the default helper: fast, follows instructions"),
56
+ LocalModel("qwen2.5-coder:1.5b", 1.5, 1.0, "code", 46, note="small coder for CPU-only machines"),
57
+ LocalModel("qwen3:1.7b", 1.7, 1.4, "small", 45, thinks=True),
58
+ LocalModel("llama3.2:3b", 3.2, 2.0, "small", 52, helper=True, note="the best helper when there is room"),
59
+ LocalModel("qwen2.5:3b", 3.1, 1.9, "small", 53, helper=True),
60
+ LocalModel("qwen2.5-coder:3b", 3.1, 1.9, "code", 56, note="coder for 4 GB GPUs and CPUs"),
61
+ LocalModel("phi4-mini", 3.8, 2.5, "general", 57),
62
+ LocalModel("qwen3:4b", 4.0, 2.6, "general", 60, thinks=True),
63
+ LocalModel("gemma3:4b", 4.3, 3.3, "general", 58),
64
+ # 7-9B: 6-8 GB GPUs
65
+ LocalModel("mistral:7b", 7.2, 4.1, "general", 62),
66
+ LocalModel("qwen2.5:7b", 7.6, 4.7, "general", 71),
67
+ LocalModel("qwen2.5-coder:7b", 7.6, 4.7, "code", 75, note="best coder for 8 GB GPUs"),
68
+ LocalModel("llama3.1:8b", 8.0, 4.9, "general", 70),
69
+ LocalModel("qwen3:8b", 8.2, 5.2, "general", 73, thinks=True),
70
+ LocalModel("deepseek-r1:8b", 8.0, 5.2, "reasoning", 69, thinks=True),
71
+ LocalModel("gemma2:9b", 9.2, 5.4, "general", 68),
72
+ # 12-16B: 12-16 GB GPUs
73
+ LocalModel("mistral-nemo:12b", 12.2, 7.1, "general", 74),
74
+ LocalModel("gemma3:12b", 12.0, 8.1, "general", 77),
75
+ LocalModel("deepseek-coder-v2:16b", 16.0, 8.9, "code", 78, note="fast for its size (mixture of experts)"),
76
+ LocalModel("qwen2.5:14b", 14.7, 9.0, "general", 80),
77
+ LocalModel("qwen2.5-coder:14b", 14.7, 9.0, "code", 83, note="best coder for 12-16 GB GPUs"),
78
+ LocalModel("deepseek-r1:14b", 14.8, 9.0, "reasoning", 79, thinks=True),
79
+ LocalModel("phi4:14b", 14.7, 9.1, "general", 81),
80
+ LocalModel("qwen3:14b", 14.8, 9.3, "general", 82, thinks=True),
81
+ # 22-32B: 24 GB GPUs
82
+ LocalModel("codestral:22b", 22.2, 13.0, "code", 85),
83
+ LocalModel("mistral-small:24b", 23.6, 14.0, "general", 84),
84
+ LocalModel("gemma3:27b", 27.4, 17.0, "general", 86),
85
+ LocalModel("qwen3:30b", 30.5, 19.0, "general", 87, thinks=True, note="mixture of experts: fast for its size"),
86
+ LocalModel("qwen2.5:32b", 32.8, 20.0, "general", 89),
87
+ LocalModel("qwen2.5-coder:32b", 32.8, 20.0, "code", 95, note="best local coder for 24 GB GPUs"),
88
+ LocalModel("qwen3:32b", 32.8, 20.0, "general", 91, thinks=True),
89
+ LocalModel("deepseek-r1:32b", 32.8, 20.0, "reasoning", 88, thinks=True),
90
+ # 70B+: 48 GB and more
91
+ LocalModel("llama3.3:70b", 70.6, 43.0, "general", 92),
92
+ LocalModel("deepseek-r1:70b", 70.6, 43.0, "reasoning", 90, thinks=True),
93
+ LocalModel("qwen2.5:72b", 72.7, 47.0, "general", 94),
94
+ # newer models (sizes are Ollama's default downloads, October 2026)
95
+ LocalModel("qwen3.5:0.8b", 0.8, 1.3, "small", 30, thinks=True),
96
+ LocalModel("qwen3.5:2b", 2.0, 2.7, "small", 50, thinks=True),
97
+ LocalModel("qwen3.5:4b", 4.0, 3.4, "general", 64, thinks=True),
98
+ LocalModel("gemma4:e2b", 2.0, 4.6, "general", 55, thinks=True, note="reads images too"),
99
+ LocalModel("gemma4:e4b", 4.0, 6.6, "general", 66, thinks=True, note="reads images too"),
100
+ LocalModel("qwen3.5:9b", 9.0, 6.6, "general", 79, thinks=True, note="reads images too"),
101
+ LocalModel("gemma4:12b", 12.0, 8.0, "general", 82, thinks=True, note="reads images too"),
102
+ LocalModel("gpt-oss:20b", 21.0, 14.0, "reasoning", 90, thinks=True, note="OpenAI's open model; reasons before answering"),
103
+ LocalModel("devstral-small-2:24b", 24.0, 15.0, "code", 93, note="agentic coding: multi-file edits, explores a codebase"),
104
+ LocalModel("qwen3.5:27b", 27.0, 17.0, "general", 92, thinks=True),
105
+ LocalModel("qwen3.6:27b", 27.0, 18.0, "code", 96, thinks=True, note="strong agentic coder; thinks first"),
106
+ LocalModel("gemma4:26b", 26.0, 18.0, "general", 89, thinks=True, note="mixture of experts: fast for its size"),
107
+ LocalModel("qwen3-coder:30b", 30.5, 19.0, "code", 97, note="best local coder for 24 GB GPUs: mixture of experts, fast, 256K context"),
108
+ LocalModel("gemma4:31b", 31.0, 20.0, "general", 91, thinks=True),
109
+ LocalModel("qwen3.5:35b", 35.0, 22.0, "general", 93, thinks=True, note="mixture of experts: fast for its size"),
110
+ LocalModel("qwen3.6:35b", 35.0, 24.0, "code", 97, thinks=True, note="mixture of experts; strong agentic coder; thinks first"),
111
+ ]
112
+ BY_TAG = {m.tag: m for m in CATALOGUE}
113
+
114
+
115
+ def budget_gb(hw: Hardware) -> float:
116
+ """Memory models may use: on the GPU (or Apple's shared memory) when there is one, else half the RAM for running on the CPU."""
117
+ return round(hw.vram_gb * (1.0 if hw.apple_silicon else USABLE), 1) if hw.vram_gb else round(hw.ram_gb * 0.5, 1)
118
+
119
+
120
+ def fit(need_gb: float, hw: Hardware) -> str:
121
+ """'gpu' (fits on the GPU), 'partial' (part on the GPU, part on the CPU: works, slower), 'no gpu' (Pal does not run local models without one)
122
+ or 'too big'."""
123
+ if hw.vram_gb:
124
+ room = budget_gb(hw)
125
+ if need_gb <= room:
126
+ return "gpu"
127
+ if not hw.apple_silicon and need_gb <= room + hw.ram_gb * 0.5:
128
+ return "partial"
129
+ return "too big"
130
+ return "no gpu"
131
+
132
+
133
+ FIT_LABEL = {"gpu": "fits your GPU", "partial": "partly on the CPU (slower)", "no gpu": "needs a GPU", "too big": "too big for this computer"}
134
+
135
+
136
+ def tier_name(hw: Hardware) -> str:
137
+ if not hw.vram_gb:
138
+ return "no GPU (API models only)"
139
+ if hw.apple_silicon:
140
+ return f"{hw.ram_gb:g} GB unified memory"
141
+ vram = hw.vram_gb
142
+ t = max((x for x in TIERS[1:] if x <= vram + 0.5), default=4)
143
+ return f"{t} GB {'unified memory' if hw.apple_silicon else 'GPU'}"
144
+
145
+
146
+ @dataclass
147
+ class Recommendation:
148
+ main: LocalModel
149
+ helper: LocalModel
150
+ others: list[LocalModel] # other good choices that fit, best first
151
+ reason: str
152
+
153
+
154
+ def pick_helper(hw: Hardware) -> LocalModel:
155
+ room = budget_gb(hw)
156
+ if hw.vram_gb and room >= 14:
157
+ return BY_TAG["llama3.2:3b"]
158
+ if (hw.vram_gb and room >= 4.5) or (not hw.vram_gb and hw.ram_gb >= 12):
159
+ return BY_TAG["qwen2.5:1.5b"]
160
+ return BY_TAG["qwen2.5:0.5b"]
161
+
162
+
163
+ def api_only(hw: Hardware) -> bool:
164
+ """No GPU (and not Apple Silicon): Pal offers no local model on this computer and uses API models only."""
165
+ return not hw.vram_gb
166
+
167
+
168
+ CODE_BONUS = 8 # Pal writes software: a coding model beats a general one of a similar rank (qwen2.5-coder:3b over mistral:7b)
169
+
170
+
171
+ def score(m: LocalModel) -> int:
172
+ return m.rank + (CODE_BONUS if m.use == "code" else 0)
173
+
174
+
175
+ def recommend(hw: Hardware) -> Recommendation | None:
176
+ """The best main model that fits the GPU (coding models first: Pal builds software), and the helper. The helper goes on the GPU beside the
177
+ main model when there is room, else on the CPU, where a small model is still quick. On a CPU only, models are kept small enough to answer
178
+ in reasonable time."""
179
+ if api_only(hw):
180
+ return None
181
+ helper = pick_helper(hw)
182
+ room = budget_gb(hw)
183
+ usable = [m for m in CATALOGUE if not m.thinks and m.use != "small" and m.need_gb <= room]
184
+ if not usable:
185
+ usable = [m for m in CATALOGUE if not m.thinks and m.need_gb <= max(room, 1.6)] or [BY_TAG["qwen2.5-coder:1.5b"]]
186
+ ranked = sorted(usable, key=score, reverse=True)
187
+ main = ranked[0]
188
+ where = "on your GPU" if hw.vram_gb else "on your CPU"
189
+ if main.need_gb + helper.need_gb - CONTEXT_GB <= room or not hw.vram_gb:
190
+ beside = "beside it"
191
+ else:
192
+ beside = "when needed (Ollama swaps the two in shared memory)" if hw.apple_silicon else "on the CPU, where it is still quick"
193
+ reason = f"the strongest model that runs {where} ({main.need_gb:g} GB of about {room:g} GB); the helper runs {beside}"
194
+ return Recommendation(main, helper, ranked[1:6], reason)
195
+
196
+
197
+ def by_tier() -> list[tuple[int, list[LocalModel]]]:
198
+ """The catalogue grouped by the smallest GPU each model fits on fully."""
199
+ groups: dict[int, list[LocalModel]] = {}
200
+ for m in CATALOGUE:
201
+ groups.setdefault(m.min_vram_gb, []).append(m)
202
+ return [(t, sorted(ms, key=lambda m: -m.rank)) for t, ms in sorted(groups.items())]
203
+
204
+
205
+ _BASE = re.compile(r"^([^:]+):(\d+(?:\.\d+)?[bm])\b", re.I)
206
+
207
+
208
+ def lookup(name: str) -> LocalModel | None:
209
+ """The catalogue entry for an installed model, also for longer tags of the same model (llama3.1:8b-instruct-q4_K_M is llama3.1:8b)."""
210
+ if name in BY_TAG or name.removesuffix(":latest") in BY_TAG:
211
+ return BY_TAG.get(name) or BY_TAG[name.removesuffix(":latest")]
212
+ m = _BASE.match(name)
213
+ return BY_TAG.get(f"{m.group(1)}:{m.group(2).lower()}") if m else None
214
+
215
+
216
+ def guess_rank(params_b: float) -> int:
217
+ """A rank for a model the catalogue does not know, from its size: bigger is usually better, with diminishing returns."""
218
+ return int(min(90, 30 + 7 * math.log2(params_b))) if params_b >= 1 else 20
219
+
220
+
221
+ def installed_fit(models: list[dict], hw: Hardware) -> list[dict]:
222
+ """Ollama's installed models with how each fits: from the catalogue when known, else from the file size Ollama reports."""
223
+ out = []
224
+ for m in models:
225
+ if m.get("embedding"):
226
+ continue
227
+ known = lookup(m["name"])
228
+ need = round((m.get("size_gb") or 0) * OVERHEAD + CONTEXT_GB, 1) if m.get("size_gb") else (known.need_gb if known else CONTEXT_GB)
229
+ out.append({**m, "need_gb": need, "fit": fit(need, hw), "rank": known.rank if known else guess_rank(m.get("params_b") or 0)})
230
+ return out
231
+
232
+
233
+ def best_installed(models: list[dict], hw: Hardware, exclude: str = "") -> str | None:
234
+ """The strongest installed chat model that fits the GPU (or CPU); if none fits, the smallest one."""
235
+ rows = [r for r in installed_fit(models, hw) if r["name"] != exclude]
236
+ if not rows:
237
+ return None
238
+ if api_only(hw):
239
+ return None
240
+ good = [r for r in rows if r["fit"] == "gpu"]
241
+ return (max(good, key=lambda r: (r["rank"], r["need_gb"])) if good else min(rows, key=lambda r: r["need_gb"]))["name"]
242
+
243
+
244
+ def suitable(hw: Hardware, installed: list[str] | None = None, limit: int = 6) -> list[tuple[str, str, bool]]:
245
+ """Main-model choices for this GPU, best first: (tag, one line about it, installed?). The recommended model leads, then installed models that
246
+ fit, then the next best downloads. Reasoning models are left out (they can still be chosen by name)."""
247
+ rec = recommend(hw)
248
+ if rec is None:
249
+ return []
250
+ have = set()
251
+ for h in installed or []:
252
+ known = lookup(h)
253
+ have.add(known.tag if known else h)
254
+ picks = [rec.main] + [m for m in rec.others if m.tag != rec.main.tag]
255
+ for name in installed or []:
256
+ known = lookup(name)
257
+ if known and known not in picks and not known.thinks and known.use != "small" and fit(known.need_gb, hw) == "gpu":
258
+ picks.append(known)
259
+ picks.sort(key=lambda m: (m.tag != rec.main.tag, m.tag not in have, -score(m)))
260
+ return [(m.tag, (f"installed · {m.note or m.use}" if m.tag in have else f"{m.size_gb:g} GB download · {m.note or m.use}"), m.tag in have)
261
+ for m in picks[:limit]]
pal/terminal/logo.py ADDED
@@ -0,0 +1,117 @@
1
+ """The Pal logo (two people hugging, white and red with a black outline) as pixels for the terminal.
2
+
3
+ Drawn from the logo image at 24 x 26 pixels with half blocks, two pixel rows per text line, so it shows in any terminal with colour and needs no
4
+ image support. W white, R red, K the black outline, . see-through (the terminal's own background).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from pal.terminal import theme
9
+
10
+ GRID = [
11
+ '......KKKK.....K........',
12
+ '.....KWWWWWK.KRRRK......',
13
+ '....KWWWWWWWKRRRRRK.....',
14
+ '....KWWWWWWWKRRRRRRK....',
15
+ '....KWWWWWWWWKRRRRRR....',
16
+ '....KWWWWWWWWKRRRRRR....',
17
+ '....KWWWWWWWWKRRRRRR....',
18
+ '....KWWWWWWWWWRRRRRR....',
19
+ '....KWWWWWWWWKRRRRRK....',
20
+ '...KRKWWWWWWWKRRRRKKK...',
21
+ '..KRRKKWWWWWWKRRRKWWK...',
22
+ '..KRRRKKWWWWKRRRRKWWK...',
23
+ '...KRRRRKKKKRRRRRKKKK...',
24
+ '..KKKRRRRRRRRRRRRRRRRK..',
25
+ '..WWWKRRRRRRRRKKWKRRRR..',
26
+ '.KWWWWKRRRRRRKWWWKRRRRK.',
27
+ '.KWWWWWKKRRRKWWWWKRRRRR.',
28
+ 'KWWWWWWWWKKKWWWWKRRRRRRK',
29
+ 'KWWWWWWWWWWWWWWWKRRRRRRK',
30
+ 'KWWWWWWWWWWWWWWKRRRRRRRK',
31
+ 'KWWWWWWWWWWWWWKRRRRRRRRK',
32
+ 'KWWWWWWWWWWWWKRRRRRRRRRK',
33
+ 'KWWWWWWWWWKKRRRRRRRRRRRK',
34
+ 'KWWWWWWWWKRRRRRRRRRRRRRK',
35
+ 'KWWWWWWWWKRRRRRRRRRRRRRK',
36
+ 'KKKKKKKKKKKKKKKKKKKKKKKK',
37
+ ]
38
+ COLOURS = {"W": theme.WHITE, "R": theme.RED, "K": "#000000"}
39
+
40
+
41
+ def logo_lines(grid: list[str] | None = None) -> list[list[tuple[str, str]]]:
42
+ """Lines of (characters, style). The style is 'fg bg' in hex, or '' for nothing drawn: rich and prompt_toolkit both read it after a small change."""
43
+ g = grid or GRID
44
+ out: list[list[tuple[str, str]]] = []
45
+ for y in range(0, len(g), 2):
46
+ top, bottom = g[y], g[y + 1] if y + 1 < len(g) else "." * len(g[y])
47
+ line: list[tuple[str, str]] = []
48
+ for t, b in zip(top, bottom):
49
+ ct, cb = COLOURS.get(t), COLOURS.get(b)
50
+ if ct is None and cb is None:
51
+ line.append((" ", ""))
52
+ elif ct is None:
53
+ line.append(("▄", f"{cb}|"))
54
+ elif cb is None or ct == cb:
55
+ line.append(("▀" if cb is None else "█", f"{ct}|"))
56
+ else:
57
+ line.append(("▀", f"{ct}|{cb}"))
58
+ out.append(line)
59
+ return out
60
+
61
+
62
+ def rich_style(style: str) -> str:
63
+ fg, _, bg = style.partition("|")
64
+ return (fg + (f" on {bg}" if bg else "")) if fg else ""
65
+
66
+
67
+ def ptk_style(style: str) -> str:
68
+ fg, _, bg = style.partition("|")
69
+ return (f"fg:{fg}" + (f" bg:{bg}" if bg else "")) if fg else ""
70
+
71
+
72
+ # The same logo as braille dots (2 x 4 dots per character): the dot-matrix look of the start screen. R red, W white, space: nothing.
73
+ BRAILLE = [
74
+ ' ⢀⣤⣶⣶⣦⣄ ⢀⣠⣤⣄⡀ ',
75
+ ' ⢀⣾⣿⣿⣿⣿⣿⣷⡄⢿⣿⣿⣿⣿⣷⡀ ',
76
+ ' ⢸⣿⣿⣿⣿⣿⣿⣿⣿⡌⣿⣿⣿⣿⣿⣧ ',
77
+ ' ⠸⣿⣿⣿⣿⣿⣿⣿⣿⡇⢹⣿⣿⣿⣿⡿ ',
78
+ ' ⢀⣤⠹⣿⣿⣿⣿⣿⣿⣿⡇⣾⣿⣿⣿⠿⠃ ',
79
+ ' ⣿⣿⣷⣙⠿⣿⣿⣿⣿⠟⣴⣿⣿⣿⢡⣾⣿⡇ ',
80
+ ' ⢀⣘⠻⣿⣿⣷⣦⣭⣥⣴⣿⣿⣿⡿⠿⣦⣭⣥⣄ ',
81
+ ' ⢀⣾⣿⣷⣮⡙⢿⣿⣿⣿⣿⣿⠟⣩⣶⣶⠘⣿⣿⣿⣷⡀ ',
82
+ ' ⣼⣿⣿⣿⣿⣿⣷⣌⡻⠿⢛⣡⣾⣿⣿⠟⣼⣿⣿⣿⣿⣷ ',
83
+ '⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢋⣼⣿⣿⣿⣿⣿⣿⡆',
84
+ '⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢋⣴⣿⣿⣿⣿⣿⣿⣿⣿⡇',
85
+ '⢸⣿⣿⣿⣿⣿⣿⣿⣿⡟⣭⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇',
86
+ '⠸⠿⠿⠿⠿⠿⠿⠿⠿⠇⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠇',
87
+ ]
88
+ BRAILLE_COLOURS = [
89
+ ' WWWWWW RRRRR ',
90
+ ' WWWWWWWWWRRRRRRR ',
91
+ ' WWWWWWWWWWRRRRRR ',
92
+ ' WWWWWWWWWWRRRRRR ',
93
+ ' RRWWWWWWWWWRRRRRR ',
94
+ ' RRRWWWWWWWRRRRWWWW ',
95
+ ' WWRRRRRRRRRRRRRRRRR ',
96
+ ' WWWWWRRRRRRRRWWWRRRRRR ',
97
+ ' WWWWWWWWRRRWWWWWRRRRRR ',
98
+ 'WWWWWWWWWWWWWWWWRRRRRRRR',
99
+ 'WWWWWWWWWWWWWWRRRRRRRRRR',
100
+ 'WWWWWWWWWWRRRRRRRRRRRRRR',
101
+ 'WWWWWWWWWWRRRRRRRRRRRRRR',
102
+ ]
103
+
104
+
105
+ def braille_lines() -> list[list[tuple[str, str]]]:
106
+ """Lines of (characters, colour) runs; colour '' for blanks."""
107
+ out = []
108
+ for chars, cols in zip(BRAILLE, BRAILLE_COLOURS):
109
+ runs: list[tuple[str, str]] = []
110
+ for ch, c in zip(chars, cols):
111
+ colour = {"R": theme.RED, "W": theme.WHITE}.get(c, "")
112
+ if runs and runs[-1][1] == colour:
113
+ runs[-1] = (runs[-1][0] + ch, colour)
114
+ else:
115
+ runs.append((ch, colour))
116
+ out.append(runs)
117
+ return out
pal/terminal/models.py ADDED
@@ -0,0 +1,99 @@
1
+ """Which local model is the strongest one on this machine?
2
+
3
+ "Strongest" here means the largest chat model Ollama has installed, judged by parameter count (from Ollama's own details, else from the name,
4
+ else from the file size). That is a proxy: a bigger model is usually better but needs more memory, so the page shows the choice and lets you
5
+ change it. Embedding models are never chosen: they cannot chat.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import asdict, dataclass
11
+ from typing import Any
12
+
13
+ EMBEDDING_HINTS = ("embed", "bge-", "minilm", "nomic", "e5-", "gte-", "snowflake-arctic", "mxbai", "paraphrase", "rerank")
14
+ _GB_PER_BILLION = 0.55 # a 4-bit model is roughly 0.55 GB per billion parameters: only used when nothing else says how big it is
15
+ _DETAIL = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([BM])\s*$", re.I)
16
+ _TAG = re.compile(r"(?<![\w.])(\d+(?:\.\d+)?)b(?![a-z])", re.I)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class LocalModel:
21
+ name: str
22
+ params_b: float | None # billions of parameters, when known
23
+ size_gb: float | None # size on disk
24
+ family: str
25
+ quant: str
26
+ embedding: bool
27
+
28
+ @property
29
+ def power(self) -> float:
30
+ """The number models are ranked by: parameters in billions, else estimated from the file size, else 0."""
31
+ if self.params_b is not None:
32
+ return self.params_b
33
+ return round(self.size_gb / _GB_PER_BILLION, 2) if self.size_gb else 0.0
34
+
35
+ def label(self) -> str:
36
+ bits = [self.name]
37
+ if self.params_b is not None:
38
+ bits.append(f"{self.params_b:g}B")
39
+ if self.size_gb:
40
+ bits.append(f"{self.size_gb:.1f} GB")
41
+ return " | ".join(bits)
42
+
43
+
44
+ def _params_from(name: str, details: dict) -> float | None:
45
+ m = _DETAIL.match(str(details.get("parameter_size") or ""))
46
+ if m:
47
+ value = float(m.group(1))
48
+ return value / 1000 if m.group(2).upper() == "M" else value
49
+ tag = name.split(":", 1)[1] if ":" in name else ""
50
+ found = _TAG.search(tag) or _TAG.search(name)
51
+ return float(found.group(1)) if found else None
52
+
53
+
54
+ def parse_tags(payload: Any) -> list[LocalModel]:
55
+ """Ollama's /api/tags -> models. Tolerant: anything that is not a named model is skipped."""
56
+ out: list[LocalModel] = []
57
+ items = payload.get("models") if isinstance(payload, dict) else None
58
+ for m in items if isinstance(items, list) else []:
59
+ if not isinstance(m, dict):
60
+ continue
61
+ name = str(m.get("name") or m.get("model") or "")
62
+ if not name:
63
+ continue
64
+ details: dict = m["details"] if isinstance(m.get("details"), dict) else {}
65
+ size = m.get("size")
66
+ out.append(LocalModel(name=name, params_b=_params_from(name, details), size_gb=round(size / 1e9, 2) if isinstance(size, (int, float)) and size > 0 else None,
67
+ family=str(details.get("family") or ""), quant=str(details.get("quantization_level") or ""),
68
+ embedding=any(h in name.lower() or h in str(details.get("family") or "").lower() for h in EMBEDDING_HINTS)))
69
+ return sorted(out, key=lambda x: x.name)
70
+
71
+
72
+ def strongest(models: list[LocalModel]) -> LocalModel | None:
73
+ """The largest chat model (ties: the bigger file, then the name), or None when there is none."""
74
+ chat = [m for m in models if not m.embedding]
75
+ return max(chat, key=lambda m: (m.power, m.size_gb or 0.0, m.name)) if chat else None
76
+
77
+
78
+ def fetch_tags(url: str, get: Any = None, timeout: float = 3.0) -> Any:
79
+ if get is None:
80
+ import requests
81
+ get = requests.get
82
+ r = get(f"{url.rstrip('/')}/api/tags", timeout=timeout)
83
+ r.raise_for_status()
84
+ return r.json()
85
+
86
+
87
+ def discover(url: str, get: Any = None) -> dict:
88
+ """What the page needs: whether Ollama answers, the models it has, and the recommended one."""
89
+ try:
90
+ models = parse_tags(fetch_tags(url, get))
91
+ except Exception as e: # noqa: BLE001 (not running, not reachable, or not Ollama)
92
+ return {"ok": False, "url": url, "models": [], "recommended": None, "error": f"Ollama is not reachable at {url} ({type(e).__name__}). Start it with `ollama serve`."}
93
+ best = strongest(models)
94
+ error = None
95
+ if not models:
96
+ error = "Ollama is running but has no models. Pull one, for example: ollama pull llama3.1:8b"
97
+ elif best is None:
98
+ error = "Ollama has only embedding models, which cannot chat. Pull a chat model, for example: ollama pull llama3.1:8b"
99
+ return {"ok": best is not None, "url": url, "models": [{**asdict(m), "label": m.label()} for m in models], "recommended": best.name if best else None, "error": error}