Skip to content
Open
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
52 changes: 52 additions & 0 deletions deploy/llama-stack/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Llama Stack container image

`test.containerfile` builds the Llama Stack server image used by
`docker-compose.yaml` (server mode, e.g. for the e2e suite). Besides the
Llama Stack distribution itself, the image bundles the pieces needed to
generate its run configuration at container start:

- `/opt/app-root/llama_stack_configuration.py` — the config-generation
script (copied from `src/llama_stack_configuration.py`).
- `/opt/app-root/data/default_run.yaml` — the shipped default baseline
(copied from `src/data/`), resolved by the script as `./data/` relative
to its own location.
- `/opt/app-root/enrich-entrypoint.sh` — the entrypoint (copied from
`scripts/llama-stack-entrypoint.sh`).

## Startup modes

The entrypoint invokes the script against the mounted
`lightspeed-stack.yaml`, and the script auto-detects the configuration
shape:

- **Unified mode** — the `lightspeed-stack.yaml` carries a *synthesis
input* (a non-empty `inference.providers` or `vector_store.providers`,
or a `llama_stack.config` block). The full `run.yaml` is synthesized
from it; no external `run.yaml` mount is needed.
- **Legacy mode** — no synthesis input present. The mounted `run.yaml`
(`$LLAMA_STACK_CONFIG`, default `/opt/app-root/run.yaml`) is enriched
with lightspeed dynamic values (BYOK RAG, Solr/OKP, Azure Entra ID).

The repository `docker-compose.yaml` mounts both files and works for
either mode — with a unified `lightspeed-stack.yaml` the `run.yaml`
mount is simply ignored. A unified-only deployment needs just:

```yaml
services:
llama-stack:
build:
context: .
dockerfile: deploy/llama-stack/test.containerfile
ports:
- "8321:8321"
volumes:
- ./lightspeed-stack.yaml:/opt/app-root/lightspeed-stack.yaml:ro,z
```

## Rebuilding

The compose file also mounts host copies of the script, the baseline
data directory, and the entrypoint over their baked-in counterparts, so
`docker compose up` picks up local changes to any of them without an
image rebuild. Rebuild (`docker compose build llama-stack`) when
dependencies (`pyproject.toml` / `uv.lock`) or the providers change.
8 changes: 6 additions & 2 deletions deploy/llama-stack/test.containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,15 @@ RUN mkdir -p /opt/app-root/src/.llama/storage \
chown -R 1001:0 /opt/app-root && \
chmod -R 775 /opt/app-root

# Copy enrichment scripts for runtime config enrichment
# Copy the config-generation script for runtime synthesis (unified mode) or
# enrichment (legacy mode), plus the shipped default baseline it resolves as
# ./data/default_run.yaml relative to its own location (load_default_baseline)
COPY src/llama_stack_configuration.py /opt/app-root/llama_stack_configuration.py
COPY src/data /opt/app-root/data
COPY scripts/llama-stack-entrypoint.sh /opt/app-root/enrich-entrypoint.sh
RUN chmod +x /opt/app-root/enrich-entrypoint.sh && \
chown 1001:0 /opt/app-root/enrich-entrypoint.sh /opt/app-root/llama_stack_configuration.py
chown -R 1001:0 /opt/app-root/enrich-entrypoint.sh \
/opt/app-root/llama_stack_configuration.py /opt/app-root/data

# Switch back to the original user
USER 1001
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ services:
mock-tls-inference:
condition: service_healthy
volumes:
# Used in legacy mode only: with a unified lightspeed-stack.yaml (one
# carrying inference.providers / vector_store.providers / a
# llama_stack.config block) the entrypoint synthesizes the run config
# from lightspeed-stack.yaml and this mount is ignored
- ./run.yaml:/opt/app-root/run.yaml:z
# Host copies so `docker compose up` picks up script changes without rebuilding llama-stack
- ./scripts/llama-stack-entrypoint.sh:/opt/app-root/enrich-entrypoint.sh:ro,z
- ./src/llama_stack_configuration.py:/opt/app-root/llama_stack_configuration.py:ro,z
- ./src/data:/opt/app-root/data:ro,z
- ${GCP_KEYS_PATH:-./tmp/.gcp-keys-dummy}:/opt/app-root/.gcp-keys:ro
- ./lightspeed-stack.yaml:/opt/app-root/lightspeed-stack.yaml:ro,z
- llama-storage:/opt/app-root/src/.llama/storage
Expand Down
37 changes: 28 additions & 9 deletions scripts/llama-stack-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,27 +1,46 @@
#!/bin/bash
# Entrypoint for llama-stack container.
# Enriches config with lightspeed dynamic values, then starts llama-stack.
#
# Generates the run configuration from the mounted lightspeed-stack.yaml and
# starts llama-stack. The Python CLI (llama_stack_configuration.py)
# auto-detects the configuration shape:
# - unified mode: the lightspeed config carries a synthesis input (a
# non-empty inference.providers or vector_store.providers, or a
# llama_stack.config block). The full run.yaml is synthesized from it —
# no external run.yaml mount is needed, and $LLAMA_STACK_CONFIG is
# ignored. The shipped default baseline is read from
# /opt/app-root/data/default_run.yaml.
# - legacy mode: the mounted run.yaml ($LLAMA_STACK_CONFIG) is enriched
# with lightspeed dynamic values (BYOK RAG, Solr/OKP, Azure Entra ID).

