From db96d5a29f5ece42d2b1c13856e7463f37171a03 Mon Sep 17 00:00:00 2001 From: Hoang130203 Date: Fri, 7 Aug 2026 11:20:33 +0700 Subject: [PATCH] fix(external-llm): match Ollama models carrying the default :latest tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The external-model reuse path normalises both sides of a comparison and then requires an exact string match. Normalisation translated `:` to `-` only after stripping quantization suffixes, so anything Ollama put in the tag position survived into the name. Ollama's default tag is `latest`. `ollama pull qwen3-30b-a3b` is listed by /api/tags as `qwen3-30b-a3b:latest`, which normalised to `qwen3-30b-a3b-latest` and matched the tier target `qwen3-30b-a3b` at nothing: qwen3-30b-a3b:latest -> qwen3-30b-a3b-latest NO MATCH gemma-4-e4b-it:latest -> gemma-4-e4b-it-latest NO MATCH qwen3.5-2b:q8_0 -> qwen3.5-2b-q8-0 NO MATCH That is the commonest case there is: the user already has exactly the model ODS wants, pulled by bare name. Reuse declined it and the installer downloaded a duplicate multi-GB GGUF — the opposite of what the feature exists to do. A user who happened to pull with an explicit size tag (qwen3.5:9b) matched fine, which is why this survived. Do tag handling before the quantization strip: drop a trailing `:latest`, then translate the colon so a quantization arriving as a tag is recognisable to the existing pattern. Size tags still survive as part of the identity (qwen3.5:9b -> qwen3.5-9b), and `latest` is only ever stripped as a tag, never from inside a name. Extends tests/test-external-services.sh with 12 assertions: every tag shape, the tier-target-to-Ollama-listing matches, and negative cases so a different size or a different model carrying :latest still does not match. --- ods/installers/lib/external-services.sh | 13 ++++++++- ods/tests/test-external-services.sh | 39 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/ods/installers/lib/external-services.sh b/ods/installers/lib/external-services.sh index d5a10b9b9..7dee880e4 100644 --- a/ods/installers/lib/external-services.sh +++ b/ods/installers/lib/external-services.sh @@ -1,14 +1,25 @@ #!/bin/bash # External Ollama / LM Studio discovery and validation helpers. +# Reduce a provider's model id to a comparable name. +# +# Ollama reports names as `:`. The tag is the default `latest` +# unless the user asked for a specific one, so `ollama pull qwen3-30b-a3b` +# is listed as `qwen3-30b-a3b:latest` — the exact model ODS wants, under a +# name that used to normalize to `qwen3-30b-a3b-latest` and match nothing. +# +# Tag handling therefore runs BEFORE the quantization strip: a quant that +# arrives in the tag position (`qwen3.5-2b:q8_0`) is only recognisable once +# the colon has become a separator the quant pattern accepts. external_llm_normalize_model_name() { local value="${1:-}" value="${value##*/}" value="${value,,}" value="${value%.gguf}" value="$(printf '%s' "$value" | sed -E \ - -e 's/[-_.](q[0-9]+([_.][a-z0-9]+)*|iq[0-9]+([_.][a-z0-9]+)*|fp(8|16|32)|bf16)([-_.].*)?$//' \ + -e 's/:latest$//' \ -e 's/:/-/' \ + -e 's/[-_.](q[0-9]+([_.][a-z0-9]+)*|iq[0-9]+([_.][a-z0-9]+)*|fp(8|16|32)|bf16)([-_.].*)?$//' \ -e 's/[[:space:]_]+/-/g' \ -e 's/-+/-/g' \ -e 's/^-|-$//g')" diff --git a/ods/tests/test-external-services.sh b/ods/tests/test-external-services.sh index 9809cdab6..b37ab2440 100644 --- a/ods/tests/test-external-services.sh +++ b/ods/tests/test-external-services.sh @@ -43,6 +43,27 @@ assert_eq "$(external_llm_normalize_model_name 'qwen3.5:9b')" \ "qwen3.5-9b" "normalizes Ollama tags" assert_eq "$(external_llm_normalize_model_name 'Qwen3.5-9B-Q4_K_M.gguf')" \ "qwen3.5-9b" "removes GGUF quantization suffixes" +# `latest` is Ollama's default tag: `ollama pull qwen3-30b-a3b` is listed by +# /api/tags as `qwen3-30b-a3b:latest`. Treating that as part of the name meant +# the commonest case — the user already has exactly the model we want — never +# matched, and the installer downloaded a duplicate copy. +assert_eq "$(external_llm_normalize_model_name 'qwen3-30b-a3b:latest')" \ + "qwen3-30b-a3b" "drops Ollama's default :latest tag" +assert_eq "$(external_llm_normalize_model_name 'gemma-4-e4b-it:latest')" \ + "gemma-4-e4b-it" "drops :latest from a hyphenated name" +# A quantization that arrives in the tag position is still a quantization. +assert_eq "$(external_llm_normalize_model_name 'qwen3.5-2b:q8_0')" \ + "qwen3.5-2b" "strips a quantization supplied as an Ollama tag" +assert_eq "$(external_llm_normalize_model_name 'qwen3.5-9b:iq4_xs')" \ + "qwen3.5-9b" "strips an IQ quantization tag" +# A size tag is part of the identity and must survive. +assert_eq "$(external_llm_normalize_model_name 'qwen3.5:9b')" \ + "qwen3.5-9b" "keeps a size tag" +assert_eq "$(external_llm_normalize_model_name 'llama3.2:3b')" \ + "llama3.2-3b" "keeps a size tag on another family" +# "latest" only counts as a tag, never as part of the name. +assert_eq "$(external_llm_normalize_model_name 'my-latest-model')" \ + "my-latest-model" "does not strip latest from inside a name" assert_eq "$(external_llm_container_url 'http://localhost:11434/v1')" \ "http://host.docker.internal:11434" "normalizes localhost for containers" assert_eq "$(external_llm_container_url 'http://[::1]:1234/api/v1')" \ @@ -73,6 +94,24 @@ if external_llm_model_matches "qwen3.5:9b" "llama3.2:3b"; then else pass "rejects unrelated model families" fi +# The tier map's LLM_MODEL against what /api/tags actually reports for a +# model the user pulled by bare name. +assert_true "matches a tier target against Ollama's :latest listing" \ + external_llm_model_matches "qwen3-30b-a3b" "qwen3-30b-a3b:latest" +assert_true "matches a hyphenated tier target against :latest" \ + external_llm_model_matches "gemma-4-e4b-it" "gemma-4-e4b-it:latest" +assert_true "matches a tier target against a quantization tag" \ + external_llm_model_matches "qwen3.5-2b" "qwen3.5-2b:q8_0" +if external_llm_model_matches "qwen3.5-9b" "qwen3.5:4b"; then + fail "rejects a different size in the tag" +else + pass "rejects a different size in the tag" +fi +if external_llm_model_matches "qwen3-30b-a3b" "qwen3-8b:latest"; then + fail "rejects a different model carrying :latest" +else + pass "rejects a different model carrying :latest" +fi run_phase_case() { set -euo pipefail