Created
June 20, 2026 00:25
-
-
Save devopsec/3a8661e81d9af0837d6e19e994f0ce4c to your computer and use it in GitHub Desktop.
Migrates LM Studio models and parameters to Ollama
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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