Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 68 additions & 64 deletions docs/model_package.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,91 +12,94 @@ referenced from each variant's configuration with a path scheme.

A model package is a directory containing a top-level `manifest.json`. The package owns a
single component that holds the variants onnxruntime-genai loads. By convention the
component is named `model`. The component directory under `models/` carries a
`metadata.json` enumerating the variants. Each variant has its own directory containing a
complete `genai_config.json` along with the ONNX graph files and any other per-variant
assets.
component is named `model`. The component is declared inline in the manifest, so its
variant directories sit directly at the package root. Each variant has its own directory
containing a complete `genai_config.json` along with the ONNX graph files and any other
per-variant assets. Files shared across variants (such as tokenizer assets) live in a
content-addressed `shared_assets/sha256-<hex>/` directory and are referenced with the
`sha256:` scheme.

```
my-model.ortpackage/
├── manifest.json
├── models/
│ └── model/
│ ├── metadata.json
│ ├── openvino-1/
│ │ ├── genai_config.json
│ │ ├── model.onnx
│ │ └── model.onnx.data
│ └── openvino-2/
│ ├── genai_config.json
│ ├── model.onnx
│ └── model.onnx.data
└── shared/
├── tokenizer.json
└── processor_config.json
├── openvino-1/
│ ├── genai_config.json
│ ├── model.onnx
│ └── model.onnx.data
├── openvino-2/
│ ├── genai_config.json
│ ├── model.onnx
│ └── model.onnx.data
└── shared_assets/
└── sha256-<hex>/
├── tokenizer.json
└── processor_config.json
```

The shape of `manifest.json`, `metadata.json`, and the variant declarations follows the
ONNX Runtime model package specification. The minimum needed for onnxruntime-genai is:
The shape of `manifest.json` and the variant declarations follows the ONNX Runtime model
package specification. The minimum needed for onnxruntime-genai is a single inline
component named `model`:

```jsonc
// manifest.json
{
"schema_version": 1,
"components": ["model"]
}
```

```jsonc
// models/model/metadata.json
{
"component_name": "model",
"variants": {
"openvino-1": {
"ep": "OpenVINOExecutionProvider",
"device": "npu",
"compatibility_string": "<compat-string-1>"
},
"openvino-2": {
"ep": "OpenVINOExecutionProvider",
"device": "npu",
"compatibility_string": "<compat-string-2>"
"schema_version": "1.0",
"components": {
"model": {
"variants": {
"openvino-1": {
"ep": "OpenVINOExecutionProvider",
"device": "npu",
"compatibility_string": "<compat-string-1>"
},
"openvino-2": {
"ep": "OpenVINOExecutionProvider",
"device": "npu",
"compatibility_string": "<compat-string-2>"
}
}
}
}
}
```

Each variant's directory defaults to its name (`openvino-1`, `openvino-2`) relative to the
package root; declare `variant_directory` on a variant to override that.

In this example both variants target the same execution provider but compile for
different hardware or software versions. The OpenVINO EP scores each variant's `compatibility_string` at load time and ORT selects the highest-scoring match — callers do
not need to know which build is best for their machine.

Each variant directory must contain a `genai_config.json` describing the model to
onnxruntime-genai. Variant directories are otherwise self-contained: they may hold the
ONNX graph, external weights, custom op libraries, LoRA adapters, and any other files
specific to that build.

> **Heads-up.** External weight files (`model.onnx.data`) currently sit inside each
> variant directory. An upcoming ONNX Runtime change adds content-addressed shared
> assets, which will let multiple variants of the same model reference a single copy of
> the weights from the package's `shared_assets/` area. Packages authored under the
> conventions in this document migrate without genai-side changes: only the weight
> references inside the variant's `genai_config.json` change form.
specific to that build. Files that are identical across variants can instead live in a
content-addressed shared asset and be referenced with the `sha256:` scheme (see below),
which lets multiple variants share a single on-disk copy.

## Sharing tokenizer and processor files across variants

Tokenizer assets and processor configuration are typically identical across variants of a
model. They can live in any sibling directory under the package root and be referenced
from each variant's `genai_config.json` using the `package:` path scheme.
model. Rather than duplicating them in every variant directory, store them once as a
content-addressed *shared asset* and reference them from each variant's `genai_config.json`
using the `sha256:` path scheme.

