Skip to content

Commit 7f55115

Browse files
committed
Add support for converting to OCI artifacts
Signed-off-by: Daniel J Walsh <[email protected]>
1 parent d5735e2 commit 7f55115

File tree

9 files changed

+833
-55
lines changed

9 files changed

+833
-55
lines changed

docs/ramalama-convert.1.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,15 @@ Print usage message
2626
#### **--network**=*none*
2727
sets the configuration for network namespaces when handling RUN instructions
2828

29-
#### **--type**=*raw* | *car*
29+
#### **--type**="artifact" | *raw* | *car*
3030

31-
type of OCI Model Image to convert.
31+
Convert the MODEL to the specified OCI Object
3232

33-
| Type | Description |
34-
| ---- | ------------------------------------------------------------- |
35-
| car | Includes base image with the model stored in a /models subdir |
36-
| raw | Only the model and a link file model.file to it stored at / |
33+
| Type | Description |
34+
| -------- | ------------------------------------------------------------- |
35+
| artifact | Store AI Models as artifacts |
36+
| car | Traditional OCI image including base image with the model stored in a /models subdir |
37+
| raw | Traditional OCI image including only the model and a link file `model.file` pointed at it stored at / |
3738

3839
## EXAMPLE
3940

docs/ramalama.conf

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@
3232
#
3333
#carimage = "registry.access.redhat.com/ubi10-micro:latest"
3434

35+
# Convert the MODEL to the specified OCI Object
36+
# Options: artifact, car, raw
37+
#
38+
# artifact: Store AI Models as artifacts
39+
# car: Traditional OCI image including base image with the model stored in a /models subdir
40+
# raw: Traditional OCI image including only the model and a link file `model.file` pointed at it stored at /
41+
#convert_type = "raw"
42+
3543
# Run RamaLama in the default container.
3644
#
3745
#container = true

docs/ramalama.conf.5.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,18 @@ Min chunk size to attempt reusing from the cache via KV shifting
8484
Run RamaLama in the default container.
8585
RAMALAMA_IN_CONTAINER environment variable overrides this field.
8686

87+
#convert_type = "raw"
88+
89+
Convert the MODEL to the specified OCI Object
90+
Options: artifact, car, raw
91+
92+
| Type | Description |
93+
| -------- | ------------------------------------------------------------- |
94+
| artifact | Store AI Models as artifacts |
95+
| car | Traditional OCI image including base image with the model stored in a /models subdir |
96+
| raw | Traditional OCI image including only the model and a link file `model.file` pointed at it stored at / |
97+
98+
8799
**ctx_size**=0
88100

89101
Size of the prompt context (0 = loaded from model)

ramalama/cli.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -702,11 +702,12 @@ def convert_parser(subparsers):
702702
add_network_argument(parser)
703703
parser.add_argument(
704704
"--type",
705-
default="raw",
706-
choices=["car", "raw"],
705+
default=CONFIG.convert_type,
706+
choices=["artifact", "car", "raw"],
707707
help="""\
708708
type of OCI Model Image to push.
709709
710+
Model "artifact" is an OCI artifact.
710711
Model "car" includes base image with the model stored in a /models subdir.
711712
Model "raw" contains the model and a link file model.file to it stored at /.""",
712713
)
@@ -746,11 +747,12 @@ def push_parser(subparsers):
746747
add_network_argument(parser)
747748
parser.add_argument(
748749
"--type",
749-
default="raw",
750-
choices=["car", "raw"],
750+
default=CONFIG.convert_type,
751+
choices=["artifact", "car", "raw"],
751752
help="""\
752753
type of OCI Model Image to push.
753754
755+
Model "artifact" is an OCI artifact.
754756
Model "car" includes base image with the model stored in a /models subdir.
755757
Model "raw" contains the model and a link file model.file to it stored at /.""",
756758
)
@@ -778,13 +780,15 @@ def _get_source_model(args):
778780

779781

780782
def push_cli(args):
781-
source_model = _get_source_model(args)
782783
target = args.SOURCE
783784
if args.TARGET:
785+
source_model = _get_source_model(args)
784786
target = shortnames.resolve(args.TARGET)
785787
if not target:
786788
target = args.TARGET
787789
target_model = New(target, args)
790+
if not args.TARGET:
791+
source_model = target_model
788792

789793
try:
790794
target_model.push(source_model, args)
@@ -1376,7 +1380,11 @@ def _rm_model(models, args):
13761380

