Skip to content

feat: provision embedding models by name - #55

Merged
sjvans merged 6 commits into
feat/actual-tokenizerfrom
feat/explicit-model-provisioning
Aug 27, 2026
Merged

feat: provision embedding models by name#55
sjvans merged 6 commits into
feat/actual-tokenizerfrom
feat/explicit-model-provisioning

Conversation

@sjvans

@sjvans sjvans commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

  • require cds.env.requires.db.embedding.model; there is no default model
  • discover compatible Hugging Face ONNX model artifacts and embedding metadata from the model name
  • store project-local models below <cds.root>/.cds/models/<model> by default
  • warn and provision a missing project-local model on demand, then reuse its pinned installation
  • add npx @cap-js/ai install-model <model> [--directory <root>] for explicit provisioning
  • allow a relative, absolute, or ~/ shared cache root through embedding.directory; configured directories remain offline/read-only at runtime
  • generate embedding.lock.json automatically with the immutable revision, files, checksums, dimensions, token limit, pooling, and normalization
  • validate a model with ONNX Runtime before publishing an installation and release validation/runtime sessions cleanly

Runtime configuration

{
  "cds": {
    "requires": {
      "db": {
        "kind": "ai-sqlite",
        "embedding": {
          "model": "foo/bar"
        }
      }
    }
  }
}

With no directory, the effective model directory is:

<cds.root>/.cds/models/foo/bar

If it does not exist, startup logs a warning, downloads and validates it, and retains it for later starts.

Explicit/shared provisioning

npx @cap-js/ai install-model foo/bar --directory ~/.cds/models

This installs into ~/.cds/models/foo/bar. Applications can reuse it with:

{
  "embedding": {
    "model": "foo/bar",
    "directory": "~/.cds/models"
  }
}

When directory is configured, startup only verifies the existing installation and never downloads or modifies it.

Discovery and validation

  • resolves the current Hugging Face revision to an immutable commit
  • requires the conventional ONNX and tokenizer/config artifacts
  • includes external ONNX data such as onnx/model.onnx_data
  • derives dimensions and bounded token length from model/tokenizer metadata
  • derives pooling and normalization from an unambiguous Sentence Transformers module chain, optionally following pinned base-model metadata
  • rejects unsupported or ambiguous repositories instead of guessing defaults
  • downloads atomically, verifies sizes and SHA-256 checksums, and serializes concurrent installs
  • probes the staged ONNX runtime before publishing the model directory

Validation

  • npm test — 72/72 passing
  • npx -y eslint@10 .
  • npx -y prettier@3 --check .
  • git diff --check
  • independent final review: no blockers

Stack

This PR targets feat/actual-tokenizer and follows #51.

@sjvans sjvans changed the title feat: add explicit embedding model provisioning feat: provision embedding models by name Aug 27, 2026
@sjvans
sjvans marked this pull request as ready for review August 27, 2026 12:31
@sjvans
sjvans requested a review from a team as a code owner August 27, 2026 12:31
@sjvans
sjvans merged commit 44dafe9 into feat/actual-tokenizer Aug 27, 2026
1 check passed
@sjvans
sjvans deleted the feat/explicit-model-provisioning branch August 27, 2026 12:31
@hyperspace-pr-bot

Copy link
Copy Markdown
Contributor

Summary

The following content is AI-generated and provides a summary of the pull request:


feat: Provision embedding models by name

New Feature

This PR replaces the hardcoded Xenova/all-MiniLM-L6-v2 default model with an explicit, name-based model provisioning system for ai-sqlite. Runtime configuration is now intentionally minimal — only model and an optional directory are accepted.

