diff --git a/ods/installers/lib/external-services.sh b/ods/installers/lib/external-services.sh index 16ef70888..96564b226 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 9beac6f7c..defbf75e4 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 # ── external_llm_env_value: parses .env the way lib/safe-env.sh does ─────────