A shared asset is a directory under the package's `shared_assets/` area named
`sha256-<hex>`, where `<hex>` is the asset's content digest. ONNX Runtime owns the
shared-asset model: it discovers `shared_assets/sha256-<hex>/` directories at load time and
can remap a digest to a custom or external directory through a `shared_assets` override in
the manifest. Referencing an asset by digest therefore resolves to the right location even
when the manifest overrides it.

Set `model.tokenizer_dir` to the directory holding `tokenizer.json` and friends. The
value uses the path-scheme syntax described below.
Set `model.tokenizer_dir` to the asset's `sha256:` URI. An optional `/`-separated tail
selects a subdirectory within the asset.

```jsonc
// models/model/openvino-1/genai_config.json
// openvino-1/genai_config.json
{
"model": {
"type": "phi",
"tokenizer_dir": "package:shared",
"tokenizer_dir": "sha256:<hex>",
...
},
"search": {}
Expand All @@ -108,17 +111,18 @@ If `tokenizer_dir` is left unset the tokenizer files are looked up alongside

Processor-specific configuration (`model.vision.config_filename`,
`model.speech.config_filename`) is intentionally left per-variant: those files are small
and keeping them next to the ONNX graphs avoids needing the `package:` resolver for them.
and keeping them next to the ONNX graphs avoids needing a shared asset for them.

## Path scheme

`model.tokenizer_dir` accepts either a plain path or a scheme-prefixed value:
`model.tokenizer_dir` accepts either a plain path or a scheme-prefixed value. Resolution is
performed by ONNX Runtime's model package resolver:

| Form | Resolution |
| --- | --- |
| *(empty)* | Treated as the variant directory (the directory containing `genai_config.json`). |
| `package:<relative_path>` | Joined with the package root. Only valid when loading from a model package. |
| Any other value | Joined with the variant directory, matching how every other path in `genai_config.json` is resolved. |
| `sha256:<hex>` or `sha256:<hex>/<tail>` | A content-addressed shared asset. Resolves to the asset's directory (honoring manifest `shared_assets` overrides), optionally joined with the confined tail. Only valid when loading from a model package. |
| Any other value | A relative path resolved against the variant directory, with portable-layout confinement (no absolute paths, no `..`). |

## Loading a package

Expand Down Expand Up @@ -182,17 +186,17 @@ auto model_ov = OgaModel::Create(*config);

## Authoring notes

- Every variant declared in `metadata.json` must have its own directory containing a
`genai_config.json`. Variant directories are completely independent — they may differ
in any genai_config field, including `context_length`, tokenizer-related defaults, or
the set of ONNX graphs they load.
- Every variant declared in the component's `variants` map must have its own directory
containing a `genai_config.json`. Variant directories are completely independent — they
may differ in any genai_config field, including `context_length`, tokenizer-related
defaults, or the set of ONNX graphs they load.
- Variants targeting the same execution provider must each declare a distinct
`compatibility_string` (and typically a `device`) so the EP can score them against the
local hardware. ONNX Runtime treats the string as opaque and forwards it to the EP's
validator; the EP defines the syntax.
- Files outside variant directories (such as the `shared/` directory in the example
above) are not interpreted by the runtime. Reference them from
`genai_config.json` with the `package:` scheme to keep them shared across variants.
- Files shared across variants live in a content-addressed shared asset under
`shared_assets/sha256-<hex>/`. Reference them from `genai_config.json` with the `sha256:`
scheme so multiple variants share a single on-disk copy.
- The model package's execution provider names must match what ONNX Runtime expects
(`CPUExecutionProvider`, `CUDAExecutionProvider`, `DmlExecutionProvider`,
`OpenVINOExecutionProvider`, ...). The short forms used by genai_config (`cuda`, `dml`,
Expand Down
26 changes: 12 additions & 14 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1623,24 +1623,22 @@ void OverlayConfig(Config& config, std::string_view json) {
JSON::Parse(element, json);
}

namespace {

constexpr std::string_view kPackageScheme = "package:";

} // namespace

fs::path Config::ResolvePath(std::string_view value) const {
if (value.empty()) {
return config_path;
}
if (value.size() >= kPackageScheme.size() &&
value.compare(0, kPackageScheme.size(), kPackageScheme) == 0) {
if (package_root.string().empty()) {
throw std::runtime_error("Cannot resolve \"" + std::string{value} +
"\": this model was not loaded from a model package.");
}
const std::string remainder{value.substr(kPackageScheme.size())};
return remainder.empty() ? package_root : package_root / remainder;
if (package_resolver) {
// Loaded from a model package: ORT owns all path-reference resolution (sha256: shared
// assets with manifest overrides, relative paths, confinement).
return package_resolver(config_path, value);
}
// Flat directory: sha256: shared-asset references are only meaningful inside a model package.
constexpr std::string_view kSharedAssetPrefix = "sha256:";
if (value.substr(0, kSharedAssetPrefix.size()) == kSharedAssetPrefix) {
throw std::runtime_error(
"\"" + std::string{value} +
"\" is a sha256: shared-asset reference, which is only valid when loading from a model "
"package; this model was loaded from a plain directory.");
}
return config_path / std::string{value};
}
Expand Down
14 changes: 11 additions & 3 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
#pragma once
#include "provider_options.h"

#include <functional>

namespace Generators {

struct RuntimeSettings;
Expand Down Expand Up @@ -88,9 +90,15 @@ struct Config {
fs::path config_path; // Path of the config directory
fs::path package_root; // Package root if loaded from a model package, otherwise empty.

// Resolves a path-like string from genai_config.json. Empty -> config_path.
// "package:<rel>" -> package_root/<rel> (errors when package_root is empty). Anything
// else is joined with config_path.
// When loaded from a model package, resolves path-shaped genai_config.json values through
// ORT's package resolver: a "sha256:<hex>[/tail]" content-addressed shared-asset reference
// (honoring manifest overrides) or a plain relative path against base_dir. Empty for flat
// model directories. Captures the OrtModelPackageContext to keep it alive for resolution.
std::function<fs::path(const fs::path& base_dir, std::string_view value)> package_resolver;

// Resolves a path-like string from genai_config.json. Empty -> config_path. When loaded
// from a package, delegates to package_resolver (sha256: shared assets, relative paths);
// otherwise the value is joined with config_path.
fs::path ResolvePath(std::string_view value) const;

using NamedString = Generators::NamedString;
Expand Down
40 changes: 33 additions & 7 deletions src/models/model.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ namespace {

constexpr const char* kOrtSessionOptionsModelExternalInitializersFileFolderPath =
"session.model_external_initializers_file_folder_path";
constexpr const char* kOrtSessionOptionEpContextFilePath = "ep.context_file_path";

// Session-option config keys whose values are file/folder path references. When a model is loaded
// from a package these may be sha256: shared-asset URIs or relative paths, so their values are
// resolved against the model root (Config::ResolvePath) before reaching ORT.
bool IsPathValuedSessionOption(std::string_view key) {
return key == kOrtSessionOptionsModelExternalInitializersFileFolderPath ||
key == kOrtSessionOptionEpContextFilePath;
}

} // namespace

Expand Down Expand Up @@ -304,7 +313,7 @@ Tokenizer::Tokenizer(Config& config) : bos_token_id_{config.model.bos_token_id},
const char* keys[] = {"add_special_tokens", "skip_special_tokens"};
const char* values[] = {"false", "true"};

// Resolve tokenizer_dir (may be empty, relative, absolute, or "package:"-scheme).
// Resolve tokenizer_dir (may be empty, relative, absolute, or a "sha256:" shared-asset reference).
const fs::path tokenizer_dir = config.ResolvePath(config.model.tokenizer_dir);
CheckResult(OrtxCreateTokenizerWithOptions(tokenizer_.Address(), tokenizer_dir.string().c_str(), keys, values, 2));
}
Expand Down Expand Up @@ -664,7 +673,12 @@ void Model::CreateSessionOptionsFromConfig(const Config::SessionOptions& config_
* Reference: https://github.com/microsoft/onnxruntime/blob/main/include/onnxruntime/core/session/onnxruntime_session_options_config_keys.h
*/
for (auto& config_entry : config_session_options.config_entries) {
session_options.AddConfigEntry(config_entry.first.c_str(), config_entry.second.c_str());
if (!config_entry.second.empty() && IsPathValuedSessionOption(config_entry.first)) {
const std::string resolved = config_->ResolvePath(config_entry.second).string();
session_options.AddConfigEntry(config_entry.first.c_str(), resolved.c_str());
} else {
session_options.AddConfigEntry(config_entry.first.c_str(), config_entry.second.c_str());
}
}

// Register custom ops libraries only if explicitly configured
Expand Down Expand Up @@ -783,11 +797,15 @@ std::unique_ptr<OrtSession> Model::CreateSession(OrtEnv& ort_env, const std::str
if (model_data_it->second.empty()) {
throw std::runtime_error("Failed to load model data from memory for " + model_filename);
}
// For models loaded from memory that reference external data files, tell ORT where to find them
// via the kOrtSessionOptionsModelExternalInitializersFileFolderPath session config entry.
const fs::path external_initializers_path = fs::absolute(config_->config_path);
session_options->AddConfigEntry(kOrtSessionOptionsModelExternalInitializersFileFolderPath,
external_initializers_path.string().c_str());
// For models loaded from memory that reference external data files, ORT has no model
// directory to resolve them against. Default the external-initializers folder to the config
// directory, but only when the config did not already set it (a genai_config session option,
// resolved in CreateSessionOptionsFromConfig, takes precedence).
if (!session_options->HasConfigEntry(kOrtSessionOptionsModelExternalInitializersFileFolderPath)) {
const fs::path external_initializers_path = fs::absolute(config_->config_path);
session_options->AddConfigEntry(kOrtSessionOptionsModelExternalInitializersFileFolderPath,
external_initializers_path.string().c_str());
}
return OrtSession::Create(ort_env, model_data_it->second.data(), model_data_it->second.size(), session_options);
}

Expand Down Expand Up @@ -834,6 +852,14 @@ std::unique_ptr<Config> CreateConfig(OrtEnv& ort_env, const char* config_path, c
auto load = OpenAndSelectVariant(ort_env, path, ep_str);
auto config = std::make_unique<Config>(load.variant_dir, std::string_view{});
config->package_root = load.package_root;
// Delegate genai_config path resolution to ORT's package resolver, capturing the package
// context to keep it alive for the lifetime of the config.
auto package_context = load.context;
config->package_resolver = [package_context](const fs::path& base_dir,
std::string_view value) -> fs::path {
return fs::path{package_context->ResolveStringRef(base_dir.string(), std::string{value},
/*must_exist=*/false)};
};
return config;
#else
throw std::runtime_error(
Expand Down
4 changes: 3 additions & 1 deletion src/models/model_package.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ bool IsModelPackage(const fs::path& path) {
PackageLoadResult OpenAndSelectVariant(OrtEnv& env,
const fs::path& package_root,
const std::string& explicit_ep) {
auto pkg_ctx = OrtModelPackageContext::Create(package_root.c_str());
std::shared_ptr<OrtModelPackageContext> pkg_ctx = OrtModelPackageContext::Create(package_root.c_str());

// Single-component restriction: the package author designates "the" genai component by
// making it the only one.
Expand Down Expand Up @@ -123,6 +123,8 @@ PackageLoadResult OpenAndSelectVariant(OrtEnv& env,
PackageLoadResult result;
result.package_root = package_root;
result.variant_dir = fs::path{component_ctx->GetSelectedVariantFolderPath()};
// Keep the package context alive so genai_config path references can be resolved later.
result.context = std::move(pkg_ctx);
return result;
}

Expand Down
4 changes: 4 additions & 0 deletions src/models/model_package.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.
#pragma once

#include <memory>
#include <string>

#include "../filesystem.h"
Expand All @@ -16,6 +17,9 @@ bool IsModelPackage(const fs::path& path);
struct PackageLoadResult {
fs::path package_root;
fs::path variant_dir;
// The opened package context, kept alive so genai_config path references (e.g. "sha256:"
// shared-asset refs) can be resolved on demand through ORT's package resolver.
std::shared_ptr<OrtModelPackageContext> context;
};

// Opens a package and selects the variant for its single component. When `explicit_ep` is
Expand Down
Loading
Loading