13771381
try:
13781382
m = New(model, args)
1379-
m.remove(args)
1383+
# Don't ignore missing so that we attempt OCI as well.
1384+
newargs = args
1385+
newargs.ignore = False
1386+
m.remove(newargs)
1387+
continue
13801388
except KeyError as e:
13811389
for prefix in MODEL_TYPES:
13821390
if model.startswith(prefix + "://"):
@@ -1386,11 +1394,10 @@ def _rm_model(models, args):
13861394
# attempt to remove as a container image
13871395
m = TransportFactory(model, args, ignore_stderr=True).create_oci()
13881396
m.remove(args)
1389-
return
1397+
continue
13901398
except Exception:
1391-
pass
1392-
if not args.ignore:
1393-
raise e
1399+
if not args.ignore:
1400+
raise e
13941401

13951402

13961403
def rm_cli(args):

ramalama/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ class BaseConfig:
120120
carimage: str = "registry.access.redhat.com/ubi10-micro:latest"
121121
container: bool = None # type: ignore
122122
ctx_size: int = 0
123+
convert_type: Literal["artifact", "car", "raw"] = "raw"
123124
default_image: str = DEFAULT_IMAGE
124125
dryrun: bool = False
125126
engine: SUPPORTED_ENGINES | None = field(default_factory=get_default_engine)

ramalama/oci_tools.py

Lines changed: 77 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,66 @@
88
ocilabeltype = "org.containers.type"
99

1010

11-
def engine_supports_manifest_attributes(engine):
11+
def convert_from_human_readable_size(input) -> str:
12+
sizes = [("KB", 1024), ("MB", 1024**2), ("GB", 1024**3), ("TB", 1024**4), ("B", 1)]
13+
for unit, size in sizes:
14+
if input.endswith(unit) or input.endswith(unit.lower()):
15+
return float(input[: -len(unit)]) * size
16+
17+
return str(input)
18+
19+
20+
def list_artifacts(args: EngineArgType):
21+
if args.engine == "docker":
22+
return []
23+
24+
conman_args = [
25+
args.engine,
26+
"artifact",
27+
"ls",
28+
"--format",
29+
(
30+
'{"name":"oci://{{ .Repository }}:{{ .Tag }}",\
31+
"created":"{{ .CreatedAt }}", \
32+
"size":"{{ .Size }}", \
33+
"ID":"{{ .Digest }}"},'
34+
),
35+
]
36+
output = run_cmd(conman_args).stdout.decode("utf-8").strip()
37+
if output == "":
38+
return []
39+
40+
artifacts = json.loads("[" + output[:-1] + "]")
41+
models = []
42+
for artifact in artifacts:
43+
conman_args = [
44+
args.engine,
45+
"artifact",
46+
"inspect",
47+
artifact["ID"],
48+
]
49+
output = run_cmd(conman_args).stdout.decode("utf-8").strip()
50+
51+
if output == "":
52+
continue
53+
inspect = json.loads(output)
54+
if "Manifest" not in inspect:
55+
continue
56+
if "artifactType" not in inspect["Manifest"]:
57+
continue
58+
if inspect["Manifest"]['artifactType'] != annotations.ArtifactTypeModelManifest:
59+
continue
60+
models += [
61+
{
62+
"name": artifact["name"],
63+
"modified": artifact["created"],
64+
"size": convert_from_human_readable_size(artifact["size"]),
65+
}
66+
]
67+
return models
68+
69+
70+
def engine_supports_manifest_attributes(engine) -> bool:
1271
if not engine or engine == "" or engine == "docker":
1372
return False
1473
if engine == "podman" and engine_version(engine) < "5":
@@ -91,26 +150,26 @@ def list_models(args: EngineArgType):
91150
"--format",
92151
formatLine,
93152
]
153+
models = []
94154
output = run_cmd(conman_args).stdout.decode("utf-8").strip()
95-
if output == "":
96-
return []
97-
98-
models = json.loads(f"[{output[:-1]}]")
99-
# exclude dangling images having no tag (i.e. <none>:<none>)
100-
models = [model for model in models if model["name"] != "oci://<none>:<none>"]
101-
102-
# Grab the size from the inspect command
103-
if conman == "docker":
104-
# grab the size from the inspect command
105-
for model in models:
106-
conman_args = [conman, "image", "inspect", model["id"], "--format", "{{.Size}}"]
107-
output = run_cmd(conman_args).stdout.decode("utf-8").strip()
108-
# convert the number value from the string output
109-
model["size"] = int(output)
110-
# drop the id from the model
111-
del model["id"]
155+
if output != "":
156+
models += json.loads(f"[{output[:-1]}]")
157+
# exclude dangling images having no tag (i.e. <none>:<none>)
158+
models = [model for model in models if model["name"] != "oci://<none>:<none>"]
159+
160+
# Grab the size from the inspect command
161+
if conman == "docker":
162+
# grab the size from the inspect command
163+
for model in models:
164+
conman_args = [conman, "image", "inspect", model["id"], "--format", "{{.Size}}"]
165+
output = run_cmd(conman_args).stdout.decode("utf-8").strip()
166+
# convert the number value from the string output
167+
model["size"] = int(output)
168+
# drop the id from the model
169+
del model["id"]
112170

