diff --git a/bin/ramalama b/bin/ramalama index 475f2c7c..ef2f67e2 100755 --- a/bin/ramalama +++ b/bin/ramalama @@ -36,7 +36,7 @@ def add_site_packages_to_syspath(base_path): sys.path.insert(0, path) def main(args): - sharedirs = ["./", "/opt/homebrew/share/ramalama", "/usr/local/share/ramalama", "/usr/share/ramalama"] + sharedirs = ["/opt/homebrew/share/ramalama", "/usr/local/share/ramalama", "/usr/share/ramalama"] syspath = next((d for d in sharedirs if os.path.exists(d+"/ramalama/cli.py")), None) if syspath: sys.path.insert(0, syspath) @@ -44,6 +44,7 @@ def main(args): add_site_packages_to_syspath('~/.local/pipx/venvs/*') add_site_packages_to_syspath('/usr/local') add_pipx_venvs_bin_to_path() + sys.path.insert(0, './') try: import ramalama except: @@ -62,6 +63,10 @@ def main(args): if args.version: return ramalama.print_version(args) + def eprint(e, exit_code): + ramalama.perror("Error: " + str(e).strip("'\"")) + sys.exit(exit_code) + # Process CLI try: args.func(args) @@ -73,22 +78,17 @@ def main(args): if args.debug: raise except IndexError as e: - ramalama.perror("Error: " + str(e).strip("'")) - sys.exit(errno.EINVAL) + eprint(e, errno.EINVAL) except KeyError as e: - ramalama.perror("Error: " + str(e).strip("'")) - sys.exit(1) + eprint(e, 1) except NotImplementedError as e: - ramalama.perror("Error: " + str(e).strip("'")) - sys.exit(errno.ENOTSUP) + eprint(e, errno.ENOTSUP) except subprocess.CalledProcessError as e: - ramalama.perror("Error: " + str(e).strip("'")) - sys.exit(e.returncode) + eprint(e, e.returncode) except KeyboardInterrupt: sys.exit(0) except ValueError as e: - ramalama.perror("Error: " + str(e).strip("'")) - sys.exit(errno.EINVAL) + eprint(e, errno.EINVAL) if __name__ == "__main__": diff --git a/docs/ramalama.1.md b/docs/ramalama.1.md index a7bbaf58..02859b4e 100644 --- a/docs/ramalama.1.md +++ b/docs/ramalama.1.md @@ -40,13 +40,21 @@ RamaLama supports multiple AI model registries types called transports. Supporte | OCI Container Registries | [`opencontainers.org`](https://opencontainers.org)| ||Examples: [`quay.io`](https://quay.io), [`Docker Hub`](https://docker.io), and [`Artifactory`](https://artifactory.com)| +RamaLama can also pull directly using URL syntax. + +http://, https:// and file://. + +This means if a model is on a web site or even on your local system, you can run it directly. + RamaLama uses the Ollama registry transport by default. The default can be overridden in the ramalama.conf file or use the RAMALAMA_TRANSPORTS environment. `export RAMALAMA_TRANSPORT=huggingface` Changes RamaLama to use huggingface transport. -Individual model transports can be modifies when specifying a model via the `huggingface://`, `oci://`, or `ollama://` prefix. +Individual model transports can be modifies when specifying a model via the `huggingface://`, `oci://`, `ollama://`, `https://`, `http://`, `file://` prefix. ramalama pull `huggingface://`afrideva/Tiny-Vicuna-1B-GGUF/tiny-vicuna-1b.q2_k.gguf +ramalama run `file://`$HOME/granite-7b-lab-Q4_K_M.gguf + To make it easier for users, RamaLama uses shortname files, which container alias names for fully specified AI Models allowing users to specify the shorter names when referring to models. RamaLama reads shortnames.conf files if they diff --git a/ramalama/cli.py b/ramalama/cli.py index 60742c44..d77c65d7 100644 --- a/ramalama/cli.py +++ b/ramalama/cli.py @@ -18,6 +18,7 @@ from ramalama.model import model_types from ramalama.oci import OCI from ramalama.ollama import Ollama +from ramalama.url import URL from ramalama.shortnames import Shortnames from ramalama.toml_parser import TOMLParser from ramalama.version import version, print_version @@ -351,7 +352,17 @@ def human_duration(d): def list_files_by_modification(): - return sorted(Path().rglob("*"), key=lambda p: os.path.getmtime(p), reverse=True) + paths = Path().rglob("*") + models = [] + for path in paths: + if str(path).startswith("file/"): + if not os.path.exists(str(path)): + path = str(path).replace("file/", "file:///") + perror(f"{path} does not exist") + continue + models.append(path) + + return sorted(models, key=lambda p: os.path.getmtime(p), reverse=True) def containers_parser(subparsers): @@ -431,7 +442,10 @@ def _list_models(args): # Collect model data for path in list_files_by_modification(): if path.is_symlink(): - name = str(path).replace("/", "://", 1) + if str(path).startswith("file/"): + name = str(path).replace("/", ":///", 1) + else: + name = str(path).replace("/", "://", 1) file_epoch = path.lstat().st_mtime modified = int(time.time() - file_epoch) size = get_size(path) @@ -727,12 +741,17 @@ def _rm_model(models, args): m = New(model, args) m.remove(args) except KeyError as e: + for prefix in model_types: + if model.startswith(prefix + "://"): + if not args.ignore: + raise e try: # attempt to remove as a container image m = OCI(model, config.get('engine', container_manager())) - m.remove(args) + m.remove(args, ignore_stderr=True) except Exception: - raise e + if not args.ignore: + raise e def rm_cli(args): @@ -774,6 +793,8 @@ def New(model, args): return Ollama(model) if model.startswith("oci://") or model.startswith("docker://"): return OCI(model, args.engine) + if model.startswith("http://") or model.startswith("https://") or model.startswith("file://"): + return URL(model) transport = config.get("transport", "ollama") if transport == "huggingface": diff --git a/ramalama/model.py b/ramalama/model.py index 5ee217bc..0bf145a6 100644 --- a/ramalama/model.py +++ b/ramalama/model.py @@ -17,7 +17,7 @@ from ramalama.kube import Kube from ramalama.common import mnt_dir, mnt_file -model_types = ["oci", "huggingface", "hf", "ollama"] +model_types = ["file", "https", "http", "oci", "huggingface", "hf", "ollama"] file_not_found = """\ @@ -87,17 +87,12 @@ def garbage_collection(self, args): def remove(self, args): model_path = self.model_path(args) - if os.path.exists(model_path): - try: - os.remove(model_path) - print(f"Untagged: {self.model}") - except OSError as e: - if not args.ignore: - raise KeyError(f"removing {self.model}: {e}") - else: + try: + os.remove(model_path) + print(f"Untagged: {self.model}") + except OSError as e: if not args.ignore: - raise KeyError(f"model {self.model} not found") - + raise KeyError(f"removing {self.model}: {e}") self.garbage_collection(args) def _image(self, args): diff --git a/ramalama/oci.py b/ramalama/oci.py index d6ce62dd..3281ff91 100644 --- a/ramalama/oci.py +++ b/ramalama/oci.py @@ -165,13 +165,15 @@ def pull(self, args): pass return self._pull_omlmd(args) - def _pull_omlmd(self, args): + def _registry_reference(self): try: registry, reference = self.model.split("/", 1) + return registry, reference except Exception: - registry = "docker.io" - reference = self.model + return "docker.io", self.model + def _pull_omlmd(self, args): + registry, reference = self._registry_reference() reference_dir = reference.replace(":", "/") outdir = f"{args.store}/repos/oci/{registry}/{reference_dir}" # note: in the current way RamaLama is designed, cannot do Helper(OMLMDRegistry()).pull(target, outdir) @@ -193,7 +195,7 @@ def _pull_omlmd(self, args): return model_path def model_path(self, args): - registry, reference = self.model.split("/", 1) + registry, reference = self._registry_reference() reference_dir = reference.replace(":", "/") path = f"{args.store}/models/oci/{registry}/{reference_dir}" @@ -206,15 +208,16 @@ def model_path(self, args): return f"{path}/{ggufs[0]}" - def remove(self, args): + def remove(self, args, ignore_stderr=False): try: super().remove(args) + return except FileNotFoundError: pass if self.conman is not None: - conman_args = [self.conman, "rmi", "--force", self.model] - exec_cmd(conman_args, debug=args.debug) + conman_args = [self.conman, "rmi", f"--force={args.ignore}", self.model] + run_cmd(conman_args, debug=args.debug, ignore_stderr=ignore_stderr) def exists(self, args): try: diff --git a/ramalama/url.py b/ramalama/url.py new file mode 100644 index 00000000..8e11dfe4 --- /dev/null +++ b/ramalama/url.py @@ -0,0 +1,45 @@ +import os +from ramalama.common import download_file +from ramalama.model import Model + + +class URL(Model): + def __init__(self, model): + self.type = "" + for prefix in ["file", "http", "https"]: + if model.startswith(f"{prefix}://"): + self.type = prefix + model = model.removeprefix(f"{prefix}://") + break + + super().__init__(model) + split = self.model.rsplit("/", 1) + self.directory = split[0].removeprefix("/") if len(split) > 1 else "" + self.filename = split[1] if len(split) > 1 else split[0] + + def pull(self, args): + model_path = self.model_path(args) + directory_path = os.path.join(args.store, "repos", self.type, self.directory, self.filename) + os.makedirs(directory_path, exist_ok=True) + + symlink_dir = os.path.dirname(model_path) + os.makedirs(symlink_dir, exist_ok=True) + + target_path = os.path.join(directory_path, self.filename) + + if self.type == "file": + if not os.path.exists(self.model): + raise FileNotFoundError(f"{self.model} no such file") + os.symlink(self.model, os.path.join(symlink_dir, self.filename)) + os.symlink(self.model, target_path) + else: + url = self.type + "://" + self.model + # Download the model file to the target path + download_file(url, target_path, headers={}, show_progress=True) + relative_target_path = os.path.relpath(target_path, start=os.path.dirname(model_path)) + if self.check_valid_model_path(relative_target_path, model_path): + # Symlink is already correct, no need to update it + return model_path + os.symlink(relative_target_path, model_path) + + return model_path diff --git a/test/system/010-list.bats b/test/system/010-list.bats index 6701dfe6..fbbe03ea 100644 --- a/test/system/010-list.bats +++ b/test/system/010-list.bats @@ -36,9 +36,9 @@ size | [0-9]\\\+ run_ramalama list --json while read field expect; do - actual=$(echo "$output" | jq -r ".[0].$field") - dprint "# actual=<$actual> expect=<$expect}>" - is "$actual" "$expect" "jq .$field" + actual=$(echo "$output" | jq -r ".[0].$field") + dprint "# actual=<$actual> expect=<$expect}>" + is "$actual" "$expect" "jq .$field" done < <(parse_table "$tests") } @@ -51,9 +51,9 @@ size | [0-9]\\\+ @test "ramalama rm --ignore" { random_image_name=i_$(safename) - run_ramalama 1 rm $random_image_name - is "$output" "Error: model $random_image_name not found.*" - run_ramalama rm --ignore $random_image_name + run_ramalama 1 rm ${random_image_name} + is "$output" "Error: removing ${random_image_name}: \[Errno 2\] No such file or directory:.*" + run_ramalama rm --ignore ${random_image_name} is "$output" "" } diff --git a/test/system/050-pull.bats b/test/system/050-pull.bats index d24d8c1d..6fd65019 100644 --- a/test/system/050-pull.bats +++ b/test/system/050-pull.bats @@ -65,6 +65,39 @@ load setup_suite run_ramalama rm oci://quay.io/mmortari/gguf-py-example:v1 } +@test "ramalama URL" { + model=$RAMALAMA_TMPDIR/mymodel.gguf + touch $model + file_url=file://${model} + https_url=https://github.com/containers/ramalama/blob/main/README.md + + for url in $file_url $https_url; do + run_ramalama pull $url + run_ramalama list + is "$output" ".*$url" "URL exists" + run_ramalama rm $url + run_ramalama list + assert "$output" !~ ".*$url" "URL no longer exists" + done +} + +@test "ramalama file URL" { + model=$RAMALAMA_TMPDIR/mymodel.gguf + touch $model + url=file://${model} + + run_ramalama pull $url + run_ramalama list + is "$output" ".*$url" "URL exists" + # test if model is removed, nothing blows up + rm ${model} + run_ramalama list + is "$output" ".*$url does not exist" "URL exists" + run_ramalama rm $url + run_ramalama list + assert "$output" !~ ".*$url" "URL no longer exists" +} + @test "ramalama use registry" { skip_if_darwin skip_if_docker diff --git a/test/system/helpers.podman.bash b/test/system/helpers.podman.bash index 3d25247b..5252855d 100644 --- a/test/system/helpers.podman.bash +++ b/test/system/helpers.podman.bash @@ -1114,7 +1114,7 @@ function parse_table() { function random_string() { local length=${1:-10} - head /dev/urandom | tr -dc a-zA-Z0-9 | head -c$length + head /dev/urandom | LC_ALL=C tr -dc a-zA-Z0-9 | head -c$length } ##############