set -e

INPUT_CONFIG="${LLAMA_STACK_CONFIG:-/opt/app-root/run.yaml}"
ENRICHED_CONFIG="/tmp/enriched-run.yaml"
GENERATED_CONFIG="/tmp/generated-run.yaml"
LIGHTSPEED_CONFIG="${LIGHTSPEED_CONFIG:-/opt/app-root/lightspeed-stack.yaml}"

# Enrich config if lightspeed config exists
# Generate config (synthesis or enrichment) if lightspeed config exists
if [ -f "$LIGHTSPEED_CONFIG" ]; then
echo "Enriching llama-stack config..."
ENRICHMENT_FAILED=0
echo "Generating llama-stack config from $LIGHTSPEED_CONFIG (mode auto-detected)..."
GENERATION_FAILED=0
/opt/app-root/.venv/bin/python3 /opt/app-root/llama_stack_configuration.py \
-c "$LIGHTSPEED_CONFIG" \
-i "$INPUT_CONFIG" \
-o "$ENRICHED_CONFIG" 2>&1 || ENRICHMENT_FAILED=1
-o "$GENERATED_CONFIG" 2>&1 || GENERATION_FAILED=1

if [ -f "$ENRICHED_CONFIG" ] && [ "$ENRICHMENT_FAILED" -eq 0 ]; then
echo "Using enriched config: $ENRICHED_CONFIG"
exec ogx stack run "$ENRICHED_CONFIG"
if [ -f "$GENERATED_CONFIG" ] && [ "$GENERATION_FAILED" -eq 0 ]; then
echo "Using generated config: $GENERATED_CONFIG"
exec ogx stack run "$GENERATED_CONFIG"
fi
fi

# Fallback: run the mounted run.yaml directly. In unified mode there may be
# no run.yaml at all — fail with a clear message instead of a confusing
# llama-stack file-not-found error.
if [ ! -f "$INPUT_CONFIG" ]; then
echo "ERROR: config generation failed and no fallback run.yaml exists at $INPUT_CONFIG" >&2
exit 1
fi