113171
models += list_manifests(args)
172+
models += list_artifacts(args)
114173
for model in models:
115174
# Convert to ISO 8601 format
116175
parsed_date = datetime.fromisoformat(

ramalama/transports/base.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import os
23
import platform
34
import random
@@ -146,6 +147,7 @@ def __init__(self, model: str, model_store_path: str):
146147
self._model_type: str
147148
self._model_name, self._model_tag, self._model_organization = self.extract_model_identifiers()
148149
self._model_type = type(self).__name__.lower()
150+
self.artifact = False
149151

150152
self._model_store_path: str = model_store_path
151153
self._model_store: Optional[ModelStore] = None
@@ -201,6 +203,8 @@ def _get_entry_model_path(self, use_container: bool, should_generate: bool, dry_
201203

202204
if self.model_type == 'oci':
203205
if use_container or should_generate:
206+
if self.artifact:
207+
return os.path.join(MNT_DIR, self.artifact_name())
204208
return os.path.join(MNT_DIR, 'model.file')
205209
else:
206210
return f"oci://{self.model}"
@@ -347,9 +351,10 @@ def exec_model_in_container(self, cmd_args, args):
347351
def setup_mounts(self, args):
348352
if args.dryrun:
349353
return
354+
350355
if self.model_type == 'oci':
351356
if self.engine.use_podman:
352-
mount_cmd = f"--mount=type=image,src={self.model},destination={MNT_DIR},subpath=/models,rw=false"
357+
mount_cmd = self.mount_cmd()
353358
elif self.engine.use_docker:
354359
output_filename = self._get_entry_model_path(args.container, True, args.dryrun)
355360
volume = populate_volume_from_image(self, os.path.basename(output_filename))
@@ -655,40 +660,52 @@ def inspect(
655660
as_json: bool = False,
656661
dryrun: bool = False,
657662
) -> None:
663+
json_out = self.get_inspect(show_all, show_all_metadata, get_field, dryrun)
664+
if as_json:
665+
print(json_out)
666+
else:
667+
print(json.loads(json_out))
668+
669+
def get_inspect(
670+
self,
671+
show_all: bool = False,
672+
show_all_metadata: bool = False,
673+
get_field: str = "",
674+
dryrun: bool = False,
675+
) -> None:
676+
as_json = True
658677
model_name = self.filename
659678
model_registry = self.type.lower()
660-
model_path = self._get_inspect_model_path(dryrun)
661-
679+
model_path = self._get_entry_model_path(False, False, dryrun)
662680
if GGUFInfoParser.is_model_gguf(model_path):
663681
if not show_all_metadata and get_field == "":
664682
gguf_info: GGUFModelInfo = GGUFInfoParser.parse(model_name, model_registry, model_path)
665-
print(gguf_info.serialize(json=as_json, all=show_all))
666-
return
683+
return gguf_info.serialize(json=as_json, all=show_all)
667684

668685
metadata = GGUFInfoParser.parse_metadata(model_path)
669686
if show_all_metadata:
670-
print(metadata.serialize(json=as_json))
671-
return
687+
return metadata.serialize(json=as_json)
672688
elif get_field != "": # If a specific field is requested, print only that field
673689
field_value = metadata.get(get_field)
674690
if field_value is None:
675691
raise KeyError(f"Field '{get_field}' not found in GGUF model metadata")
676-
print(field_value)
677-
return
692+
return field_value
678693

679694
if SafetensorInfoParser.is_model_safetensor(model_name):
680695
safetensor_info: SafetensorModelInfo = SafetensorInfoParser.parse(model_name, model_registry, model_path)
681-
print(safetensor_info.serialize(json=as_json, all=show_all))
682-
return
696+
return safetensor_info.serialize(json=as_json, all=show_all)
683697

684-
print(ModelInfoBase(model_name, model_registry, model_path).serialize(json=as_json))
698+
return ModelInfoBase(model_name, model_registry, model_path).serialize(json=as_json)
685699

686700
def print_pull_message(self, model_name):
687701
model_name = trim_model_name(model_name)
688702
# Write messages to stderr
689703
perror(f"Downloading {model_name} ...")
690704
perror(f"Trying to pull {model_name} ...")
691705

706+
def is_artifact(self) -> bool:
707+
return False
708+
692709

693710
def compute_ports() -> list:
694711
first_port = DEFAULT_PORT_RANGE[0]

0 commit comments

Comments
 (0)