Skip to content

Commit

Permalink
Add support for http, https and file pulls
Browse files Browse the repository at this point in the history
Summary by Sourcery

Add support for pulling models using http, https, and file URLs, enabling direct
 execution of models from web or local sources. Update documentation and add tes
ts to cover the new functionality.

New Features:

    Add support for pulling models using http, https, and file URLs, allowing mo
dels to be run directly from web sources or local files.

Enhancements:

    Refactor the symlink creation process to use os.symlink directly instead of
run_cmd.

Documentation:

    Update documentation to include new URL syntax support for http, https, and
file protocols, explaining how models can be pulled from these sources.

Tests:

    Add system tests to verify the functionality of pulling models using file an
d https URLs, ensuring they can be listed and removed correctly.

Signed-off-by: Daniel J Walsh <[email protected]>
  • Loading branch information
rhatdan committed Nov 21, 2024
1 parent b6c9492 commit 618259b
Show file tree
Hide file tree
Showing 9 changed files with 146 additions and 41 deletions.
22 changes: 11 additions & 11 deletions bin/ramalama
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,15 @@ 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)

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:
Expand All @@ -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)
Expand All @@ -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__":
Expand Down
10 changes: 9 additions & 1 deletion docs/ramalama.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 25 additions & 4 deletions ramalama/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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":
Expand Down
17 changes: 6 additions & 11 deletions ramalama/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """\
Expand Down Expand Up @@ -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):
Expand Down
17 changes: 10 additions & 7 deletions ramalama/oci.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}"

Expand All @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions ramalama/url.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 6 additions & 6 deletions test/system/010-list.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand All @@ -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" ""
}

Expand Down
33 changes: 33 additions & 0 deletions test/system/050-pull.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/system/helpers.podman.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

##############
Expand Down

0 comments on commit 618259b

Please sign in to comment.