Skip to content

Instantly share code, notes, and snippets.

@devopsec
Created June 20, 2026 00:25
Show Gist options
  • Select an option

  • Save devopsec/3a8661e81d9af0837d6e19e994f0ce4c to your computer and use it in GitHub Desktop.

Select an option

Save devopsec/3a8661e81d9af0837d6e19e994f0ce4c to your computer and use it in GitHub Desktop.
Migrates LM Studio models and parameters to Ollama
#!/usr/bin/env bash
#
# PACKAGE: lmstudio-to-ollama
# VERSION: 1.0.0
# SUMMARY: Migrates LM Studio models and config presets into Ollama blob and manifest store.
#
function usage() {
cat <<EOF
Summary: Migrate LM Studio GGUF models and config presets into Ollama's blob/manifest store.
Builds Docker-manifest-v2 manifests directly — no Modelfile, no re-quantization.
Usage: $0 [-h] [-d] [-n] [-f] [--all-quants] [--skip-remediate] [--cleanup]
[--lms-dir <path>] [--presets-dir <path>] [--ollama-dir <path>]
[--lms-config-dir <path>]
Arguments: -h Show this usage documentation.
-d Enable debug/verbose output.
-n Dry-run: print planned actions without modifying Ollama store.
-f Force overwrite existing blobs and manifests.
--all-quants Import all quantization variants without prompting.
--skip-remediate Skip the remediate_existing() pass for type:local manifests.
--cleanup Remove original non-standard manifests after remediation.
--lms-dir <path> Override LM Studio models directory.
Default: ~/.lmstudio/models
--presets-dir <p> Override LM Studio config-presets directory.
Default: ~/.lmstudio/config-presets
--ollama-dir <p> Override Ollama models directory.
Default: ~/.ollama/models
--lms-config-dir <p> Override LM Studio per-model config directory.
Default: ~/.lmstudio/.internal/user-concrete-model-default-config
Supported: Arch Linux amd64
Notes: Re-running is safe (idempotent). Use -f to overwrite existing manifests.
Fields not mappable to Ollama params are logged as WARN and skipped.
EOF
}
# ---------------------------------------------------------------------------
# Strict mode
# ---------------------------------------------------------------------------
if [[ -n "${_Dbg_set_debug:-}" ]]; then
set -Euo pipefail
else
set -Eeuo pipefail
fi
shopt -s inherit_errexit nullglob
export DEBUG="${DEBUG:-0}"
(( DEBUG == 1 )) && set -x
# ---------------------------------------------------------------------------
# Global variables
# ---------------------------------------------------------------------------
declare -g LMS_MODELS_DIR="${HOME}/.lmstudio/models"
declare -g LMS_PRESETS_DIR="${HOME}/.lmstudio/config-presets"
declare -g LMS_CONFIG_DIR="${HOME}/.lmstudio/.internal/user-concrete-model-default-config"
declare -g OLLAMA_MODELS_DIR="${HOME}/.ollama/models"
declare -ig DRY_RUN=0
declare -ig FORCE=0
declare -ig ALL_QUANTS=0
declare -ig SKIP_REMEDIATE=0
declare -ig CLEANUP=0
# ---------------------------------------------------------------------------
# Logging helpers
# ---------------------------------------------------------------------------
log_info() { printf '[INFO] %s\n' "$*" >&2; }
log_warn() { printf '[WARN] %s\n' "$*" >&2; }
log_debug() { if (( DEBUG == 1 )); then printf '[DEBUG] %s\n' "$*" >&2; fi; }
log_dry() { printf '[DRY] %s\n' "$*" >&2; }
log_err() { printf '[ERROR] %s\n' "$*" >&2; }
# ---------------------------------------------------------------------------
# Dependency check
# ---------------------------------------------------------------------------
function check_deps() {
local dep
for dep in sha256sum jq cp ln stat mktemp; do
command -v "$dep" &>/dev/null || {
log_err "missing dependency: $dep"
exit 1
}
done
}
# ---------------------------------------------------------------------------
# blob_exists <digest> → returns 0 if already present
# ---------------------------------------------------------------------------
function blob_exists() {
local digest="$1"
local blob_path="${OLLAMA_MODELS_DIR}/blobs/sha256-${digest}"
[[ -f "$blob_path" ]]
}
# ---------------------------------------------------------------------------
# ingest_blob <src_file> → prints digest to stdout; side-effects blob store
# Skips if blob already exists (unless FORCE=1).
# Uses hard-link when same filesystem, cp --reflink=auto otherwise.
# Atomic: writes to .tmp then renames.
# ---------------------------------------------------------------------------
function ingest_blob() {
local src="$1"
local digest
local src_dev dst_dev
local blob_path tmp_path
# Guard: skip zero-byte files
if [[ ! -s "$src" ]]; then
log_warn "skipping empty file: $src"
echo ""
return 0
fi
digest=$(sha256sum "$src" | cut -d' ' -f1)
blob_path="${OLLAMA_MODELS_DIR}/blobs/sha256-${digest}"
if blob_exists "$digest" && (( FORCE == 0 )); then
log_info "blob already exists, skipping: sha256-${digest:0:12}..."
echo "$digest"
return 0
fi
if (( DRY_RUN == 1 )); then
log_dry "would ingest blob: $(basename "$src") → sha256-${digest:0:12}..."
echo "$digest"
return 0
fi
mkdir --parents "$(dirname "$blob_path")"
tmp_path="${blob_path}.tmp.$$"
src_dev=$(stat --format="%d" "$src")
dst_dev=$(stat --format="%d" "${OLLAMA_MODELS_DIR}/blobs")
if [[ "$src_dev" == "$dst_dev" ]]; then
log_debug "hard-linking: $src → $blob_path"
ln --force "$src" "$tmp_path"
else
log_debug "copying: $src → $blob_path"
cp --reflink=auto "$src" "$tmp_path"
fi
# verify digest post-copy
local verify_digest
verify_digest=$(sha256sum "$tmp_path" | cut -d' ' -f1)
if [[ "$verify_digest" != "$digest" ]]; then
rm --force "$tmp_path"
log_err "digest mismatch after copy for $src (expected $digest, got $verify_digest)"
echo ""
return 1
fi
mv --force "$tmp_path" "$blob_path"
log_info "ingested blob: $(basename "$src") → sha256-${digest:0:12}..."
echo "$digest"
}
# ---------------------------------------------------------------------------
# write_text_blob <content_var> → prints digest to stdout; side-effects blob store
# ---------------------------------------------------------------------------
function write_text_blob() {
local content="$1"
local digest tmp_path blob_path
digest=$(printf '%s' "$content" | sha256sum | cut -d' ' -f1)
blob_path="${OLLAMA_MODELS_DIR}/blobs/sha256-${digest}"
if blob_exists "$digest" && (( FORCE == 0 )); then
log_debug "text blob already exists: sha256-${digest:0:12}..."
echo "$digest"
return 0
fi
if (( DRY_RUN == 1 )); then
log_dry "would write text blob: sha256-${digest:0:12}..."
echo "$digest"
return 0
fi
mkdir --parents "$(dirname "$blob_path")"
tmp_path="${blob_path}.tmp.$$"
printf '%s' "$content" >"$tmp_path"
mv --force "$tmp_path" "$blob_path"
log_debug "wrote text blob: sha256-${digest:0:12}..."
echo "$digest"
}
# ---------------------------------------------------------------------------
# file_size <path> → prints size in bytes
# ---------------------------------------------------------------------------
function file_size() {
local path="$1"
stat --format="%s" "$path"
}
# ---------------------------------------------------------------------------
# blob_size <digest> → prints size in bytes of stored blob
# ---------------------------------------------------------------------------
function blob_size() {
local digest="$1"
local blob_path="${OLLAMA_MODELS_DIR}/blobs/sha256-${digest}"
if [[ -f "$blob_path" ]]; then
file_size "$blob_path"
else
echo "0"
fi
}
# ---------------------------------------------------------------------------
# guess_model_family <gguf_filename> → prints family string (e.g. "gemma", "llama")
# ---------------------------------------------------------------------------
function guess_model_family() {
local name
name=$(basename "$1" .gguf | tr '[:upper:]' '[:lower:]')
if [[ "$name" == *gemma* ]]; then echo "gemma"
elif [[ "$name" == *llama* ]]; then echo "llama"
elif [[ "$name" == *qwen* ]]; then echo "qwen"
elif [[ "$name" == *flux* ]]; then echo "flux"
elif [[ "$name" == *mistral* ]]; then echo "mistral"
elif [[ "$name" == *phi* ]]; then echo "phi"
elif [[ "$name" == *nomic* ]]; then echo "nomic"
else echo "gguf"
fi
}
# ---------------------------------------------------------------------------
# guess_quant_type <gguf_filename> → prints quant label (e.g. "Q4_K_M")
# ---------------------------------------------------------------------------
function guess_quant_type() {
local name
name=$(basename "$1" .gguf)
local quant
quant=$(grep -oP '(?i)(Q\d+_K_[A-Z]+|Q\d+_\d+|F16|F32|BF16|IQ\d+_[A-Z0-9]+|UD-[A-Z0-9_]+)' <<<"$name" | head -1) || true
echo "${quant:-unknown}"
}
# ---------------------------------------------------------------------------
# build_config_blob <gguf_path> → prints digest; writes config blob JSON to blob store
# The config blob mirrors what `ollama pull` produces.
# ---------------------------------------------------------------------------
function build_config_blob() {
local gguf_path="$1"
local gguf_name family quant_type content
gguf_name=$(basename "$gguf_path" .gguf)
family=$(guess_model_family "$gguf_path")
quant_type=$(guess_quant_type "$gguf_path")
content=$(jq --null-input \
--arg mf "gguf" \
--arg fam "$family" \
--arg ft "$quant_type" \
--arg arch "amd64" \
--arg os "linux" \
'{
model_format: $mf,
model_family: $fam,
model_families: [$fam],
model_type: $ft,
file_type: $ft,
architecture: $arch,
os: $os,
rootfs: { type: "layers", diff_ids: [] }
}')
write_text_blob "$content"
}
# ---------------------------------------------------------------------------
# map_lms_fields <json_fields_array> → prints Ollama params JSON
# Handles llm.prediction.* key schema with {"checked": bool, "value": ...} wrappers.
# ---------------------------------------------------------------------------
function map_lms_fields() {
local fields_json="$1"
jq --null-input --argjson fields "$fields_json" '
# Build a key→value lookup from the fields array
(reduce $fields[] as $f ({}; . + { ($f.key): $f.value })) as $m |
# Helper: emit a field only when checked==true (or no wrapper)
def checked(k):
if $m[k] | type == "object" and has("checked")
then (if $m[k].checked then $m[k].value else null end)
else $m[k]
end;
# Helper: raw (always-emit) scalar
def raw(k): $m[k] // null;
{
temperature: (raw("llm.prediction.temperature") // null),
top_k: (raw("llm.prediction.topKSampling") // null),
top_p: (checked("llm.prediction.topPSampling") // null),
min_p: (checked("llm.prediction.minPSampling") // null),
repeat_penalty: (checked("llm.prediction.repeatPenalty") // null),
num_ctx: (raw("llm.load.contextLength") // null),
num_parallel: (raw("llm.load.numParallelSessions") // null),
num_thread: (raw("llm.load.llama.cpuThreadPoolSize") //
raw("llm.prediction.llama.cpuThreads") // null),
system: (if (raw("llm.prediction.systemPrompt") // "") != ""
then raw("llm.prediction.systemPrompt") else null end)
} | with_entries(select(.value != null))
'
}
# ---------------------------------------------------------------------------
# parse_lms_user_config <publisher> <model_gguf_name> → prints params JSON (or "{}")
# Reads ~/.lmstudio/.internal/user-concrete-model-default-config/<pub>/<model>/<gguf>.json
# Warns on unrecognised ext.* / llm.prediction.reasoning.* keys.
# ---------------------------------------------------------------------------
function parse_lms_user_config() {
local publisher="$1"
local model_dir="$2"
local gguf_name="$3"
local cfg_file fields_json params_json
# Config files on gamebox are named <gguf_filename>.gguf.json (the .gguf stays in name).
# Try <name>.gguf.json first, fall back to <name>.json (older convention).
cfg_file="${LMS_CONFIG_DIR}/${publisher}/${model_dir}/${gguf_name}.gguf.json"
if [[ ! -f "$cfg_file" ]]; then
cfg_file="${LMS_CONFIG_DIR}/${publisher}/${model_dir}/${gguf_name}.json"
fi
if [[ ! -f "$cfg_file" ]]; then
log_debug "no per-model config at ${LMS_CONFIG_DIR}/${publisher}/${model_dir}/${gguf_name}.gguf.json"
echo "{}"
return 0
fi
if ! fields_json=$(jq -e '.operation.fields // []' "$cfg_file" 2>/dev/null); then
log_warn "malformed config JSON: $cfg_file"
echo "{}"
return 0
fi
# Warn on unrecognised keys
jq -r '.[].key' <<<"$fields_json" | while IFS= read -r key; do
if [[ "$key" == ext.* || "$key" == llm.prediction.reasoning.* ]]; then
log_warn "unrecognised LM Studio config key (skipped): $key"
elif [[ "$key" == llm.prediction.contextOverflowPolicy ]]; then
log_warn "llm.prediction.contextOverflowPolicy has no Ollama equivalent (skipped)"
fi
done
params_json=$(map_lms_fields "$fields_json")
echo "$params_json"
}
# ---------------------------------------------------------------------------
# parse_lms_preset <preset_json_path> → prints params JSON (or "{}")
# ---------------------------------------------------------------------------
function parse_lms_preset() {
local preset_file="$1"
local fields_json params_json
if ! fields_json=$(jq -e '.operation.fields // []' "$preset_file" 2>/dev/null); then
log_warn "malformed preset JSON: $preset_file"
echo "{}"
return 0
fi
# Warn on unrecognised keys
jq -r '.[].key' <<<"$fields_json" | while IFS= read -r key; do
if [[ "$key" == ext.* || "$key" == llm.prediction.reasoning.* ]]; then
log_warn "unrecognised preset key (skipped): $key"
elif [[ "$key" == llm.prediction.contextOverflowPolicy ]]; then
log_warn "llm.prediction.contextOverflowPolicy has no Ollama equivalent (skipped)"
fi
done
params_json=$(map_lms_fields "$fields_json")
echo "$params_json"
}
# ---------------------------------------------------------------------------
# build_params_blob <params_json> → prints digest (or "" if params empty)
# ---------------------------------------------------------------------------
function build_params_blob() {
local params_json="$1"
# If params are empty object, skip
if [[ "$(jq 'length' <<<"$params_json")" == "0" ]]; then
echo ""
return 0
fi
write_text_blob "$params_json"
}
# ---------------------------------------------------------------------------
# write_manifest <publisher> <model_tag> <tag> <model_digest> <model_size>
# [<params_digest> <params_size>]
# Writes Docker-v2 manifest JSON to manifests/registry.ollama.ai/<pub>/<model>/<tag>
# Also writes the config blob.
# ---------------------------------------------------------------------------
function write_manifest() {
local publisher="$1"
local model_name="$2"
local tag="$3"
local model_digest="$4"
local model_size="$5"
local params_digest="${6:-}"
local params_size="${7:-}"
local gguf_path="${8:-}"
local manifest_dir manifest_path config_digest config_size manifest_json
manifest_dir="${OLLAMA_MODELS_DIR}/manifests/registry.ollama.ai/${publisher}/${model_name}"
manifest_path="${manifest_dir}/${tag}"
if [[ -f "$manifest_path" ]] && (( FORCE == 0 )); then
log_info "manifest already exists, skipping: ${publisher}/${model_name}:${tag}"
return 0
fi
# Build config blob
config_digest=$(build_config_blob "${gguf_path:-${model_name}.gguf}")
if [[ -z "$config_digest" ]]; then
log_warn "could not build config blob for ${publisher}/${model_name}:${tag}"
return 1
fi
config_size=$(blob_size "$config_digest")
# Build layers array
local layers_json
layers_json=$(jq --null-input \
--arg model_digest "sha256:${model_digest}" \
--argjson model_size "$model_size" \
'[{
mediaType: "application/vnd.ollama.image.model",
digest: $model_digest,
size: $model_size
}]')
if [[ -n "$params_digest" && -n "$params_size" ]]; then
layers_json=$(jq \
--arg params_digest "sha256:${params_digest}" \
--argjson params_size "$params_size" \
'. + [{
mediaType: "application/vnd.ollama.image.params",
digest: $params_digest,
size: $params_size
}]' <<<"$layers_json")
fi
manifest_json=$(jq --null-input \
--argjson layers "$layers_json" \
--arg config_digest "sha256:${config_digest}" \
--argjson config_size "$config_size" \
'{
schemaVersion: 2,
mediaType: "application/vnd.docker.distribution.manifest.v2+json",
config: {
mediaType: "application/vnd.ollama.image.config",
digest: $config_digest,
size: $config_size
},
layers: $layers
}')
if (( DRY_RUN == 1 )); then
log_dry "would write manifest: registry.ollama.ai/${publisher}/${model_name}:${tag}"
log_dry " model blob: sha256:${model_digest:0:12}... (${model_size} bytes)"
[[ -n "$params_digest" ]] && log_dry " params blob: sha256:${params_digest:0:12}... (${params_size} bytes)"
return 0
fi
mkdir --parents "$manifest_dir"
printf '%s\n' "$manifest_json" >"$manifest_path"
log_info "wrote manifest: registry.ollama.ai/${publisher}/${model_name}:${tag}"
}
# ---------------------------------------------------------------------------
# select_quant <model_key> <gguf_paths_array_name>
# Prints chosen gguf path(s) (newline-separated).
# With --all-quants prints all; otherwise prompts interactively (or auto if single).
# ---------------------------------------------------------------------------
function select_quant() {
local model_key="$1"
local -n _sq_paths="$2"
local count="${#_sq_paths[@]}"
if (( count == 1 )); then
echo "${_sq_paths[0]}"
return 0
fi
if (( ALL_QUANTS == 1 )); then
printf '%s\n' "${_sq_paths[@]}"
return 0
fi
# Interactive prompt
printf '\n[SELECT] Model "%s" has %d quantization variants:\n' "$model_key" "$count" >&2
local i
for (( i=0; i<count; i++ )); do
printf ' [%d] %s\n' "$(( i+1 ))" "$(basename "${_sq_paths[$i]}")" >&2
done
printf ' [a] Import ALL variants\n' >&2
printf 'Choice [1-%d/a]: ' "$count" >&2
local choice
read -r choice
if [[ "$choice" == "a" || "$choice" == "A" ]]; then
printf '%s\n' "${_sq_paths[@]}"
return 0
fi
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= count )); then
echo "${_sq_paths[$(( choice-1 ))]}"
return 0
fi
log_err "invalid selection '$choice' for model $model_key"
return 1
}
# ---------------------------------------------------------------------------
# ingest_model <publisher> <model_dir_name> <gguf_path>
# Full pipeline: ingest blob → parse config → build params blob → write manifest
# ---------------------------------------------------------------------------
function ingest_model() {
local publisher="$1"
local model_dir_name="$2"
local gguf_path="$3"
local gguf_name tag model_digest model_size params_json params_digest params_size
gguf_name=$(basename "$gguf_path" .gguf)
tag=$(guess_quant_type "$gguf_path")
[[ "$tag" == "unknown" ]] && tag="latest"
log_info "processing: ${publisher}/${model_dir_name}:${tag} ($(basename "$gguf_path"))"
model_digest=$(ingest_blob "$gguf_path")
if [[ -z "$model_digest" ]]; then
log_warn "skipping ${publisher}/${model_dir_name}: could not ingest blob"
return 0
fi
model_size=$(blob_size "$model_digest")
# Parse LM Studio per-model config
params_json=$(parse_lms_user_config "$publisher" "$model_dir_name" "$gguf_name")
params_digest=$(build_params_blob "$params_json")
params_size=""
[[ -n "$params_digest" ]] && params_size=$(blob_size "$params_digest")
write_manifest "$publisher" "$model_dir_name" "$tag" \
"$model_digest" "$model_size" \
"$params_digest" "$params_size" \
"$gguf_path"
}
# ---------------------------------------------------------------------------
# discover_models → discovers GGUF files from LMS_MODELS_DIR, groups by parent dir
# Calls ingest_model for each selected GGUF.
# ---------------------------------------------------------------------------
function discover_models() {
log_info "scanning LM Studio models dir: ${LMS_MODELS_DIR}"
# Collect all GGUFs into a temp file, then group by parent directory.
# We use a tmpfile of NUL-terminated paths so filenames with spaces/newlines are safe.
local tmpfile=""
tmpfile=$(mktemp -t lms2ollama.XXXXXX)
# shellcheck disable=SC2064
trap "rm -f '${tmpfile}'" RETURN
find "$LMS_MODELS_DIR" -name "*.gguf" -type f -print0 2>/dev/null >"$tmpfile"
# Collect unique parent directories
declare -A dir_seen
local gguf_file parent_dir
while IFS= read -r -d '' gguf_file; do
parent_dir=$(dirname "$gguf_file")
dir_seen["$parent_dir"]=1
done <"$tmpfile"
if [[ "${#dir_seen[@]}" -eq 0 ]]; then
log_info "no GGUF files found in ${LMS_MODELS_DIR}"
return 0
fi
for parent_dir in "${!dir_seen[@]}"; do
# Build gguf_paths array for this parent dir by re-scanning tmpfile
local -a gguf_paths=()
while IFS= read -r -d '' gguf_file; do
[[ "$(dirname "$gguf_file")" == "$parent_dir" ]] && gguf_paths+=("$gguf_file")
done <"$tmpfile"
# Derive publisher/model from path relative to LMS_MODELS_DIR
local rel_path publisher model_dir_name
rel_path="${parent_dir#"${LMS_MODELS_DIR}/"}"
publisher=$(cut -d'/' -f1 <<<"$rel_path")
model_dir_name=$(cut -d'/' -f2- <<<"$rel_path")
[[ -z "$model_dir_name" ]] && model_dir_name="$publisher" && publisher="local"
local model_key="${publisher}/${model_dir_name}"
local -a selected_paths=()
local chosen
# Select quant(s)
while IFS= read -r chosen; do
[[ -n "$chosen" ]] && selected_paths+=("$chosen")
done < <(select_quant "$model_key" gguf_paths)
for gguf_file in "${selected_paths[@]}"; do
ingest_model "$publisher" "$model_dir_name" "$gguf_file"
done
unset gguf_paths selected_paths
done
}
# ---------------------------------------------------------------------------
# remediate_existing → converts type:local manifests to proper Docker-v2 manifests
# ---------------------------------------------------------------------------
function remediate_existing() {
log_info "scanning for non-standard type:local manifests to remediate..."
local manifest_file model_type
# Only scan flat manifests/*.json (not registry.ollama.ai subdirs)
for manifest_file in "${OLLAMA_MODELS_DIR}/manifests/"*.json; do
[[ -f "$manifest_file" ]] || continue
# Check if this is a type:local manifest
model_type=$(jq -r '.type // ""' "$manifest_file" 2>/dev/null) || continue
[[ "$model_type" != "local" ]] && continue
log_info "remediating: $(basename "$manifest_file")"
local model_name publisher model_dir_name gguf_path gguf_abs
model_name=$(jq -r '.name // ""' "$manifest_file")
if [[ -z "$model_name" ]]; then
log_warn "type:local manifest missing .name field: $manifest_file"
continue
fi
# Extract GGUF blob path from the manifest's blobs array
gguf_path=$(jq -r '[.blobs[] | select(.path | test("\\.gguf$"))] | first | .path // ""' "$manifest_file")
if [[ -z "$gguf_path" ]]; then
log_warn "no GGUF blob path in manifest: $manifest_file"
continue
fi
# Path may be relative to OLLAMA_MODELS_DIR parent or absolute
if [[ "$gguf_path" == /* ]]; then
gguf_abs="$gguf_path"
else
gguf_abs="${OLLAMA_MODELS_DIR}/${gguf_path}"
fi
# Also try blobs/<name>/<name>.gguf pattern
if [[ ! -f "$gguf_abs" ]]; then
local alt_path
alt_path="${OLLAMA_MODELS_DIR}/blobs/${model_name}/${model_name}.gguf"
if [[ -f "$alt_path" ]]; then
gguf_abs="$alt_path"
else
log_warn "GGUF file not found for $model_name (tried $gguf_abs)"
continue
fi
fi
if [[ ! -s "$gguf_abs" ]]; then
log_warn "GGUF file is empty or missing: $gguf_abs"
continue
fi
# Try to derive publisher/model from original_folder metadata
local original_folder
original_folder=$(jq -r '.metadata.original_folder // ""' "$manifest_file")
if [[ -n "$original_folder" ]]; then
# e.g. "~/.lmstudio/models/unsloth/gemma-4-E4B-it-GGUF"
original_folder="${original_folder/#\~/$HOME}"
publisher=$(basename "$(dirname "$original_folder")")
model_dir_name=$(basename "$original_folder")
else
publisher="local"
model_dir_name="$model_name"
fi
ingest_model "$publisher" "$model_dir_name" "$gguf_abs"
if (( CLEANUP == 1 )) && (( DRY_RUN == 0 )); then
rm --force "$manifest_file"
log_info "removed original non-standard manifest: $(basename "$manifest_file")"
fi
done
}
# ---------------------------------------------------------------------------
# discover_presets → iterates *.preset.json, applies each to all migrated models
# ---------------------------------------------------------------------------
function discover_presets() {
log_info "scanning LM Studio config presets: ${LMS_PRESETS_DIR}"
local preset_file preset_name
local -a preset_files=()
for preset_file in "${LMS_PRESETS_DIR}"/*.preset.json; do
[[ -f "$preset_file" ]] && preset_files+=("$preset_file")
done
if [[ "${#preset_files[@]}" -eq 0 ]]; then
log_info "no *.preset.json files found in ${LMS_PRESETS_DIR}"
return 0
fi
# Collect all manifests already written under registry.ollama.ai
local -a manifest_paths=()
while IFS= read -r -d '' mf; do
manifest_paths+=("$mf")
done < <(find "${OLLAMA_MODELS_DIR}/manifests/registry.ollama.ai" -type f -print0 2>/dev/null)
if [[ "${#manifest_paths[@]}" -eq 0 ]]; then
log_warn "no models in Ollama manifest store yet; presets cannot be applied"
return 0
fi
for preset_file in "${preset_files[@]}"; do
preset_name=$(jq -r '.name // ""' "$preset_file" 2>/dev/null)
if [[ -z "$preset_name" ]]; then
log_warn "preset missing .name field: $preset_file"
continue
fi
# Normalise tag: replace spaces with hyphens, lowercase
local preset_tag
preset_tag=$(tr ' ' '-' <<<"$preset_name" | tr '[:upper:]' '[:lower:]')
log_info "applying preset '${preset_name}' (tag: ${preset_tag}) to all models"
local params_json params_digest params_size
params_json=$(parse_lms_preset "$preset_file")
params_digest=$(build_params_blob "$params_json")
params_size=""
[[ -n "$params_digest" ]] && params_size=$(blob_size "$params_digest")
# Apply preset to every existing model manifest
local mf_path publisher model_name model_digest manifest_json
for mf_path in "${manifest_paths[@]}"; do
# Path: .../registry.ollama.ai/<publisher>/<model>/<tag>
local rel="${mf_path#"${OLLAMA_MODELS_DIR}/manifests/registry.ollama.ai/"}"
publisher=$(cut -d'/' -f1 <<<"$rel")
model_name=$(cut -d'/' -f2 <<<"$rel")
# Extract model blob digest from existing manifest
manifest_json=$(cat "$mf_path")
model_digest=$(jq -r '
[.layers[] | select(.mediaType == "application/vnd.ollama.image.model")] |
first | .digest | ltrimstr("sha256:")
' <<<"$manifest_json" 2>/dev/null) || continue
[[ -z "$model_digest" || "$model_digest" == "null" ]] && continue
local model_size
model_size=$(jq -r '
[.layers[] | select(.mediaType == "application/vnd.ollama.image.model")] |
first | .size
' <<<"$manifest_json" 2>/dev/null) || continue
write_manifest "$publisher" "$model_name" "$preset_tag" \
"$model_digest" "$model_size" \
"$params_digest" "$params_size" \
"${model_name}.gguf"
done
done
}
# ---------------------------------------------------------------------------
# verify → run ollama list advisory
# ---------------------------------------------------------------------------
function verify() {
if ! command -v ollama &>/dev/null; then
log_warn "ollama binary not in PATH; skipping verification"
return 0
fi
log_info "running: ollama list"
if ! ollama list 2>/dev/null; then
log_warn "ollama service is not running; models will be visible once service starts"
fi
}
# ---------------------------------------------------------------------------
# parse_args
# ---------------------------------------------------------------------------
function parse_args() {
while (( $# > 0 )); do
case "$1" in
-h|--help)
usage
exit 0
;;
-d|--debug)
DEBUG=1
set -x
shift
;;
-n|--dry-run)
DRY_RUN=1
shift
;;
-f|--force)
FORCE=1
shift
;;
--all-quants)
ALL_QUANTS=1
shift
;;
--skip-remediate)
SKIP_REMEDIATE=1
shift
;;
--cleanup)
CLEANUP=1
shift
;;
--lms-dir)
LMS_MODELS_DIR="$2"
shift 2
;;
--presets-dir)
LMS_PRESETS_DIR="$2"
shift 2
;;
--ollama-dir)
OLLAMA_MODELS_DIR="$2"
shift 2
;;
--lms-config-dir)
LMS_CONFIG_DIR="$2"
shift 2
;;
*)
log_err "unknown argument: $1"
usage
exit 1
;;
esac
done
}
# ---------------------------------------------------------------------------
# validate_dirs → expand tildes, create Ollama dirs if needed
# ---------------------------------------------------------------------------
function validate_dirs() {
# Expand any remaining ~ references (paths set via flags won't auto-expand)
LMS_MODELS_DIR="${LMS_MODELS_DIR/#\~/$HOME}"
LMS_PRESETS_DIR="${LMS_PRESETS_DIR/#\~/$HOME}"
LMS_CONFIG_DIR="${LMS_CONFIG_DIR/#\~/$HOME}"
OLLAMA_MODELS_DIR="${OLLAMA_MODELS_DIR/#\~/$HOME}"
if (( DRY_RUN == 0 )); then
mkdir --parents "${OLLAMA_MODELS_DIR}/blobs"
mkdir --parents "${OLLAMA_MODELS_DIR}/manifests/registry.ollama.ai"
fi
}
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
function main() {
parse_args "$@"
check_deps
validate_dirs
(( DRY_RUN == 1 )) && log_info "DRY-RUN mode: no files will be written"
if (( SKIP_REMEDIATE == 0 )); then
remediate_existing
fi
discover_models
discover_presets
verify
log_info "migration complete"
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment