diff --git a/docs/static/deprecated-llama-stack-spec.yaml b/docs/static/deprecated-llama-stack-spec.yaml index 1a215b877..43c565f86 100644 --- a/docs/static/deprecated-llama-stack-spec.yaml +++ b/docs/static/deprecated-llama-stack-spec.yaml @@ -10061,4 +10061,4 @@ x-tagGroups: - PostTraining (Coming Soon) - Safety - Telemetry - - VectorIO + - VectorIO \ No newline at end of file diff --git a/llama_stack/cli/stack/_list_deps.py b/llama_stack/cli/stack/_list_deps.py new file mode 100644 index 000000000..a36b5b3c8 --- /dev/null +++ b/llama_stack/cli/stack/_list_deps.py @@ -0,0 +1,176 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the terms described in the LICENSE file in +# the root directory of this source tree. + +import argparse +import sys +from pathlib import Path + +import yaml +from termcolor import cprint + +from llama_stack.cli.stack.utils import ImageType +from llama_stack.core.build import get_provider_dependencies +from llama_stack.core.datatypes import ( + BuildConfig, + BuildProvider, + DistributionSpec, +) +from llama_stack.core.distribution import get_provider_registry +from llama_stack.core.external import load_external_apis +from llama_stack.core.stack import replace_env_vars +from llama_stack.core.utils.config_dirs import DISTRIBS_BASE_DIR +from llama_stack.core.utils.exec import run_command +from llama_stack.log import get_logger +from llama_stack.providers.datatypes import Api + +TEMPLATES_PATH = Path(__file__).parent.parent.parent / "templates" + +logger = get_logger(name=__name__, category="cli") + + +# These are the dependencies needed by the distribution server. +# `llama-stack` is automatically installed by the installation script. +SERVER_DEPENDENCIES = [ + "aiosqlite", + "fastapi", + "fire", + "httpx", + "uvicorn", + "opentelemetry-sdk", + "opentelemetry-exporter-otlp-proto-http", +] + + +def format_output_deps_only( + normal_deps: list[str], + special_deps: list[str], + external_deps: list[str], + uv: bool = False, +) -> str: + """Format dependencies as a list.""" + lines = [] + + uv_str = "" + if uv: + uv_str = "uv pip install " + + # Quote deps with commas + quoted_normal_deps = [quote_if_needed(dep) for dep in normal_deps] + lines.append(f"{uv_str}{' '.join(quoted_normal_deps)}") + + for special_dep in special_deps: + lines.append(f"{uv_str}{quote_special_dep(special_dep)}") + + for external_dep in external_deps: + lines.append(f"{uv_str}{quote_special_dep(external_dep)}") + + return "\n".join(lines) + + +def run_stack_list_deps_command(args: argparse.Namespace) -> None: + if args.config: + try: + from llama_stack.core.utils.config_resolution import Mode, resolve_config_or_distro + + config_file = resolve_config_or_distro(args.config, Mode.BUILD) + except ValueError as e: + cprint( + f"Could not parse config file {args.config}: {e}", + color="red", + file=sys.stderr, + ) + sys.exit(1) + if config_file: + with open(config_file) as f: + try: + contents = yaml.safe_load(f) + contents = replace_env_vars(contents) + build_config = BuildConfig(**contents) + build_config.image_type = "venv" + except Exception as e: + cprint( + f"Could not parse config file {config_file}: {e}", + color="red", + file=sys.stderr, + ) + sys.exit(1) + elif args.providers: + provider_list: dict[str, list[BuildProvider]] = dict() + for api_provider in args.providers.split(","): + if "=" not in api_provider: + cprint( + "Could not parse `--providers`. Please ensure the list is in the format api1=provider1,api2=provider2", + color="red", + file=sys.stderr, + ) + sys.exit(1) + api, provider_type = api_provider.split("=") + providers_for_api = get_provider_registry().get(Api(api), None) + if providers_for_api is None: + cprint( + f"{api} is not a valid API.", + color="red", + file=sys.stderr, + ) + sys.exit(1) + if provider_type in providers_for_api: + provider = BuildProvider( + provider_type=provider_type, + module=None, + ) + provider_list.setdefault(api, []).append(provider) + else: + cprint( + f"{provider_type} is not a valid provider for the {api} API.", + color="red", + file=sys.stderr, + ) + sys.exit(1) + distribution_spec = DistributionSpec( + providers=provider_list, + description=",".join(args.providers), + ) + build_config = BuildConfig(image_type=ImageType.VENV.value, distribution_spec=distribution_spec) + + normal_deps, special_deps, external_provider_dependencies = get_provider_dependencies(build_config) + normal_deps += SERVER_DEPENDENCIES + + # Format and output based on requested format + output = format_output_deps_only( + normal_deps=normal_deps, + special_deps=special_deps, + external_deps=external_provider_dependencies, + uv=args.format == "uv", + ) + + print(output) + + +def quote_if_needed(dep): + # Add quotes if the dependency contains special characters that need escaping in shell + # This includes: commas, comparison operators (<, >, <=, >=, ==, !=) + needs_quoting = any(char in dep for char in [",", "<", ">", "="]) + return f"'{dep}'" if needs_quoting else dep + + +def quote_special_dep(dep_string): + """ + Quote individual packages in a special dependency string. + Special deps may contain multiple packages and flags like --extra-index-url. + We need to quote only the package specs that contain special characters. + """ + parts = dep_string.split() + quoted_parts = [] + + for part in parts: + # Don't quote flags (they start with -) + if part.startswith("-"): + quoted_parts.append(part) + else: + # Quote package specs that need it + quoted_parts.append(quote_if_needed(part)) + + return " ".join(quoted_parts) diff --git a/llama_stack/cli/stack/build.py b/llama_stack/cli/stack/build.py index 80cf6fb38..cbe8ed881 100644 --- a/llama_stack/cli/stack/build.py +++ b/llama_stack/cli/stack/build.py @@ -8,6 +8,9 @@ import textwrap from llama_stack.cli.stack.utils import ImageType from llama_stack.cli.subcommand import Subcommand +from llama_stack.log import get_logger + +logger = get_logger(__name__, category="cli") class StackBuild(Subcommand): @@ -16,7 +19,7 @@ class StackBuild(Subcommand): self.parser = subparsers.add_parser( "build", prog="llama stack build", - description="Build a Llama stack container", + description="[DEPRECATED] Build a Llama stack container. This command is deprecated and will be removed in a future release. Use `llama stack list-deps ' instead.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) self._add_arguments() @@ -93,6 +96,9 @@ the build. If not specified, currently active environment will be used if found. ) def _run_stack_build_command(self, args: argparse.Namespace) -> None: + logger.warning( + "The 'llama stack build' command is deprecated and will be removed in a future release. Please use 'llama stack list-deps'" + ) # always keep implementation completely silo-ed away from CLI so CLI # can be fast to load and reduces dependencies from ._build import run_stack_build_command diff --git a/llama_stack/cli/stack/list_deps.py b/llama_stack/cli/stack/list_deps.py new file mode 100644 index 000000000..b6eee1f3b --- /dev/null +++ b/llama_stack/cli/stack/list_deps.py @@ -0,0 +1,51 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the terms described in the LICENSE file in +# the root directory of this source tree. +import argparse + +from llama_stack.cli.subcommand import Subcommand + + +class StackListDeps(Subcommand): + def __init__(self, subparsers: argparse._SubParsersAction): + super().__init__() + self.parser = subparsers.add_parser( + "list-deps", + prog="llama stack list-deps", + description="list the dependencies for a llama stack distribution", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + self._add_arguments() + self.parser.set_defaults(func=self._run_stack_list_deps_command) + + def _add_arguments(self): + self.parser.add_argument( + "config", + type=str, + nargs="?", # Make it optional + metavar="config | distro", + help="Path to config file to use or name of known distro (llama stack list for a list).", + ) + + self.parser.add_argument( + "--providers", + type=str, + default=None, + help="sync dependencies for a list of providers and only those providers. This list is formatted like: api1=provider1,api2=provider2. Where there can be multiple providers per API.", + ) + self.parser.add_argument( + "--format", + type=str, + choices=["uv", "deps-only"], + default="deps-only", + help="Output format: 'uv' shows shell commands, 'deps-only' shows just the list of dependencies without `uv` (default)", + ) + + def _run_stack_list_deps_command(self, args: argparse.Namespace) -> None: + # always keep implementation completely silo-ed away from CLI so CLI + # can be fast to load and reduces dependencies + from ._list_deps import run_stack_list_deps_command + + return run_stack_list_deps_command(args) diff --git a/llama_stack/cli/stack/stack.py b/llama_stack/cli/stack/stack.py index 3aff78e23..fd0a4edf5 100644 --- a/llama_stack/cli/stack/stack.py +++ b/llama_stack/cli/stack/stack.py @@ -13,6 +13,7 @@ from llama_stack.cli.subcommand import Subcommand from .build import StackBuild from .list_apis import StackListApis +from .list_deps import StackListDeps from .list_providers import StackListProviders from .remove import StackRemove from .run import StackRun @@ -39,6 +40,7 @@ class StackParser(Subcommand): subparsers = self.parser.add_subparsers(title="stack_subcommands") # Add sub-commands + StackListDeps.create(subparsers) StackBuild.create(subparsers) StackListApis.create(subparsers) StackListProviders.create(subparsers) diff --git a/llama_stack/cli/stack/utils.py b/llama_stack/cli/stack/utils.py index fdf9e1761..4d4c1b538 100644 --- a/llama_stack/cli/stack/utils.py +++ b/llama_stack/cli/stack/utils.py @@ -4,7 +4,28 @@ # This source code is licensed under the terms described in the LICENSE file in # the root directory of this source tree. +import json +import sys from enum import Enum +from functools import lru_cache +from pathlib import Path + +import yaml +from termcolor import cprint + +from llama_stack.core.datatypes import ( + BuildConfig, + Provider, + StackRunConfig, +) +from llama_stack.core.distribution import get_provider_registry +from llama_stack.core.resolver import InvalidProviderError +from llama_stack.core.utils.config_dirs import EXTERNAL_PROVIDERS_DIR +from llama_stack.core.utils.dynamic import instantiate_class_type +from llama_stack.core.utils.image_types import LlamaStackImageType +from llama_stack.providers.datatypes import Api + +TEMPLATES_PATH = Path(__file__).parent.parent.parent / "distributions" class ImageType(Enum): @@ -19,3 +40,91 @@ def print_subcommand_description(parser, subparsers): description = subcommand.description description_text += f" {name:<21} {description}\n" parser.epilog = description_text + + +def generate_run_config( + build_config: BuildConfig, + build_dir: Path, + image_name: str, +) -> Path: + """ + Generate a run.yaml template file for user to edit from a build.yaml file + """ + apis = list(build_config.distribution_spec.providers.keys()) + run_config = StackRunConfig( + container_image=(image_name if build_config.image_type == LlamaStackImageType.CONTAINER.value else None), + image_name=image_name, + apis=apis, + providers={}, + external_providers_dir=build_config.external_providers_dir + if build_config.external_providers_dir + else EXTERNAL_PROVIDERS_DIR, + ) + # build providers dict + provider_registry = get_provider_registry(build_config) + for api in apis: + run_config.providers[api] = [] + providers = build_config.distribution_spec.providers[api] + + for provider in providers: + pid = provider.provider_type.split("::")[-1] + + p = provider_registry[Api(api)][provider.provider_type] + if p.deprecation_error: + raise InvalidProviderError(p.deprecation_error) + + try: + config_type = instantiate_class_type(provider_registry[Api(api)][provider.provider_type].config_class) + except (ModuleNotFoundError, ValueError) as exc: + # HACK ALERT: + # This code executes after building is done, the import cannot work since the + # package is either available in the venv or container - not available on the host. + # TODO: use a "is_external" flag in ProviderSpec to check if the provider is + # external + cprint( + f"Failed to import provider {provider.provider_type} for API {api} - assuming it's external, skipping: {exc}", + color="yellow", + file=sys.stderr, + ) + # Set config_type to None to avoid UnboundLocalError + config_type = None + + if config_type is not None and hasattr(config_type, "sample_run_config"): + config = config_type.sample_run_config(__distro_dir__=f"~/.llama/distributions/{image_name}") + else: + config = {} + + p_spec = Provider( + provider_id=pid, + provider_type=provider.provider_type, + config=config, + module=provider.module, + ) + run_config.providers[api].append(p_spec) + + run_config_file = build_dir / f"{image_name}-run.yaml" + + with open(run_config_file, "w") as f: + to_write = json.loads(run_config.model_dump_json()) + f.write(yaml.dump(to_write, sort_keys=False)) + + # Only print this message for non-container builds since it will be displayed before the + # container is built + # For non-container builds, the run.yaml is generated at the very end of the build process so it + # makes sense to display this message + if build_config.image_type != LlamaStackImageType.CONTAINER.value: + cprint(f"You can now run your stack with `llama stack run {run_config_file}`", color="green", file=sys.stderr) + return run_config_file + + +@lru_cache +def available_templates_specs() -> dict[str, BuildConfig]: + import yaml + + template_specs = {} + for p in TEMPLATES_PATH.rglob("*build.yaml"): + template_name = p.parent.name + with open(p) as f: + build_config = BuildConfig(**yaml.safe_load(f)) + template_specs[template_name] = build_config + return template_specs diff --git a/llama_stack/core/utils/config_resolution.py b/llama_stack/core/utils/config_resolution.py index 182a571ee..fcf057db6 100644 --- a/llama_stack/core/utils/config_resolution.py +++ b/llama_stack/core/utils/config_resolution.py @@ -42,25 +42,25 @@ def resolve_config_or_distro( # Strategy 1: Try as file path first config_path = Path(config_or_distro) if config_path.exists() and config_path.is_file(): - logger.info(f"Using file path: {config_path}") + logger.debug(f"Using file path: {config_path}") return config_path.resolve() # Strategy 2: Try as distribution name (if no .yaml extension) if not config_or_distro.endswith(".yaml"): distro_config = _get_distro_config_path(config_or_distro, mode) if distro_config.exists(): - logger.info(f"Using distribution: {distro_config}") + logger.debug(f"Using distribution: {distro_config}") return distro_config # Strategy 3: Try as built distribution name distrib_config = DISTRIBS_BASE_DIR / f"llamastack-{config_or_distro}" / f"{config_or_distro}-{mode}.yaml" if distrib_config.exists(): - logger.info(f"Using built distribution: {distrib_config}") + logger.debug(f"Using built distribution: {distrib_config}") return distrib_config distrib_config = DISTRIBS_BASE_DIR / f"{config_or_distro}" / f"{config_or_distro}-{mode}.yaml" if distrib_config.exists(): - logger.info(f"Using built distribution: {distrib_config}") + logger.debug(f"Using built distribution: {distrib_config}") return distrib_config # Strategy 4: Failed - provide helpful error