Key changes

  • Required model name: cds.env.requires.db.embedding.model is now required. Startup fails if it is absent or if unsupported configuration keys are provided.
  • Automatic on-demand provisioning: If the project-local model directory (.cds/models/<model>) is missing, startup logs a warning, downloads and validates the model, and reuses the pinned installation on subsequent starts.
  • Explicit/shared provisioning via a new CLI entrypoint:
    npx @cap-js/ai install-model foo/bar
    npx @cap-js/ai install-model foo/bar --directory ~/.cds/models
  • Model discovery (model-discovery.js): Automatically resolves the Hugging Face revision, selects required ONNX and tokenizer artifacts, derives dimensions, token limits, and pooling/normalization semantics from Sentence Transformers metadata. Rejects unsupported or ambiguous repositories.
  • Model installation (model-install.js): Downloads atomically, verifies sizes and SHA-256 checksums, serializes concurrent installs via an install lock, and generates embedding.lock.json.
  • ONNX validation before publish: A runtime probe runs against the staged model before it is published to the final directory.
  • directory support: Relative, absolute, and ~/-prefixed cache roots are supported. Configured directories are treated as read-only; startup only verifies them and never downloads.
  • Symlink safety: Model directories and artifact path components must not be symbolic links.
  • Improved session lifecycle: AISQLiteService now disposes the embedding runtime on disconnect and cleans up correctly on initialization failure.

Configuration example

{
  "cds": {
    "requires": {
      "db": {
        "kind": "ai-sqlite",
        "embedding": {
          "model": "foo/bar",
          "directory": "~/.cds/models"
        }
      }
    }
  }
}

Have you...

  • Added relevant entry to the change log?

  • 🔄 Regenerate and Update Summary
  • ✏️ Insert as PR Description (deletes this comment)
  • 🗑️ Delete comment
PR Bot Information

Version: 1.29.54

@hyperspace-pr-bot hyperspace-pr-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pull request introduces a well-structured model provisioning system with thorough test coverage. Two substantive issues were posted: ensureDirectory can throw ENOENT on chmod when the target directory doesn't yet exist (the function creates only ancestors, not the directory itself), and the module-level createSession singleton silently ignores configuration passed by any second caller, which would misroute embeddings if two AISQLiteService instances with different models are ever used in the same process. The lowercaseFirst guard comment was also noted as a minor robustness improvement.

PR Bot Information

Version: 1.29.54

  • File Content Strategy: Full file content
  • Correlation ID: 3b5af0d0-a213-11f1-8f9b-d6565d7ca05c
  • LLM: anthropic--claude-4.6-sonnet
  • Event Trigger: pull_request.ready_for_review

