Skip to content

Commit 28793d8

Browse files
ColinGPT9claude
andauthored
Fix caption corrections, model downloads and missing-model jobs (#163)
* Model downloads count as done only when the model is installed (#148, #150) A pull now counts as done only once Ollama has sent "success" and lists the model. A stream that stops short, or a model that never shows up, is reported as an error, so a failed tag is never chosen as the active model (that gave a 404 from /api/generate). Each download's state is kept for the new GET /models/pulls, and a second POST /models/pull for a tag that is already downloading starts nothing and returns "already". The Models page tracks each download by tag. It reads GET /models/pulls when it opens and every 3 s while something downloads, so a missed final event no longer leaves the bar stuck. The percentage rounds down and stops at 99 until the model is done. Ollama's checking steps say to keep the app open, and a failure shows its error with Try again. Setup does the same, starts its download by itself only once, and offers Try again after an error or when "done" still leaves no usable model. The bundled Ollama runs with OLLAMA_NOPRUNE so a stopped download resumes instead of being pruned at the next start. Its output goes to <data>/logs/ollama.log. Closing the app while a model downloads asks first. It asks the engine, and if the engine doesn't answer within 2 s it quits anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * A missing local model fails the job at once, in plain words (#149) A job on a local model that was never downloaded ran the download and the transcription, then failed with "404 Client Error: Not Found for url: http://127.0.0.1:11435/api/generate". - OllamaBackend: a 4xx is no longer a bare HTTPError. A 404 raises LLMError("model_unavailable"): the model isn't downloaded, get it on the Models page. Any other refusal says what Ollama said. Still asked once. - The worker asks Ollama what it has before a process job downloads anything, and ends the job when there is no model at all. Ollama not answering, another installed model, a cloud model and a Shorts-only plugin pipeline run as before. - A failed job stores an LLMError's own message. - resolve_usable_model returns the name Ollama listed ("gemma3:12b"), never a bare family that Ollama reads as gemma3:latest and answers with a 404. - The queue row offers Open Models for this error, beside the OpenRouter suggestion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Editor: a fixed caption word is burned and shown after Apply (#147) A corrected word could reach the render with its old spelling, and the editor kept showing the old text after Apply: - Caption fixes and mute censoring move to lib/captionEdits.ts and match words by Unicode letters, marks and digits instead of \b, which knows only a-z: an accented word was never found, and "perch" matched inside "perché". - The word box keeps what was typed when it loses focus, like Enter; only Escape drops it. - Apply leaves the draft preview for the clip. - A re-render deletes the old draft and publishes clip_replaced, and the clip list moves the open editor and the selected card to the new id instead of losing the clip. - /media and /media/preview send Cache-Control: no-store, and the preview route's leftover unlink uses core.paths.discard (WinError 32). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * llm/pulls.py: which model downloads are running in this process Shared by the model download (server/api.py) and the job's model check (server/jobs.py), so a job doesn't fail for a model still downloading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Model downloads: every quit asks, a cut-off pull says try again, old partials go A pull whose stream Ollama cut off (it quit or crashed while checking, or the connection dropped) made requests raise from iter_lines, so the error was "Response ended prematurely". It now gets the same plain "didn't finish (last step: ...). Try again" message. The download is recorded in llm.pulls from before its thread starts until it ends, so a job can tell its model is coming. The quit warning moves into before-quit, so File > Exit, the tray and the window all ask; the updater's install and session end go straight through, and an engine that doesn't answer in 2 s never holds a quit up. Its text now says what happens: start it again from the Models page and it carries on. OLLAMA_NOPRUNE stays, but partial blobs untouched for a week are deleted before Ollama starts. Setup stops waiting on a download the engine has forgotten, and the Ask Clips Kitty install button uses the shared percentage and checking text and catches up from GET /models/pulls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * The job-start model check runs first and lets a model on its way through Review of the #149 check: - A process job is checked before it waits for its own prefetch or starts the next video's. A job failing the check used to start that download, so a batch with no model downloaded every video only to fail each one. Render and translate jobs start the prefetch as before. - The check lets the job go ahead while a model download is running in this process (llm/pulls.py), as the first-run flow needs: setup tells a new user to carry on, and Ollama lists nothing until the pull is done. - It also lets through a Shorts job whose Shorts were made before (no force): process_video ends it as done without the AI, as it always did. - The queue row's Open Models matches missing_model's own wording, not a plugin's model that "isn't downloaded yet" (Marketplace, not Models). - A bare family takes its smallest recommended member, not whichever Ollama lists first (the most recently pulled). - Preflight names the model a job will load ("gemma3:12b", not "gemma3"). - The AI stack repo map describes installed_as, usable_model_in and the job-start check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Editor: a word with a hyphen is fixed, and the editor stays open after Apply (#147) The review of the caption fixes found gaps left in them: - captionEdits.ts trims punctuation only from a word's ends. Taken out of the middle too, "peut-être", "e-mail", "3,5", "U.S." and a Persian word with a ZWNJ inside were never found in the caption line, so their fix or mute went nowhere. - ClipStudio notes a re-rendered clip's new id as clip_replaced comes, and every refresh moves the editor and the selected card with the list that has it. The job's own 'done' refresh could land first, and the editor closed and opened again from scratch. - Escape in the word box drops that word only; EditorModal no longer closes the editor on an Escape typed in a text field. - test_clip_rekey keeps a real second clip's draft, not the new clip's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #147-#150: the last review findings - llm/pulls.py counts downloads, so a retry of the same tag starting as the first one's thread ends isn't taken off the list. - The updater's "installing" flag is set on before-quit-for-update, when the installer really starts; set on the click, a failed install left every later quit skipping the download question. - Closing the window with nothing watched drops the tray before quitting, so the download question shows the window instead of leaving it hidden. - dropStalePartials also clears finished blobs no manifest lists after a week (what a quit during "verifying" leaves), and only when every manifest reads. - A corrected caption word no longer doubles the line's punctuation ("ciao,,"). - Escape from a slider or a checkbox leaves the editor again; only text fields count as typing. - The AI-stack map's line references, a tighter family test, and CHANGELOG entries for #147 and #149. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #147-#150: final review fixes - The job-start model check lets an Edited Stream through: it only cuts silence and AFK stretches with a fixed title, and finished with no model before the check. Its "also make Shorts" still needs one, unless they were made before. - dropStalePartials deletes no whole blob unless every manifest is the bundled Ollama's own shape (docker v2 manifest, a config, no manifest list) and there is no manifests-v2 folder: a newer Ollama's models would read as orphans. A test pins the bundled Ollama at v0.32.6 so a bump brings a look at this file. - A word both corrected and muted is censored as corrected; the mute looked for Whisper's spelling, which the fix had already replaced. - The job log no longer says a family in settings "is not installed" when Ollama has it as :latest, or loads a member of it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: ColinGPT9 <ColinGPT9@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b61630a commit 28793d8

33 files changed

Lines changed: 2455 additions & 229 deletions

‎CHANGELOG.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,28 @@ were often broken in a way that only showed up on somebody else's machine.
210210

211211
### Fixed
212212

213+
- **Model downloads no longer reach 100% and then do nothing.** The bar stays below
214+
100% until the model is really installed, and while Ollama checks a big download,
215+
which can take minutes, it says **Checking the download… keep Clips Kitty open**.
216+
Quitting during a download asks first. A download that stopped short now says so, with
217+
**Try again**, which carries on where it stopped instead of starting over; before, it
218+
could be called done, and even chosen as your AI model, with nothing there. The Models
219+
page and setup also catch up on how a download ended if they missed it. A download
220+
nobody has carried on with for a week is cleared from disk when the app starts. Ollama's
221+
own log is kept in `%LOCALAPPDATA%\Clips Studio\data\logs\ollama.log`. (#148, #150)
222+
- **A job on an AI model that isn't downloaded stops at once and says so.** It used to
223+
download and transcribe the video first and then fail with "404 Client Error: Not Found
224+
for url: http://127.0.0.1:11435/api/generate". It now ends before anything is
225+
downloaded with "The AI model … isn't downloaded. Download it on the Models page, or
226+
choose a model you have.", and the queue row has an **Open Models** button. A job queued
227+
while setup is still downloading the model carries on as before, and when another model
228+
is installed the job still uses that one. (#149)
229+
- **A word you correct in the captions is burned into the clip.** A correction to a word
230+
with an accent ("perché", "città"), a hyphen or an apostrophe, or in a non-Latin script,
231+
was never found in the caption line, so the clip kept the old word. A correction is also
232+
kept when you click away from the word instead of pressing Enter, and after **Apply**
233+
the editor shows the re-rendered clip instead of the earlier preview, staying open on
234+
it. Escape in the word box now cancels that word only, not the whole editor. (#147)
213235
- **Cloud AI keys save again.** In 2.0.0, saving a key for OpenRouter, Gemini, OpenAI or
214236
Anthropic failed with an error, because part of the cloud AI support was left out of
215237
the installed app. (#135)

‎core/pipeline.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,15 @@ def _with_usable_model(llm_config: dict) -> dict:
166166
usable = resolve_usable_model(
167167
llm_config.get("ollama_host", "http://localhost:11434"), configured
168168
)
169-
if not usable or usable == configured:
169+
# A bare family name is what Ollama itself loads as ":latest": the same model.
170+
if not usable or usable == configured or usable == f"{configured}:latest":
170171
return llm_config
171172

172-
print(f" AI model: '{configured}' is not installed — using '{usable}'")
173+
if ":" not in configured and usable.split(":")[0] == configured:
174+
# Settings name the family; this is the member that is installed.
175+
print(f" AI model: '{configured}' is loaded as '{usable}', the one installed")
176+
else:
177+
print(f" AI model: '{configured}' is not installed — using '{usable}'")
173178
return {**llm_config, "backend": f"ollama/{usable}"}
174179

175180

‎core/preflight.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,13 @@ def check_ollama(host: str, model: str) -> list[Check]:
123123
# reviewer watched gemma3:4b reach 100% and still be told gemma:7b was
124124
# missing. "The recommended model is absent" and "there is no AI at all"
125125
# are completely different situations and only the second is a failure.
126-
from llm.manager import model_is_installed, resolve_usable_model
126+
#
127+
# The name shown is the one a job will load, from the list just fetched:
128+
# with "gemma3" in settings and gemma3:12b pulled, that is gemma3:12b, not
129+
# the bare family, which Ollama would read as gemma3:latest (#149).
130+
from llm.manager import usable_model_in
127131

128-
usable = (
129-
model.split("/")[-1]
130-
if model and model_is_installed(model, installed)
131-
else resolve_usable_model(host, model)
132-
)
132+
usable = usable_model_in(model, installed)
133133
checks.append(Check(
134134
name="model",
135135
ok=bool(usable),

‎docs/API.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -702,7 +702,10 @@ Ollama serves works. The app has no allow-list.
702702

703703
Downloads a model. Returns immediately; **progress arrives on the WebSocket**
704704
as `{"type": "model_pull", "tag": …, "status": "done"}` (or `"error"`). A 12 GB model
705-
on a slow connection is not an HTTP request you want to hold open.
705+
on a slow connection is not an HTTP request you want to hold open. `"done"` comes only
706+
once Ollama has said the pull succeeded and lists the model; a download that stopped
707+
short is an `"error"`. Asking for a tag that is already downloading starts nothing and
708+
returns `{"started": tag, "already": true}`.
706709

707710
### Where the AI runs: `/ai`
708711

@@ -1513,7 +1516,7 @@ An agent skill for clients that support them is in [`skills/clips-kitty/`](../sk
15131516
ws://127.0.0.1:8765/ws
15141517
```
15151518

1516-
Connect and listen. The server never expects a message from you. Six event
1519+
Connect and listen. The server never expects a message from you. Seven event
15171520
types:
15181521

15191522
```json
@@ -1523,6 +1526,7 @@ types:
15231526
{"type": "model_pull", "tag": "gemma3:12b", "status": "done"}
15241527
{"type": "publish", "publish_job": 4, "clip_id": 812, "stage": "publish", "phase": "upload", "fraction": 0.42, "message": "Uploading to YouTube"}
15251528
{"type": "automation"}
1529+
{"type": "clip_replaced", "old": 812, "new": 845, "video_id": "aB3dEfGhIjK"}
15261530
```
15271531

15281532
- **`queue`** carries no data. It means "something changed, re-fetch
@@ -1544,6 +1548,9 @@ types:
15441548
- **`model_pull`** is download progress for `POST /models/pull`.
15451549
- **`automation`** carries no data, like `queue`: a watch found something or
15461550
moved a video along. Re-fetch `GET /automation/items`.
1551+
- **`clip_replaced`** is a clip rendered again with new edits or times. It is
1552+
back under a new id (`new`; `old` is gone), with its title, translations and
1553+
uploads moved over to it. Anything holding the old id should follow it.
15471554
- **`publish`** is a YouTube upload. `phase` moves through `prepare`, `upload`,
15481555
`metadata`, then one of `done` / `failed` / `cancelled`. The terminal one
15491556
also sets `terminal` to the same value, and `done` carries `youtube_id`,

‎docs/developers/api-reference.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
API version **1** (`GET /health` → `api_version`). The engine listens on `http://127.0.0.1:8765`. How to call it, with examples and the gotchas, is in [`docs/API.md`](../API.md); what the labels promise is in [API](api.md).
66

7-
196 routes: **60 stable**, **25 experimental**, 111 internal.
7+
197 routes: **60 stable**, **25 experimental**, 112 internal.
88

99
| Label | Promise |
1010
|---|---|
@@ -195,7 +195,7 @@ Everything else. Grouped by the first part of the path; the module is where it i
195195
| `/feedback` | `GET /feedback/diagnostics`<br>`POST /feedback/submit` | `server.api` |
196196
| `/jobs` | `POST /jobs/{job_id}/move` | `server.api` |
197197
| `/media` | `GET /media/preview/{clip_id}` | `server.api` |
198-
| `/models` | `DELETE /models/{tag:path}` | `server.api` |
198+
| `/models` | `GET /models/pulls`<br>`DELETE /models/{tag:path}` | `server.api` |
199199
| `/publish` | `POST /publish/{job_id}/cancel` | `server.youtube_api` |
200200
| `/queue` | `POST /queue/clear` | `server.api` |
201201
| `/remote-render` | `GET /remote-render`<br>`PUT /remote-render`<br>`POST /remote-render/jobs/{job_id}/retry`<br>`POST /remote-render/pairing-code`<br>`PUT /remote-render/this-pc`<br>`POST /remote-render/this-pc/unpair`<br>`POST /remote-render/videos/{video_id}/render-locally`<br>`DELETE /remote-render/workers/{worker_id}`<br>`POST /remote-render/workers/{worker_id}/hold` | `remote_render.service` |

‎docs/platform/repo-map/09-ai-stack-and-models.md‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,16 @@ Docs: `docs/AI-BACKENDS.md` sections 1 local, 2 OpenRouter, 3 direct APIs, plan
6868
- `RECOMMENDATIONS: list[(hardware, tag, note)]` (`llm/manager.py:31-43`): CPU/iGPU -> `gemma3:4b`; <6 GB -> `gemma4:e2b`; 6 GB -> `gemma4:e4b`; 8 GB -> `gemma:7b`; 10-12 GB -> `gemma3:12b`; 16-24 GB -> `gemma3:27b`.
6969
- `OTHER_MODELS: list[(purpose, tags, note)]` (`:66-84`): qwen3:8b/14b, mistral-nemo:12b/phi4:14b, llama3.1:8b/qwen2.5:14b, deepseek-r1:8b/14b, gpt-oss:20b, nemotron-3-nano:4b. Licence notes are free text in the note column (`:52-65`).
7070
- `recommend_for(vram_gb: float | None) -> {"model", "reason"}` (`:87-133`): thresholds >=16 -> gemma3:27b, >=10 -> gemma3:12b, >=7 -> gemma:7b, >=6 -> gemma4:e4b, else gemma4:e2b, None/0 -> gemma3:4b.
71-
- `installed_models(host) -> [{"name","size_gb"}]` = GET `{host}/api/tags` (`:136-143`).
72-
- `model_is_installed(tag, installed)` matches `"gemma:7b"`, `"ollama/gemma:7b"` or family (`:146-156`).
73-
- `resolve_usable_model(host, configured) -> str | None` (`:159-189`): configured if installed, else first RECOMMENDATIONS tag installed, else `installed[0]`.
74-
- `switch_model(settings_path, model_tag) -> str` rewrites the `model:` line in settings.yaml via regex (`:192-202`).
71+
- `installed_models(host, timeout=15) -> [{"name","size_gb"}]` = GET `{host}/api/tags` (`:138-145`).
72+
- `model_is_installed(tag, installed)` matches `"gemma:7b"`, `"ollama/gemma:7b"` or family (`:148-154`); it is `installed_as(...) is not None`.
73+
- `installed_as(tag, installed) -> str | None` (`:157-180`): the name Ollama lists `tag` under, never a bare family (Ollama reads `"gemma3"` as `gemma3:latest` and answers 404 when only `gemma3:12b` was pulled, #149): the exact name, then `<family>:latest`, then the family member that comes first in RECOMMENDATIONS (smallest first, so the result doesn't depend on Ollama listing the most recently pulled first), then the family's first in Ollama's order.
74+
- `missing_model(model) -> LLMError("model_unavailable", "The AI model <model> isn't downloaded. Download it on the Models page, or choose a model you have.")` (`:183-192`); `OllamaBackend` raises it on a 404, and the queue row's `MODEL_NOT_DOWNLOADED` (`ui/src/renderer/src/components/queue/QueueItem.tsx`) matches its wording to offer Open Models.
75+
- `resolve_usable_model(host, configured) -> str | None` (`:195-213`) asks Ollama, then `usable_model_in(configured, installed)` (`:216-233`) answers from the list: `installed_as(configured)` if installed, else the first RECOMMENDATIONS tag installed, else `installed[0]`; None only for an empty list (or, for `resolve_usable_model`, Ollama not answering). The name returned is always one Ollama listed.
76+
- `switch_model(settings_path, model_tag) -> str` rewrites the `model:` line in settings.yaml via regex (`:236-246`).
7577

7678
Ollama HTTP calls used across the engine: `GET /api/tags` (`llm/manager.py:138`, `core/preflight.py:101`), `POST /api/generate` (`llm/ollama_backend.py:110`), `POST /api/show` (`llm/ollama_backend.py:167`), `POST /api/pull` streamed with `{"model": tag}` (`server/api.py:2862-2863`), `DELETE /api/delete` (`server/api.py:2922`).
7779

78-
`/models` endpoints (`server/api.py:2807-2924`): `GET /models` -> `{active, installed, recommendations, other_models, recommended: recommend_for(_nvidia_vram_gb())}`; `POST /models/activate {tag}` (must be pulled; `switch_model`); `POST /models/pull {tag}` (background thread, broadcasts `{"type":"model_pull", tag, status, completed, total}` events, then `_select_if_nothing_usable`); `DELETE /models/{tag}`. CLI: `python main.py models [use <tag>]` (`main.py:186-188`, `:339-383`). UI: `ui/src/renderer/src/lib/api.ts:460,523,525`, `ui/src/renderer/src/pages/Models.tsx`.
80+
`/models` endpoints (`server/api.py:2933-3141`): `GET /models` -> `{active, installed, recommendations, other_models, recommended: recommend_for(_nvidia_vram_gb())}`; `POST /models/activate {tag}` (must be pulled; `switch_model`); `POST /models/pull {tag}` (background thread, broadcasts `{"type":"model_pull", tag, status, completed, total}` events; "done" only once Ollama said `success` and `/api/tags` lists the tag, then `_select_if_nothing_usable`; a cut-off stream is an "error" saying to try again; a second call for a tag still downloading returns `already: true`; the running tags are in `llm/pulls.py` for the job check); `GET /models/pulls` (each download's last state, so the UI catches up after a missed event); `DELETE /models/{tag}`. CLI: `python main.py models [use <tag>]` (`main.py:186-188`, `:339-383`). UI: `ui/src/renderer/src/lib/api.ts:460,523,525`, `ui/src/renderer/src/pages/Models.tsx`.
7981

8082
Where Ollama keeps its models: in a checkout, whatever the system Ollama uses (not managed by the repo). Packaged desktop app: Electron spawns the bundled `ollama serve` on port 11435 with env `OLLAMA_HOST` and `OLLAMA_MODELS: models` (`ui/src/main/index.ts:25-29`, `:161-163`) and tells the engine via `CLIPS_STUDIO_OLLAMA_HOST` (`:219`). `models` = `%LOCALAPPDATA%/Clips Kitty/data/models` if it already holds a `manifests/` folder (1.1.3/1.1.4 installs), else `%LOCALAPPDATA%/Clips Studio/data/models` (`ui/src/main/index.ts:150-159`), i.e. inside the engine's data dir from `core/paths.resolve_data_dir` (`core/paths.py:45-46`). The bundled exe is `resources/backend/_internal/ollama/ollama.exe` (`ui/src/main/index.ts:144`).
8183

@@ -115,7 +117,9 @@ Frozen build (PyInstaller one-dir + Electron): `_search_roots` = exe dir, `exe/<
115117

116118
Docker: `CLIPS_STUDIO_OLLAMA_HOST`, `CLIPS_STUDIO_DATA_DIR` env (`main.py:97-108`).
117119

118-
Preflight (`core/preflight.py:298-331 run(config)`): `check_ffmpeg`, `check_ollama(host, model)` (GET /api/tags + `resolve_usable_model`), `check_cloud_ai`, `check_cloud_transcription`, `check_whisper(configured)` (non-blocking: "faster-whisper can still fetch them from Hugging Face", `:200-204`), `check_gpu`, `check_disk`.
120+
Job-start check (`server/jobs.py:692-736 Worker._check_local_model(db, vid, llm_cfg, payload)`): for a process job on a local backend, before this video's prefetch is waited on or the next one's started, the worker asks Ollama (GET /api/tags, 5 s timeout) and raises `missing_model` only when `usable_model_in` finds nothing at all. It lets the job run when Ollama doesn't answer, when another model is installed (`_with_usable_model` swaps it in later), when a model download is in flight in this process (`llm/pulls.py running()`; Ollama lists a model only once its pull is complete), for a Shorts-only plugin pipeline, and for a Shorts job whose Shorts were made before (`db.shorts_made`) without `force`.
121+
122+
Preflight (`core/preflight.py:298-331 run(config)`): `check_ffmpeg`, `check_ollama(host, model)` (GET /api/tags + `usable_model_in` on that list, reporting the name a job will load), `check_cloud_ai`, `check_cloud_transcription`, `check_whisper(configured)` (non-blocking: "faster-whisper can still fetch them from Hugging Face", `:200-204`), `check_gpu`, `check_disk`.
119123

120124
## 6. What a shared model manager would have to index, and what already exists
121125

‎llm/manager.py‎

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
import requests
1111

12+
from llm.providers.base import LLMError
13+
1214
# Rough VRAM guide for Ollama default (4-bit) quantizations.
1315
#
1416
# ONE axis: what the machine can hold. Every row answers "how much VRAM do you
@@ -133,9 +135,9 @@ def recommend_for(vram_gb: float | None) -> dict:
133135
}
134136

135137

136-
def installed_models(host: str) -> list[dict]:
138+
def installed_models(host: str, timeout: float = 15) -> list[dict]:
137139
"""Models currently pulled in Ollama: [{"name", "size_gb"}]."""
138-
response = requests.get(f"{host.rstrip('/')}/api/tags", timeout=15)
140+
response = requests.get(f"{host.rstrip('/')}/api/tags", timeout=timeout)
139141
response.raise_for_status()
140142
return [
141143
{"name": m["name"], "size_gb": m.get("size", 0) / 1e9}
@@ -149,10 +151,44 @@ def model_is_installed(tag: str, installed: list[str]) -> bool:
149151
Ollama reports "gemma:7b"; a config may say "gemma:7b", "ollama/gemma:7b",
150152
or just the family. All three have to match the same model.
151153
"""
154+
return installed_as(tag, installed) is not None
155+
156+
157+
def installed_as(tag: str, installed: list[str]) -> str | None:
158+
"""The name Ollama lists `tag` under, matched as model_is_installed
159+
matches it, or None.
160+
161+
A family is not a model Ollama can load: it reads a bare "gemma3" as
162+
gemma3:latest, and answers 404 when only gemma3:12b was pulled. So the
163+
name handed back is always one Ollama listed: the exact one, then the
164+
family's ":latest", then a member of the family.
165+
"""
152166
wanted = tag.split("/")[-1]
153-
return any(
154-
m == wanted or m.startswith(f"{wanted}:") or m.split(":")[0] == wanted
155-
for m in installed
167+
if wanted in installed:
168+
return wanted
169+
if f"{wanted}:latest" in installed:
170+
return f"{wanted}:latest"
171+
family = [m for m in installed if m.startswith(f"{wanted}:") or m.split(":")[0] == wanted]
172+
# Ollama lists the most recently pulled first, so its order says only
173+
# which came last: with gemma3:27b and gemma3:4b both pulled, a small card
174+
# got the 27b whenever it was pulled second. RECOMMENDATIONS runs smallest
175+
# first, so the member taken is the one that runs on the most machines. A
176+
# family it doesn't list keeps Ollama's order.
177+
for _hardware, recommended, _note in RECOMMENDATIONS:
178+
if recommended in family:
179+
return recommended
180+
return family[0] if family else None
181+
182+
183+
def missing_model(model: str) -> LLMError:
184+
"""The error for a local model Ollama doesn't have, in words a creator
185+
can act on. The queue row offers the Models page on "isn't downloaded.
186+
Download it on the Models page" (QueueItem.tsx), so the two are changed
187+
together."""
188+
named = f"The AI model {model}" if model else "The AI model"
189+
return LLMError(
190+
"model_unavailable",
191+
f"{named} isn't downloaded. Download it on the Models page, or choose a model you have.",
156192
)
157193

158194

@@ -174,18 +210,26 @@ def resolve_usable_model(host: str, configured: str) -> str | None:
174210
installed = [m["name"] for m in installed_models(host)]
175211
except Exception:
176212
return None # Ollama unreachable; the runtime check reports that
213+
return usable_model_in(configured, installed)
214+
177215

216+
def usable_model_in(configured: str, installed: list[str]) -> str | None:
217+
"""resolve_usable_model's answer from a list Ollama has already given:
218+
None only when the list is empty."""
178219
if not installed:
179220
return None
180-
if configured and model_is_installed(configured, installed):
181-
return configured.split("/")[-1]
221+
if configured:
222+
name = installed_as(configured, installed)
223+
if name:
224+
return name # what Ollama listed, never the bare family (#149)
182225

183226
# Nothing selected that can be used, so pick for them rather than stopping.
184227
# Preferring the recommendation order means the choice matches what the
185228
# Models page would have suggested, instead of whatever Ollama lists first.
186229
for _hardware, tag, _note in RECOMMENDATIONS:
187-
if model_is_installed(tag, installed):
188-
return tag
230+
name = installed_as(tag, installed)
231+
if name:
232+
return name
189233
return installed[0]
190234

191235

0 commit comments

Comments
 (0)