echo "Using original config: $INPUT_CONFIG"
exec ogx stack run "$INPUT_CONFIG"
50 changes: 45 additions & 5 deletions src/llama_stack_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -1346,10 +1346,45 @@ def generate_configuration(
# =============================================================================


def has_synthesis_input(lcs_config: dict[str, Any]) -> bool:
"""Return True when a raw lightspeed config carries a synthesis input.

Mirrors the unified-vs-legacy detection of the root ``Configuration``
model (``check_unified_vs_legacy``) for callers that work with the raw
YAML dict, such as the CLI: a non-empty top-level ``inference.providers``,
a non-empty ``vector_store.providers``, or a ``llama_stack.config`` block
signal unified mode (R11).

Parameters:
lcs_config: The ``lightspeed-stack.yaml`` contents parsed into a dict.

Returns:
bool: True when any synthesis input is present.
"""
inference = lcs_config.get("inference") or {}
vector_store = lcs_config.get("vector_store") or {}
llama_stack = lcs_config.get("llama_stack") or {}
return (
bool(inference.get("providers"))
or bool(vector_store.get("providers"))
or llama_stack.get("config") is not None
)


def main() -> None:
"""CLI entry point."""
"""CLI entry point with unified-vs-legacy auto-detection.

Auto-detects the configuration shape from the ``--config`` file (spec
"Trigger mechanism"): when it carries a synthesis input the full run.yaml
is synthesized from it and ``--input`` is ignored, so no external
run.yaml needs to exist; otherwise the legacy path enriches the
``--input`` run.yaml in place. Server-mode container entrypoints rely on
this dispatch to serve both modes with a single invocation.
"""
parser = ArgumentParser(
description="Enrich Llama Stack config with Lightspeed values",
description="Generate the Llama Stack run configuration from a "
"lightspeed-stack.yaml: synthesized in unified mode, or enriched "
"from --input in legacy mode (auto-detected)",
)
parser.add_argument(
"-c",
Expand All @@ -1361,20 +1396,25 @@ def main() -> None:
"-i",
"--input",
default="run.yaml",
help="Input Llama Stack config (default: run.yaml)",
help="Input Llama Stack config enriched in legacy mode; ignored in "
"unified mode (default: run.yaml)",
)
parser.add_argument(
"-o",
"--output",
default="run_.yaml",
help="Output enriched config (default: run_.yaml)",
help="Output generated config (default: run_.yaml)",
)
args = parser.parse_args()

with open(args.config, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)

generate_configuration(args.input, args.output, config)
if has_synthesis_input(config):
config_file_dir = os.path.dirname(os.path.abspath(args.config))
synthesize_to_file(config, args.output, config_file_dir)
else:
generate_configuration(args.input, args.output, config)


if __name__ == "__main__":
Expand Down
122 changes: 122 additions & 0 deletions tests/unit/test_llama_stack_synthesize.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import os
import stat
import sys
from pathlib import Path
from typing import Any, Optional, get_args

Expand All @@ -19,7 +20,9 @@
apply_high_level_inference,
deep_merge_list_replace,
ensure_mcp_tool_runtime,
has_synthesis_input,
load_default_baseline,
main,
migrate_config_dumb,
synthesize_configuration,
synthesize_to_file,
Expand Down Expand Up @@ -811,3 +814,122 @@ def test_reference_profile_loads_via_synthesizer(profile_path: Path) -> None:
assert result["version"] == 2
assert "inference" in result["apis"]
assert result["providers"]["inference"], "profile must configure inference"


# ---------------------------------------------------------------------------
# CLI auto-detection (LCORE-2338): main() dispatches unified vs legacy
# ---------------------------------------------------------------------------


def test_has_synthesis_input_detection() -> None:
"""The raw-dict detection mirrors the root-model synthesis-input check."""
assert has_synthesis_input({"llama_stack": {"config": {"baseline": "default"}}})
assert has_synthesis_input({"inference": {"providers": [{"type": "openai"}]}})
assert has_synthesis_input({"vector_store": {"providers": [{"id": "nb"}]}})
assert not has_synthesis_input({})
assert not has_synthesis_input(
{"llama_stack": {"library_client_config_path": "run.yaml"}}
)
# empty provider lists and null sections are not synthesis inputs
assert not has_synthesis_input({"inference": {"providers": []}})
assert not has_synthesis_input({"inference": None, "llama_stack": None})


def _run_main(monkeypatch: pytest.MonkeyPatch, argv: list[str]) -> None:
"""Invoke the module CLI with the given arguments."""
monkeypatch.setattr(sys, "argv", ["llama_stack_configuration.py", *argv])
main()


def test_main_unified_config_synthesizes_without_input_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A unified config is synthesized; the legacy --input file is ignored.

This is the server-mode contract (spec "Trigger mechanism"): the container
entrypoint always passes -i, but in unified mode no run.yaml mount needs
to exist.
"""
lcs = {
"llama_stack": {
"config": {
"baseline": "empty",
"native_override": {"version": 2, "marker": "synthesized"},
}
}
}
cfg_path = tmp_path / "lightspeed-stack.yaml"
cfg_path.write_text(yaml.dump(lcs), encoding="utf-8")
out_path = tmp_path / "generated-run.yaml"

_run_main(
monkeypatch,
[
"-c",
str(cfg_path),
"-i",
str(tmp_path / "does-not-exist.yaml"),
"-o",
str(out_path),
],
)

assert yaml.safe_load(out_path.read_text(encoding="utf-8")) == {
"version": 2,
"marker": "synthesized",
}
# synthesized output carries the 0600 secret-safety contract (R10)
assert stat.S_IMODE(os.stat(out_path).st_mode) == 0o600


def test_main_unified_config_resolves_relative_profile(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A relative profile: in the config resolves against the config's dir (R8)."""
profile = {"version": 2, "apis": ["inference"], "marker": "from-profile"}
(tmp_path / "my-profile.yaml").write_text(yaml.dump(profile), encoding="utf-8")
lcs = {"llama_stack": {"config": {"profile": "my-profile.yaml"}}}
cfg_path = tmp_path / "lightspeed-stack.yaml"
cfg_path.write_text(yaml.dump(lcs), encoding="utf-8")
out_path = tmp_path / "generated-run.yaml"

_run_main(monkeypatch, ["-c", str(cfg_path), "-o", str(out_path)])

assert yaml.safe_load(out_path.read_text(encoding="utf-8"))["marker"] == (
"from-profile"
)


def test_main_legacy_config_enriches_input_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A legacy config (no synthesis input) enriches the --input run.yaml."""
run_yaml = {"version": 2, "apis": ["inference"]}
run_path = tmp_path / "run.yaml"
run_path.write_text(yaml.dump(run_yaml), encoding="utf-8")
lcs = {
"llama_stack": {"library_client_config_path": str(run_path)},
"byok_rag": [
{
"rag_id": "kb1",
"vector_db_id": "kb1",
"db_path": "/var/lib/kb1/faiss.db",
"embedding_model": "nomic-ai/nomic-embed-text-v1.5",
"embedding_dimension": 768,
}
],
}
cfg_path = tmp_path / "lightspeed-stack.yaml"
cfg_path.write_text(yaml.dump(lcs), encoding="utf-8")
out_path = tmp_path / "enriched-run.yaml"

_run_main(
monkeypatch,
["-c", str(cfg_path), "-i", str(run_path), "-o", str(out_path)],
)

enriched = yaml.safe_load(out_path.read_text(encoding="utf-8"))
# original content survives and BYOK enrichment was applied to the input
assert enriched["version"] == 2
vector_io_ids = {p["provider_id"] for p in enriched["providers"]["vector_io"]}
assert "byok_kb1" in vector_io_ids
Loading