Comment on lines +368 to +372
async function ensureDirectory(directory) {
await ensureParentDirectories(directory);
await assertModelDirectory(directory);
await fs.chmod(directory, PROVISIONED_DIRECTORY_MODE);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: ensureDirectory calls ensureParentDirectories(directory) to create missing ancestors and then calls assertModelDirectory(directory) expecting the directory itself to already exist — but ensureParentDirectories only creates ancestors up to (but not including) directory. If the directory does not yet exist, assertModelDirectory returns false and fs.chmod is then called on a path that doesn't exist, throwing ENOENT. The function is called from downloadModelIfNeeded and writeModelLock, so any first-time provisioning that creates the model directory hits this path.

The directory itself should be created before chmod is applied.

Suggested change
async function ensureDirectory(directory) {
await ensureParentDirectories(directory);
await assertModelDirectory(directory);
await fs.chmod(directory, PROVISIONED_DIRECTORY_MODE);
}
async function ensureDirectory(directory) {
await ensureParentDirectories(directory);
await fs.mkdir(directory, { mode: PROVISIONED_DIRECTORY_MODE }).catch((error) => {
if (error.code !== 'EEXIST') throw error;
});
await assertModelDirectory(directory);
await fs.chmod(directory, PROVISIONED_DIRECTORY_MODE);
}

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful


const DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000;
const MODEL_LOCK_FILE = 'embedding.lock.json';
const MODEL_INSTALL_LOCK_FILE = '.embedding.install.lock';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: The install-lock file name is constructed by concatenating the directory base name with MODEL_INSTALL_LOCK_FILE (.embedding.install.lock), producing e.g. .bar.embedding.install.lock. However, in the test tests/model-provisioning.test.js the expected lock path is .model.embedding.install.lock, which matches. The issue is that MODEL_INSTALL_LOCK_FILE already starts with a ., so when appended to .${basename} the result is .<basename>.embedding.install.lock — a double-dotted name that is inconsistent with how the test at line 181 constructs the path manually (path.join(parent, '.model.embedding.install.lock')). That test passes only because the constant value happens to produce that exact string. This is fine functionally, but the constant name MODEL_INSTALL_LOCK_FILE = '.embedding.install.lock' is misleading: it is a suffix, not a standalone file name. Consider renaming it to make its role as a suffix explicit, to avoid future misuse.

Suggested change
const MODEL_INSTALL_LOCK_FILE = '.embedding.install.lock';
const MODEL_INSTALL_LOCK_FILE_SUFFIX = '.embedding.install.lock';

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines +384 to 392
async function createSession(configuration, options) {
sessionInitialization ??= createEmbeddingRuntime(configuration, options)
.then((runtime) => (sessionRuntime = runtime))
.catch((error) => {
defaultInitialization = undefined;
sessionInitialization = undefined;
throw error;
});
return defaultInitialization;
return sessionInitialization;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: The module-level sessionRuntime and sessionInitialization variables are shared across all callers of createSession. If AISQLiteService is instantiated multiple times (e.g. two services with different models), the second call to createSession reuses the first initialization regardless of the configuration and options arguments passed, silently using the wrong model for the second service. The ??= assignment means subsequent calls with different configurations are completely ignored.

Suggested change
async function createSession(configuration, options) {
sessionInitialization ??= createEmbeddingRuntime(configuration, options)
.then((runtime) => (sessionRuntime = runtime))
.catch((error) => {
defaultInitialization = undefined;
sessionInitialization = undefined;
throw error;
});
return defaultInitialization;
return sessionInitialization;
}
async function createSession(configuration, options) {
sessionInitialization ??= createEmbeddingRuntime(configuration, options)
.then((runtime) => (sessionRuntime = runtime))
.catch((error) => {
sessionInitialization = undefined;
throw error;
});
return sessionInitialization;
}
// NOTE: createSession uses module-level singletons and is only suitable for
// a single embedding configuration per process. Multiple AISQLiteService
// instances with different models will silently share the first runtime.

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines +165 to 167
function lowercaseFirst(value) {
return `${value[0].toLowerCase()}${value.slice(1)}`;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: lowercaseFirst is called with the result of modelInstallHint(...), which always returns a non-empty string starting with "Run '". So in practice it never receives an empty string. However, if value is ever empty, value[0] is undefined and the template literal produces "undefinedrun ...". Adding a guard is a cheap safeguard.

Consider adding a guard:

if (!value) return value;
Suggested change
function lowercaseFirst(value) {
return `${value[0].toLowerCase()}${value.slice(1)}`;
}
function lowercaseFirst(value) {
if (!value) return value;
return `${value[0].toLowerCase()}${value.slice(1)}`;
}

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Comment on lines +384 to 392
async function createSession(configuration, options) {
sessionInitialization ??= createEmbeddingRuntime(configuration, options)
.then((runtime) => (sessionRuntime = runtime))
.catch((error) => {
defaultInitialization = undefined;
sessionInitialization = undefined;
throw error;
});
return defaultInitialization;
return sessionInitialization;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Error: createSession uses module-level singletons (sessionRuntime, sessionInitialization), so if createSession is called a second time with a different configuration or options, the ??= short-circuits and silently returns the first session's runtime. Any AISQLiteService instance initialized after the first will use the wrong model without any error. If multiple ai-sqlite services with different models are ever supported, this will be a silent correctness failure.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

sjvans added a commit that referenced this pull request Aug 27, 2026
* feat: add explicit embedding model provisioning

* feat: support lazy embedding model provisioning

* docs: explain embedding model provisioning

* fix: require explicit embedding model

* refactor: require provisioned embedding models

* feat: provision embedding models by name
sjvans added a commit that referenced this pull request Aug 27, 2026
* feat: add explicit embedding model provisioning

* feat: support lazy embedding model provisioning

* docs: explain embedding model provisioning

* fix: require explicit embedding model

* refactor: require provisioned embedding models

* feat: provision embedding models by name
sjvans added a commit that referenced this pull request Aug 27, 2026
* feat: support configurable local embedding models

* feat: provision embedding models by name (#55)

* feat: add explicit embedding model provisioning

* feat: support lazy embedding model provisioning

* docs: explain embedding model provisioning

* fix: require explicit embedding model

* refactor: require provisioned embedding models

* feat: provision embedding models by name

* fix: make tokenizer an optional peer

---------

Co-authored-by: Sebastian Van Syckel <sebastian.van.syckel@sap.com>
Co-authored-by: sjvans <30337871+sjvans@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant