diff --git a/docs/devel_doc/openapi.json b/docs/devel_doc/openapi.json index ba4df6865..5c8161773 100644 --- a/docs/devel_doc/openapi.json +++ b/docs/devel_doc/openapi.json @@ -12317,6 +12317,11 @@ "title": "BYOK RAG configuration", "description": "BYOK RAG configuration. This configuration can be used to reconfigure Llama Stack through its run.yaml configuration file" }, + "vector_store": { + "$ref": "#/components/schemas/VectorStoreConfiguration", + "title": "Vector store configuration", + "description": "Dynamic vector-store provider capacity for runtime POST /v1/vector-stores creates. Not the same as byok_rag (static registered corpora). When providers is non-empty, default_provider is required and must match one of providers[].id. Applied in unified synthesis only." + }, "a2a_state": { "$ref": "#/components/schemas/A2AStateConfiguration", "title": "A2A state configuration", @@ -13231,6 +13236,67 @@ "title": "DetailModel", "description": "Nested detail model for error responses." }, + "FaissVectorStoreProvider": { + "properties": { + "id": { + "type": "string", + "minLength": 1, + "title": "Provider ID", + "description": "Llama Stack vector_io provider_id. Surrounding whitespace is stripped before validation and emission." + }, + "embedding_model": { + "type": "string", + "minLength": 1, + "title": "Embedding model", + "description": "Embedding model identification used for stores created against this provider." + }, + "embedding_dimension": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Embedding dimension", + "description": "Dimensionality of embedding vectors for this provider." + }, + "type": { + "type": "string", + "const": "faiss", + "title": "Provider type", + "description": "Product type for this dynamic vector-store provider.", + "default": "faiss" + }, + "config": { + "$ref": "#/components/schemas/FaissVectorStoreProviderConfig", + "title": "Storage config", + "description": "FAISS storage settings for this provider." + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "embedding_model", + "embedding_dimension", + "config" + ], + "title": "FaissVectorStoreProvider", + "description": "Dynamic FAISS vector-store provider (runtime create capacity)." + }, + "FaissVectorStoreProviderConfig": { + "properties": { + "path": { + "type": "string", + "minLength": 1, + "title": "DB path", + "description": "On-disk FAISS/SQLite path for this provider." + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "path" + ], + "title": "FaissVectorStoreProviderConfig", + "description": "Storage config for a FAISS dynamic vector-store provider." + }, "FeedbackCategory": { "type": "string", "enum": [ @@ -16680,6 +16746,120 @@ "title": "PasswordOAuthFlow", "description": "Defines configuration details for the OAuth 2.0 Resource Owner Password flow." }, + "PgvectorVectorStoreProvider": { + "properties": { + "id": { + "type": "string", + "minLength": 1, + "title": "Provider ID", + "description": "Llama Stack vector_io provider_id. Surrounding whitespace is stripped before validation and emission." + }, + "embedding_model": { + "type": "string", + "minLength": 1, + "title": "Embedding model", + "description": "Embedding model identification used for stores created against this provider." + }, + "embedding_dimension": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Embedding dimension", + "description": "Dimensionality of embedding vectors for this provider." + }, + "type": { + "type": "string", + "const": "pgvector", + "title": "Provider type", + "description": "Product type for this dynamic vector-store provider.", + "default": "pgvector" + }, + "config": { + "$ref": "#/components/schemas/PgvectorVectorStoreProviderConfig", + "title": "Storage config", + "description": "pgvector connection settings for this provider." + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "embedding_model", + "embedding_dimension", + "config" + ], + "title": "PgvectorVectorStoreProvider", + "description": "Dynamic pgvector vector-store provider (runtime create capacity)." + }, + "PgvectorVectorStoreProviderConfig": { + "properties": { + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "PostgreSQL host", + "description": "PostgreSQL host. Defaults to ${env.POSTGRES_HOST}." + }, + "port": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "PostgreSQL port", + "description": "PostgreSQL port. Defaults to ${env.POSTGRES_PORT}." + }, + "db": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "PostgreSQL database", + "description": "PostgreSQL database name. Defaults to ${env.POSTGRES_DATABASE}." + }, + "user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "PostgreSQL user", + "description": "PostgreSQL user. Defaults to ${env.POSTGRES_USER}." + }, + "password": { + "anyOf": [ + { + "type": "string", + "format": "password", + "writeOnly": true + }, + { + "type": "null" + } + ], + "title": "PostgreSQL password", + "description": "PostgreSQL password. Defaults to ${env.POSTGRES_PASSWORD}." + } + }, + "additionalProperties": false, + "type": "object", + "title": "PgvectorVectorStoreProviderConfig", + "description": "Storage config for a pgvector dynamic vector-store provider." + }, "PostgreSQLDatabaseConfiguration": { "properties": { "host": { @@ -20572,6 +20752,48 @@ ], "title": "ValidationError" }, + "VectorStoreConfiguration": { + "properties": { + "default_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Default provider", + "description": "Provider id used for vector_stores.default_* in the synthesized Llama Stack config. Required when providers is non-empty; must match one of providers[].id." + }, + "providers": { + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/FaissVectorStoreProvider" + }, + { + "$ref": "#/components/schemas/PgvectorVectorStoreProvider" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "faiss": "#/components/schemas/FaissVectorStoreProvider", + "pgvector": "#/components/schemas/PgvectorVectorStoreProvider" + } + } + }, + "type": "array", + "title": "Vector store providers", + "description": "Dynamic vector-store provider capacity for runtime POST /v1/vector-stores creates. Not the same as byok_rag (static registered corpora)." + } + }, + "additionalProperties": false, + "type": "object", + "title": "VectorStoreConfiguration", + "description": "Configuration for dynamic vector-store providers.\n\nMirrors ``InferenceConfiguration``: a providers list plus a sibling\n``default_provider`` pointer, rather than a per-entry default flag.\n\nAttributes:\n default_provider: Provider id used for vector_stores.default_* in the\n synthesized Llama Stack config. Required when providers is\n non-empty; must match one of providers[].id. Must be omitted when\n providers is empty.\n providers: Dynamic vector-store provider capacity for runtime\n POST /v1/vector-stores creates. Not the same as byok_rag (static\n registered corpora)." + }, "VectorStoreCreateRequest": { "properties": { "name": { diff --git a/docs/user_doc/byok_guide.md b/docs/user_doc/byok_guide.md index dafdc9355..5299d72ff 100644 --- a/docs/user_doc/byok_guide.md +++ b/docs/user_doc/byok_guide.md @@ -416,6 +416,8 @@ The BYOK (Bring Your Own Knowledge) feature in Lightspeed Core provides powerful For additional support and advanced configurations, refer to: - [RAG Configuration Guide](rag_guide.md) +- [Dynamic vector store providers](rag_guide.md#configure-dynamic-vector-store-providers) (runtime creates) +- [Configuration schema](config.md) - [rag-content Tool Repository](https://github.com/lightspeed-core/rag-content) Remember to regularly update your knowledge sources and monitor system performance to maintain optimal BYOK functionality. diff --git a/docs/user_doc/config.md b/docs/user_doc/config.md index 752dcc0dc..9556c301d 100644 --- a/docs/user_doc/config.md +++ b/docs/user_doc/config.md @@ -247,6 +247,7 @@ Global service configuration. | compaction | | Controls when conversation history is summarized to keep the model's input below the context window limit. Disabled by default — when disabled, requests that exceed the window continue to surface as HTTP 413. | | approvals | | Settings for human-in-the-loop approval of MCP tool invocations | | byok_rag | array | BYOK RAG configuration. This configuration can be used to reconfigure Llama Stack through its run.yaml configuration file | +| vector_store | | Dynamic vector-store provider capacity for runtime POST /v1/vector-stores creates. Not the same as byok_rag (static registered corpora). When providers is non-empty, default_provider is required and must match one of providers[].id. Applied in unified synthesis only. | | a2a_state | | Configuration for A2A protocol persistent state storage. | | quota_handlers | | Quota handlers configuration | | azure_entra_id | | | @@ -315,6 +316,32 @@ Database configuration. | postgres | | PostgreSQL database configuration | +## FaissVectorStoreProvider + + +Dynamic FAISS vector-store provider (runtime create capacity). + + +| Field | Type | Description | +|---------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| id | string | Llama Stack vector_io provider_id. Surrounding whitespace is stripped before validation and emission. Must match ``[a-z0-9_-]+`` and must not start with ``byok_``. | +| type | string | Product type for this dynamic vector-store provider. Must be ``faiss``. | +| embedding_model | string | Embedding model identification used for stores created against this provider. Required. | +| embedding_dimension | integer | Dimensionality of embedding vectors for this provider. Required. | +| config | | FAISS storage settings for this provider. | + + +## FaissVectorStoreProviderConfig + + +Storage config for a FAISS dynamic vector-store provider. + + +| Field | Type | Description | +|-------|--------|----------------------------------------------| +| path | string | On-disk FAISS/SQLite path for this provider. | + + ## InMemoryCacheConfig @@ -489,6 +516,36 @@ Only relevant when ``"okp"`` is listed in ``rag.inline`` or ``rag.tool``. | chunk_filter_query | string | Additional OKP filter query applied to every OKP search request. Use Solr boolean syntax, e.g. 'product:ansible AND product:*openshift*'. | +## PgvectorVectorStoreProvider + + +Dynamic pgvector vector-store provider (runtime create capacity). + + +| Field | Type | Description | +|---------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| id | string | Llama Stack vector_io provider_id. Surrounding whitespace is stripped before validation and emission. Must match ``[a-z0-9_-]+`` and must not start with ``byok_``. | +| type | string | Product type for this dynamic vector-store provider. Must be ``pgvector``. | +| embedding_model | string | Embedding model identification used for stores created against this provider. Required. | +| embedding_dimension | integer | Dimensionality of embedding vectors for this provider. Required. | +| config | | pgvector connection settings for this provider. | + + +## PgvectorVectorStoreProviderConfig + + +Storage config for a pgvector dynamic vector-store provider. + + +| Field | Type | Description | +|----------|--------|-----------------------------------------------------------------| +| host | string | PostgreSQL host. Defaults to ${env.POSTGRES_HOST}. | +| port | string | PostgreSQL port. Defaults to ${env.POSTGRES_PORT}. | +| db | string | PostgreSQL database name. Defaults to ${env.POSTGRES_DATABASE}. | +| user | string | PostgreSQL user. Defaults to ${env.POSTGRES_USER}. | +| password | string | PostgreSQL password. Defaults to ${env.POSTGRES_PASSWORD}. | + + ## PostgreSQLDatabaseConfiguration @@ -866,3 +923,18 @@ User data collection configuration. | feedback_storage | string | Path to directory where feedback will be saved for further processing. | | transcripts_enabled | boolean | When set to true the conversation history is stored and later sent for analysis. | | transcripts_storage | string | Path to directory where conversation history will be saved for further processing. | + + +## VectorStoreConfiguration + + +Configuration for dynamic vector-store providers. + +Mirrors ``InferenceConfiguration``: a providers list plus a sibling +``default_provider`` pointer, rather than a per-entry default flag. + + +| Field | Type | Description | +|------------------|--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| default_provider | string | Provider id used for vector_stores.default_* in the synthesized Llama Stack config. Required when providers is non-empty; must match one of providers[].id. Must be omitted when providers is empty. | +| providers | array | Dynamic vector-store provider capacity for runtime POST /v1/vector-stores creates. Not the same as byok_rag (static registered corpora). | diff --git a/docs/user_doc/rag_guide.md b/docs/user_doc/rag_guide.md index 2aa3a0c87..e888f1265 100644 --- a/docs/user_doc/rag_guide.md +++ b/docs/user_doc/rag_guide.md @@ -16,6 +16,7 @@ This document explains how to configure and customize your RAG pipeline. You wil * [Set Up the Vector Database](#set-up-the-vector-database) * [Download an Embedding Model](#download-an-embedding-model) * [Configure BYOK Knowledge Sources](#configure-byok-knowledge-sources) +* [Configure Dynamic Vector Store Providers](#configure-dynamic-vector-store-providers) * [Add an Inference Model (LLM)](#add-an-inference-model-llm) * [Complete Configuration Reference](#complete-configuration-reference) * [System Prompt Guidance for RAG (as a tool)](#system-prompt-guidance-for-rag-as-a-tool) @@ -34,6 +35,11 @@ Lightspeed Core Stack (LCS) supports two complementary RAG strategies: Both strategies can be enabled independently via the `rag` section of `lightspeed-stack.yaml`. See [BYOK Feature Documentation](byok_guide.md) for configuration details. +For **runtime-created** vector stores (`POST /v1/vector-stores`), configure +[`vector_store`](#configure-dynamic-vector-store-providers) instead of +`byok_rag`. BYOK registers static corpora with a fixed `vector_db_id`; dynamic +providers only declare capacity (provider id, storage, default embeddings). + The **Embedding Model** is used to convert queries and documents into vector representations for similarity matching. > [!NOTE] @@ -130,6 +136,69 @@ byok_rag: --- +## Configure Dynamic Vector Store Providers + +Use `vector_store` when clients create vector stores at runtime +(for example `POST /v1/vector-stores` flows). This is +**not** BYOK: do not put a static corpus here, and do not use a `byok_` +provider id prefix. + +Requirements: + +- Supported types: `faiss`, `pgvector` +- `embedding_model` and `embedding_dimension` are **required** on every provider entry +- When `providers` is non-empty, `default_provider` is **required** and must + match one of `providers[].id` +- When `providers` is empty, `default_provider` must be omitted +- Provider `id` must match `[a-z0-9_-]+` and must not start with `byok_` +- Applied in **unified** Llama Stack synthesis only + (`llama_stack.use_as_library_client: true` with a synthesis input such as + `llama_stack.config`, `inference.providers`, or `vector_store.providers`) + +`default_provider` becomes `vector_stores.default_provider_id` and that +provider's embedding model becomes `default_embedding_model` in the +synthesized Llama Stack config. FAISS entries also get a dedicated storage +backend named `vsprov__storage`. + +### FAISS example + +```yaml +vector_store: + default_provider: example + providers: + - id: example + type: faiss + embedding_model: /example/embeddings_model + embedding_dimension: 768 + config: + path: /example/abc/faiss_store.db +``` + +### pgvector example + +```yaml +vector_store: + default_provider: example-pg + providers: + - id: example-pg + type: pgvector + embedding_model: sentence-transformers/all-mpnet-base-v2 + embedding_dimension: 768 + config: + # host/port/db/user/password optional; default to ${env.POSTGRES_*} + host: ${env.POSTGRES_HOST} + port: ${env.POSTGRES_PORT} + db: ${env.POSTGRES_DATABASE} + user: ${env.POSTGRES_USER} + password: ${env.POSTGRES_PASSWORD} +``` + +Field reference is in the [configuration schema](config.md) +(`VectorStoreConfiguration`, `FaissVectorStoreProvider`, +`PgvectorVectorStoreProvider`). + +--- + ## Add an Inference Model (LLM) ### vLLM on RHEL AI (Llama 3.1) example @@ -330,12 +399,15 @@ the number of retrieved chunks, set the constants in `src/constants.py`: # Complete Configuration Reference -To enable RAG functionality, configure the `byok_rag` and `rag` sections in your `lightspeed-stack.yaml`. +To enable RAG functionality, configure the `byok_rag` and `rag` sections in +your `lightspeed-stack.yaml`. Add `vector_store` when you also need +runtime `POST /v1/vector-stores` capacity. Below is an example of a working `lightspeed-stack.yaml` configuration with: * A local `all-mpnet-base-v2` embedding model -* A `FAISS`-based vector store +* A `FAISS`-based static BYOK corpus +* Optional FAISS capacity for runtime-created stores * Inline and Tool RAG enabled > [!TIP] @@ -356,6 +428,17 @@ byok_rag: vector_db_id: vs_3a7f9b2e-45dc-4e1a-b8f2-1c9d0e3f5a6b db_path: /home/USER/lightspeed-stack/vector_dbs/ocp_docs/faiss_store.db +# Optional: capacity for runtime POST /v1/vector-stores (not a static corpus) +vector_store: + default_provider: example + providers: + - id: example + type: faiss + embedding_model: sentence-transformers/all-mpnet-base-v2 + embedding_dimension: 768 + config: + path: /home/USER/lightspeed-stack/vector_dbs/example/faiss_store.db + rag: inline: - ocp-docs @@ -363,7 +446,12 @@ rag: - ocp-docs ``` -The BYOK vector store providers and registered resources are automatically generated at startup from the `byok_rag` entries above. Models and inference providers must be configured separately in your `run.yaml`. +BYOK providers and registered resources are generated at startup from +`byok_rag`. Dynamic providers and create defaults are generated from +`vector_store` during unified synthesis. Embedding models for +those providers are registered automatically when needed. Inference models +and providers must still be configured separately (for example in your +baseline / profile `run.yaml`). --- @@ -381,3 +469,8 @@ You are a helpful assistant with access to a 'knowledge_search' tool. When users The top-level `vector_stores` block in [`run.yaml`](../examples/run.yaml) may include `annotation_prompt_params` to control whether extra RAG annotation instructions are injected into the model prompt (for example, citation-style markers). The default configuration sets `enable_annotations: false` under that block to avoid unwanted annotations. +When `vector_store` is configured, `default_provider` overwrites +`vector_stores.default_provider_id` and `default_embedding_model` +during unified synthesis. Annotation settings are not managed by that enricher +— keep them in the Llama Stack baseline/profile or `native_override`. + diff --git a/src/llama_stack_configuration.py b/src/llama_stack_configuration.py index 4e82bfbf8..68f344b1a 100644 --- a/src/llama_stack_configuration.py +++ b/src/llama_stack_configuration.py @@ -81,6 +81,11 @@ }, } +VECTOR_STORE_PROVIDER_TYPE_MAP: dict[str, str] = { + "faiss": "inline::faiss", + "pgvector": "remote::pgvector", +} + class YamlDumper(yaml.Dumper): # pylint: disable=too-many-ancestors """Custom YAML dumper with proper indentation levels.""" @@ -355,17 +360,21 @@ def construct_models_section( def _build_vector_io_config( - rag_type: str, backend_name: str, brag: dict[str, Any] + rag_type: str, backend_name: str, extra_fields: dict[str, Any] ) -> dict[str, Any]: """Build the provider config dict from VECTOR_IO_TEMPLATES. Parameters: rag_type: Llama Stack provider type (e.g. 'inline::faiss', 'remote::pgvector'). backend_name: Storage backend name (used when template has '{backend_name}'). - brag: BYOK RAG entry dict — extra_fields are read from here. + extra_fields: Source values for template ``extra_fields`` (e.g. db_path, + host/port/db/user/password). Used by BYOK and vector_store.providers. Returns: dict[str, Any]: Provider config mapping. + + Raises: + ValueError: If ``rag_type`` is not present in VECTOR_IO_TEMPLATES. """ template = VECTOR_IO_TEMPLATES.get(rag_type) if template is None: @@ -383,7 +392,7 @@ def _build_vector_io_config( } } for field, default in template.get("extra_fields", {}).items(): - value = brag.get(field) + value = extra_fields.get(field) if isinstance(value, SecretStr): value = value.get_secret_value() if value is None or (isinstance(value, str) and not value.strip()): @@ -501,6 +510,258 @@ def enrich_byok_rag(ls_config: dict[str, Any], byok_rag: list[dict[str, Any]]) - ) +# ============================================================================= +# Enrichment: vector_store +# ============================================================================= + + +def _vector_store_provider_by_id( + providers: list[dict[str, Any]], provider_id: str | None +) -> dict[str, Any] | None: + """Return the provider entry matching ``provider_id``. + + Parameters: + providers: High-level ``vector_store.providers`` entries. + provider_id: Id from ``vector_store.default_provider``. + + Returns: + Matching provider dict, or None when unset / not found. + """ + if provider_id is None: + return None + cleaned = provider_id.strip() + if not cleaned: + return None + for provider in providers: + if str(provider.get("id", "")).strip() == cleaned: + return provider + return None + + +def _upsert_vsprov_embedding_model( + ls_config: dict[str, Any], + provider_id: str, + embedding_model: str, + embedding_dimension: int, +) -> None: + """Register an embedding model if provider_model_id is not already present. + + Dedupes against BYOK/baseline rows by ``provider_model_id`` (after stripping + a leading ``sentence-transformers/`` prefix). + + Parameters: + ls_config: Llama Stack configuration modified in place. + provider_id: Dynamic provider id used to name the model row. + embedding_model: Configured embedding model path or id. + embedding_dimension: Embedding vector dimensionality (required on + validated ``vector_store.providers`` entries). + """ + models = ls_config.setdefault("registered_resources", {}).setdefault("models", []) + provider_model_id = embedding_model.removeprefix("sentence-transformers/") + if any(model.get("provider_model_id") == provider_model_id for model in models): + return + models.append( + { + "model_id": f"vsprov_{provider_id}_embedding", + "model_type": "embedding", + "provider_id": "sentence-transformers", + "provider_model_id": provider_model_id, + "metadata": {"embedding_dimension": embedding_dimension}, + } + ) + + +def _vsprov_fields_and_backend( + product_type: str, provider_id: str, cfg: dict[str, Any] +) -> tuple[dict[str, Any], str, dict[str, Any] | None]: + """Build template extra fields and optional faiss storage backend. + + Parameters: + product_type: Product type (``faiss`` or ``pgvector``). + provider_id: Dynamic provider id. + cfg: Nested provider ``config`` dict. + + Returns: + Tuple of (extra_fields, backend_name, backend_entry_or_None). + + Raises: + ValueError: If ``product_type`` is not a supported vector-store + provider type. + """ + backend_name = f"vsprov_{provider_id}_storage" + if product_type == "faiss": + return ( + {"db_path": cfg["path"]}, + backend_name, + {"type": "kv_sqlite", "db_path": cfg["path"]}, + ) + if product_type == "pgvector": + return ( + { + "host": cfg.get("host"), + "port": cfg.get("port"), + "db": cfg.get("db"), + "user": cfg.get("user"), + "password": cfg.get("password"), + }, + backend_name, + None, + ) + raise ValueError( + f"Unsupported vector_store.providers type '{product_type}'. " + f"Supported types: {list(VECTOR_STORE_PROVIDER_TYPE_MAP)}" + ) + + +def _replace_or_append_vector_io( + vector_io: list[dict[str, Any]], + existing_ids: set[str], + provider_entry: dict[str, Any], +) -> None: + """Replace a vector_io entry with the same provider_id, else append. + + Parameters: + vector_io: Mutable providers.vector_io list. + existing_ids: Set of provider_ids already present (updated on append). + provider_entry: New provider entry to install. + """ + provider_id = provider_entry["provider_id"] + if provider_id not in existing_ids: + vector_io.append(provider_entry) + existing_ids.add(provider_id) + return + + for index, existing in enumerate(vector_io): + if isinstance(existing, dict) and existing.get("provider_id") == provider_id: + logger.info( + "Replacing existing vector_io provider with " + "provider_id=%r from vector_store.providers", + provider_id, + ) + vector_io[index] = provider_entry + return + + +def _apply_vector_stores_defaults( + ls_config: dict[str, Any], designated: dict[str, Any] +) -> None: + """Write vector_stores.default_* from the designated provider entry. + + Parameters: + ls_config: Llama Stack configuration modified in place. + designated: Provider entry selected by ``vector_store.default_provider``. + """ + vector_stores = ls_config.get("vector_stores") + if not isinstance(vector_stores, dict): + vector_stores = {} + ls_config["vector_stores"] = vector_stores + vector_stores["default_provider_id"] = str(designated["id"]).strip() + emb = designated.get("embedding_model") + if emb: + vector_stores["default_embedding_model"] = { + "provider_id": "sentence-transformers", + "model_id": emb, + } + + +def _enrich_one_vector_store_provider( + entry: dict[str, Any], + backends: dict[str, Any], + vector_io: list[Any], + existing_ids: set[str], + ls_config: dict[str, Any], +) -> None: + """Enrich LS config for a single ``vector_store.providers`` entry. + + Parameters: + entry: One high-level provider dict from Lightspeed config. + backends: ``storage.backends`` map (modified in place for faiss). + vector_io: ``providers.vector_io`` list (modified in place). + existing_ids: Known ``provider_id`` values already in ``vector_io``. + ls_config: Full Llama Stack config (for embedding model registration). + """ + provider_id = str(entry["id"]).strip() + product_type = entry["type"] + ls_type = VECTOR_STORE_PROVIDER_TYPE_MAP[product_type] + extra_fields, backend_name, backend_entry = _vsprov_fields_and_backend( + product_type, provider_id, entry.get("config") or {} + ) + if backend_entry is not None: + backends[backend_name] = backend_entry + + _replace_or_append_vector_io( + vector_io, + existing_ids, + { + "provider_id": provider_id, + "provider_type": ls_type, + "config": _build_vector_io_config(ls_type, backend_name, extra_fields), + }, + ) + + embedding_model = entry.get("embedding_model") + embedding_dimension = entry.get("embedding_dimension") + if embedding_model and embedding_dimension is not None: + _upsert_vsprov_embedding_model( + ls_config, + provider_id=provider_id, + embedding_model=embedding_model, + embedding_dimension=embedding_dimension, + ) + + +def enrich_vector_store( + ls_config: dict[str, Any], + vector_store: dict[str, Any] | None = None, +) -> None: + """Enrich LS config with dynamic vector-store provider capacity. + + Appends or replaces ``providers.vector_io`` entries and faiss storage + backends, registers embedding models when needed, and writes + ``vector_stores.default_provider_id`` / ``default_embedding_model`` from + ``vector_store.default_provider``. Does not register + ``registered_resources.vector_stores``. + + Parameters: + ls_config: Llama Stack configuration dictionary (modified in place). + vector_store: High-level ``vector_store`` section + (``default_provider`` + ``providers``) as a dict. + """ + vector_store = vector_store or {} + providers = vector_store.get("providers") or [] + if not providers: + logger.debug("vector_store.providers not configured: skipping") + dedupe_providers_vector_io(ls_config) + return + + backends = ls_config.setdefault("storage", {}).setdefault("backends", {}) + providers_section = ls_config.setdefault("providers", {}) + vector_io = providers_section.get("vector_io") + if not isinstance(vector_io, list): + vector_io = [] + providers_section["vector_io"] = vector_io + ls_config.setdefault("registered_resources", {}).setdefault("models", []) + + existing_ids = { + str(entry.get("provider_id")).strip() + for entry in vector_io + if isinstance(entry, dict) and entry.get("provider_id") + } + + for entry in providers: + _enrich_one_vector_store_provider( + entry, backends, vector_io, existing_ids, ls_config + ) + + designated = _vector_store_provider_by_id( + providers, vector_store.get("default_provider") + ) + if designated is not None: + _apply_vector_stores_defaults(ls_config, designated) + + dedupe_providers_vector_io(ls_config) + + # ============================================================================= # Enrichment: Solr # ============================================================================= @@ -897,6 +1158,7 @@ def synthesize_configuration( # unified output matches legacy output for equivalent inputs (R7). enrich_azure_entra_id_inference(ls_config, lcs_config.get("azure_entra_id")) enrich_byok_rag(ls_config, lcs_config.get("byok_rag", [])) + enrich_vector_store(ls_config, lcs_config.get("vector_store")) enrich_solr(ls_config, lcs_config.get("rag", {}), lcs_config.get("okp", {})) # 5. High-level inference providers (Decision S5 — a root-level section). diff --git a/src/models/config.py b/src/models/config.py index 28ff23264..c5f0aa639 100644 --- a/src/models/config.py +++ b/src/models/config.py @@ -7,7 +7,7 @@ from functools import cached_property from pathlib import Path from re import Pattern -from typing import Any, Literal, Optional, Self +from typing import Annotated, Any, Literal, Optional, Self import jsonpath_ng import yaml @@ -2129,6 +2129,250 @@ def validate_rag_type_fields(self) -> Self: return self +class FaissVectorStoreProviderConfig(ConfigurationBase): + """Storage config for a FAISS dynamic vector-store provider.""" + + path: str = Field( + ..., + min_length=1, + title="DB path", + description="On-disk FAISS/SQLite path for this provider.", + ) + + +class PgvectorVectorStoreProviderConfig(ConfigurationBase): + """Storage config for a pgvector dynamic vector-store provider.""" + + host: Optional[str] = Field( + default=None, + title="PostgreSQL host", + description="PostgreSQL host. Defaults to ${env.POSTGRES_HOST}.", + ) + + port: Optional[str] = Field( + default=None, + title="PostgreSQL port", + description="PostgreSQL port. Defaults to ${env.POSTGRES_PORT}.", + ) + + db: Optional[str] = Field( + default=None, + title="PostgreSQL database", + description="PostgreSQL database name. Defaults to ${env.POSTGRES_DATABASE}.", + ) + + user: Optional[str] = Field( + default=None, + title="PostgreSQL user", + description="PostgreSQL user. Defaults to ${env.POSTGRES_USER}.", + ) + + password: Optional[SecretStr] = Field( + default=None, + title="PostgreSQL password", + description="PostgreSQL password. Defaults to ${env.POSTGRES_PASSWORD}.", + ) + + @model_validator(mode="after") + def apply_pgvector_env_defaults(self) -> Self: + """Fill unset connection fields with ${env.POSTGRES_*} references.""" + pgvector_defaults: dict[str, str | SecretStr] = { + "host": "${env.POSTGRES_HOST}", + "port": "${env.POSTGRES_PORT}", + "db": "${env.POSTGRES_DATABASE}", + "user": "${env.POSTGRES_USER}", + "password": SecretStr("${env.POSTGRES_PASSWORD}"), + } + for field_name, default_value in pgvector_defaults.items(): + if getattr(self, field_name) is None: + object.__setattr__(self, field_name, default_value) + return self + + +class VectorStoreProviderBase(ConfigurationBase): + """Shared fields for dynamic vector-store provider capacity entries. + + Attributes: + id: Llama Stack vector_io provider_id. Surrounding whitespace is + stripped before validation and emission. + embedding_model: Embedding model identification used for stores + created against this provider. + embedding_dimension: Dimensionality of embedding vectors for this + provider. + """ + + id: str = Field( + ..., + min_length=1, + title="Provider ID", + description=( + "Llama Stack vector_io provider_id. Surrounding whitespace is " + "stripped before validation and emission." + ), + ) + embedding_model: str = Field( + ..., + min_length=1, + title="Embedding model", + description="Embedding model identification used for stores created " + "against this provider.", + ) + embedding_dimension: PositiveInt = Field( + ..., + title="Embedding dimension", + description="Dimensionality of embedding vectors for this provider.", + ) + + @field_validator("id") + @classmethod + def validate_id(cls, value: str) -> str: + """Validate and normalize the provider id. + + Parameters: + value: Raw provider id from configuration. + + Returns: + Stripped provider id. + + Raises: + ValueError: If the id is empty, uses the reserved ``byok_`` prefix, + or contains characters outside ``[a-z0-9_-]``. + """ + stripped = value.strip() + if not stripped: + raise ValueError("id must be non-empty after stripping whitespace") + if stripped.startswith("byok_"): + raise ValueError("id must not start with 'byok_' (reserved for BYOK RAG)") + if not re.fullmatch(r"[a-z0-9_-]+", stripped): + raise ValueError( + "id may contain only lowercase letters, digits, " + "underscores, and hyphens" + ) + return stripped + + +class FaissVectorStoreProvider(VectorStoreProviderBase): + """Dynamic FAISS vector-store provider (runtime create capacity).""" + + type: Literal["faiss"] = Field( + "faiss", + title="Provider type", + description="Product type for this dynamic vector-store provider.", + ) + config: FaissVectorStoreProviderConfig = Field( + ..., + title="Storage config", + description="FAISS storage settings for this provider.", + ) + + +class PgvectorVectorStoreProvider(VectorStoreProviderBase): + """Dynamic pgvector vector-store provider (runtime create capacity).""" + + type: Literal["pgvector"] = Field( + "pgvector", + title="Provider type", + description="Product type for this dynamic vector-store provider.", + ) + config: PgvectorVectorStoreProviderConfig = Field( + ..., + title="Storage config", + description="pgvector connection settings for this provider.", + ) + + +VectorStoreProvider = Annotated[ + FaissVectorStoreProvider | PgvectorVectorStoreProvider, + Field(discriminator="type"), +] + + +class VectorStoreConfiguration(ConfigurationBase): + """Configuration for dynamic vector-store providers. + + Mirrors ``InferenceConfiguration``: a providers list plus a sibling + ``default_provider`` pointer, rather than a per-entry default flag. + + Attributes: + default_provider: Provider id used for vector_stores.default_* in the + synthesized Llama Stack config. Required when providers is + non-empty; must match one of providers[].id. Must be omitted when + providers is empty. + providers: Dynamic vector-store provider capacity for runtime + POST /v1/vector-stores creates. Not the same as byok_rag (static + registered corpora). + """ + + default_provider: Optional[str] = Field( + None, + title="Default provider", + description=( + "Provider id used for vector_stores.default_* in the synthesized " + "Llama Stack config. Required when providers is non-empty; must " + "match one of providers[].id." + ), + ) + + providers: list[VectorStoreProvider] = Field( + default_factory=list, + title="Vector store providers", + description=( + "Dynamic vector-store provider capacity for runtime " + "POST /v1/vector-stores creates. " + "Not the same as byok_rag (static registered corpora)." + ), + ) + + @model_validator(mode="after") + def validate_providers_and_default(self) -> Self: + """Validate providers list and default_provider pointer. + + When providers is empty, default_provider must be unset. When + providers is non-empty, default_provider is required and must match + exactly one providers[].id. Provider ids must be unique. + + Returns: + Self: The validated configuration instance. + + Raises: + ValueError: If ids are duplicated, default_provider is missing + for a non-empty list, is set for an empty list, or does not + match a provider id. + """ + # pylint: disable=no-member + if not self.providers: + if self.default_provider is not None: + raise ValueError( + "vector_store.default_provider must not be set when " + "providers is empty" + ) + return self + + if self.default_provider is None: + raise ValueError( + "vector_store.default_provider is required when providers " + "is non-empty" + ) + + ids = [provider.id for provider in self.providers] + if len(ids) != len(set(ids)): + raise ValueError(f"vector_store.providers ids must be unique; got {ids}") + + default_provider = self.default_provider.strip() + if not default_provider: + raise ValueError( + "vector_store.default_provider must be non-empty after " + "stripping whitespace" + ) + if default_provider not in ids: + raise ValueError( + "vector_store.default_provider must match one of " + f"providers[].id; got {default_provider!r}, known ids: {ids}" + ) + object.__setattr__(self, "default_provider", default_provider) + return self + + class QuotaLimiterConfiguration(ConfigurationBase): """Configuration for one quota limiter. @@ -2708,6 +2952,21 @@ class Configuration(ConfigurationBase): "reconfigure Llama Stack through its run.yaml configuration file", ) + vector_store: VectorStoreConfiguration = Field( + default_factory=lambda: VectorStoreConfiguration( + default_provider=None, providers=[] + ), + title="Vector store configuration", + description=( + "Dynamic vector-store provider capacity for runtime " + "POST /v1/vector-stores creates. " + "Not the same as byok_rag (static registered corpora). " + "When providers is non-empty, default_provider is required and " + "must match one of providers[].id. Applied in unified synthesis " + "only." + ), + ) + a2a_state: A2AStateConfiguration = Field( default_factory=A2AStateConfiguration, title="A2A state configuration", @@ -2919,17 +3178,18 @@ def check_unified_vs_legacy(self) -> Self: """Reconcile unified synthesis inputs, legacy mode, and library-mode needs. Unified-mode *synthesis inputs* span the configuration root: a non-empty - top-level ``inference.providers`` (Decision S5) and/or a - ``llama_stack.config`` block. The legacy path is - ``llama_stack.library_client_config_path`` pointing at an external - run.yaml. Both checks live here on the root model rather than on - ``LlamaStackConfiguration`` (which cannot see ``inference.providers``): + top-level ``inference.providers`` (Decision S5), a non-empty + ``vector_store.providers``, and/or a ``llama_stack.config`` block. The + legacy path is ``llama_stack.library_client_config_path`` pointing at an + external run.yaml. Both checks live here on the root model rather than + on ``LlamaStackConfiguration`` (which cannot see root-level provider + lists): - A synthesis input and the legacy path are mutually exclusive — a single file must pick one shape. - Library mode needs *some* run source — a synthesis input or the - legacy path. ``inference.providers`` alone is sufficient; no - ``llama_stack.config`` block is required. + legacy path. ``inference.providers`` or ``vector_store.providers`` + alone is sufficient; no ``llama_stack.config`` block is required. Returns: Self: The validated configuration instance. @@ -2941,14 +3201,17 @@ def check_unified_vs_legacy(self) -> Self: """ # pylint: disable=no-member synthesis_input = ( - bool(self.inference.providers) or self.llama_stack.config is not None + bool(self.inference.providers) + or bool(self.vector_store.providers) + or self.llama_stack.config is not None ) legacy_input = self.llama_stack.library_client_config_path is not None if synthesis_input and legacy_input: raise ValueError( "Llama Stack configuration is ambiguous: unified synthesis " - "inputs (a non-empty inference.providers or a llama_stack.config " - "block) are mutually exclusive with the legacy " + "inputs (a non-empty inference.providers, a non-empty " + "vector_store.providers, or a llama_stack.config block) are " + "mutually exclusive with the legacy " "llama_stack.library_client_config_path. Use one or the other. " "To convert a legacy two-file setup to unified mode, run " "`lightspeed-stack --migrate-config`." @@ -2960,8 +3223,9 @@ def check_unified_vs_legacy(self) -> Self: ): raise ValueError( "Llama Stack library mode requires a run-configuration source: " - "set a non-empty inference.providers, a llama_stack.config " - "block, or library_client_config_path." + "set a non-empty inference.providers, a non-empty " + "vector_store.providers, a llama_stack.config block, or " + "library_client_config_path." ) return self diff --git a/tests/unit/models/config/test_dump_configuration.py b/tests/unit/models/config/test_dump_configuration.py index e1cd67816..5aae2db4b 100644 --- a/tests/unit/models/config/test_dump_configuration.py +++ b/tests/unit/models/config/test_dump_configuration.py @@ -16,6 +16,8 @@ Configuration, CORSConfiguration, DatabaseConfiguration, + FaissVectorStoreProvider, + FaissVectorStoreProviderConfig, InferenceConfiguration, LlamaStackConfiguration, ModelContextProtocolServer, @@ -26,7 +28,9 @@ ServiceConfiguration, SkillsConfiguration, TLSConfiguration, + UnifiedLlamaStackConfig, UserDataCollection, + VectorStoreConfiguration, ) _DEFAULT_APPROVALS_DUMP: dict[str, int] = { @@ -194,6 +198,10 @@ def test_dump_configuration_minimal_cfg(tmp_path: Path) -> None: }, "approvals": _DEFAULT_APPROVALS_DUMP, "byok_rag": [], + "vector_store": { + "default_provider": None, + "providers": [], + }, "quota_handlers": { "sqlite": None, "postgres": None, @@ -417,6 +425,10 @@ def test_dump_configuration_valid_values(tmp_path: Path) -> None: }, "approvals": _DEFAULT_APPROVALS_DUMP, "byok_rag": [], + "vector_store": { + "default_provider": None, + "providers": [], + }, "quota_handlers": { "sqlite": None, "postgres": None, @@ -776,6 +788,10 @@ def test_dump_configuration_with_quota_limiters(tmp_path: Path) -> None: }, "approvals": _DEFAULT_APPROVALS_DUMP, "byok_rag": [], + "vector_store": { + "default_provider": None, + "providers": [], + }, "quota_handlers": { "sqlite": None, "postgres": None, @@ -1034,6 +1050,10 @@ def test_dump_configuration_with_quota_limiters_different_values( }, "approvals": _DEFAULT_APPROVALS_DUMP, "byok_rag": [], + "vector_store": { + "default_provider": None, + "providers": [], + }, "quota_handlers": { "sqlite": None, "postgres": None, @@ -1089,6 +1109,59 @@ def test_dump_configuration_with_quota_limiters_different_values( } +def test_dump_configuration_with_vector_store(tmp_path: Path) -> None: + """Dump preserves a configured vector_store default_provider and providers.""" + cfg = Configuration( + name="test_name", + service=ServiceConfiguration( + tls_config=TLSConfiguration( + tls_certificate_path=Path("tests/configuration/server.crt"), + tls_key_path=Path("tests/configuration/server.key"), + tls_key_password=Path("tests/configuration/password"), + ), + cors=CORSConfiguration(), + ), + llama_stack=LlamaStackConfiguration( + use_as_library_client=True, + config=UnifiedLlamaStackConfig(baseline="default"), + api_key=SecretStr("whatever"), + ), + user_data_collection=UserDataCollection( + feedback_enabled=False, feedback_storage=None + ), + vector_store=VectorStoreConfiguration( + default_provider="notebooks", + providers=[ + FaissVectorStoreProvider( + id="notebooks", + type="faiss", + embedding_model="/rag-content/embeddings_model", + embedding_dimension=768, + config=FaissVectorStoreProviderConfig(path="/var/lib/notebooks.db"), + ) + ], + ), + ) + dump_file = tmp_path / "test.json" + cfg.dump(dump_file) + + with open(dump_file, "r", encoding="utf-8") as fin: + content = json.load(fin) + + assert content["vector_store"] == { + "default_provider": "notebooks", + "providers": [ + { + "id": "notebooks", + "type": "faiss", + "embedding_model": "/rag-content/embeddings_model", + "embedding_dimension": 768, + "config": {"path": "/var/lib/notebooks.db"}, + } + ], + } + + def test_dump_configuration_byok(tmp_path: Path) -> None: """ Test that the Configuration object can be serialized to a JSON file and @@ -1287,6 +1360,10 @@ def test_dump_configuration_byok(tmp_path: Path) -> None: "password": None, }, ], + "vector_store": { + "default_provider": None, + "providers": [], + }, "quota_handlers": { "sqlite": None, "postgres": None, @@ -1505,6 +1582,10 @@ def test_dump_configuration_pg_namespace(tmp_path: Path) -> None: }, "approvals": _DEFAULT_APPROVALS_DUMP, "byok_rag": [], + "vector_store": { + "default_provider": None, + "providers": [], + }, "quota_handlers": { "sqlite": None, "postgres": None, @@ -1883,6 +1964,10 @@ def test_dump_configuration_allow_degraded_mode(tmp_path: Path) -> None: }, "approvals": _DEFAULT_APPROVALS_DUMP, "byok_rag": [], + "vector_store": { + "default_provider": None, + "providers": [], + }, "quota_handlers": { "sqlite": None, "postgres": None, @@ -2107,6 +2192,10 @@ def test_dump_configuration_max_retries_settings(tmp_path: Path) -> None: }, "approvals": _DEFAULT_APPROVALS_DUMP, "byok_rag": [], + "vector_store": { + "default_provider": None, + "providers": [], + }, "quota_handlers": { "sqlite": None, "postgres": None, @@ -2331,6 +2420,10 @@ def test_dump_configuration_retry_count_settings(tmp_path: Path) -> None: }, "approvals": _DEFAULT_APPROVALS_DUMP, "byok_rag": [], + "vector_store": { + "default_provider": None, + "providers": [], + }, "quota_handlers": { "sqlite": None, "postgres": None, @@ -2562,6 +2655,10 @@ def test_dump_configuration_specific_compaction_values(tmp_path: Path) -> None: }, "approvals": _DEFAULT_APPROVALS_DUMP, "byok_rag": [], + "vector_store": { + "default_provider": None, + "providers": [], + }, "quota_handlers": { "sqlite": None, "postgres": None, diff --git a/tests/unit/models/config/test_llama_stack_configuration.py b/tests/unit/models/config/test_llama_stack_configuration.py index cdaed0059..239815d8f 100644 --- a/tests/unit/models/config/test_llama_stack_configuration.py +++ b/tests/unit/models/config/test_llama_stack_configuration.py @@ -259,6 +259,53 @@ def test_root_rejects_inference_providers_and_legacy_path_together() -> None: Configuration(**config_dict) +def test_root_rejects_vector_store_providers_and_legacy_path_together() -> None: + """Non-empty vector_store.providers plus a legacy path fail at load.""" + config_dict = _base_config_dict() + config_dict["llama_stack"] = { + "use_as_library_client": True, + "library_client_config_path": "tests/configuration/run.yaml", + } + config_dict["vector_store"] = { + "default_provider": "notebooks", + "providers": [ + { + "id": "notebooks", + "type": "faiss", + "embedding_model": "/rag-content/embeddings_model", + "embedding_dimension": 768, + "config": {"path": "/var/lib/notebooks.db"}, + } + ], + } + with pytest.raises(ValidationError, match="mutually exclusive"): + Configuration(**config_dict) + + +def test_root_accepts_vector_store_providers_only_no_config_block() -> None: + """Library mode driven by vector_store.providers alone is valid.""" + config_dict = _base_config_dict() + config_dict["llama_stack"] = {"use_as_library_client": True} + config_dict["inference"] = {"providers": []} + config_dict["vector_store"] = { + "default_provider": "notebooks", + "providers": [ + { + "id": "notebooks", + "type": "faiss", + "embedding_model": "/rag-content/embeddings_model", + "embedding_dimension": 768, + "config": {"path": "/var/lib/notebooks.db"}, + } + ], + } + cfg = Configuration(**config_dict) + # pylint: disable=no-member + assert cfg.llama_stack.config is None + assert cfg.vector_store.default_provider == "notebooks" + assert len(cfg.vector_store.providers) == 1 + + def test_root_accepts_unified_library_config() -> None: """A unified library-mode config (no legacy path) loads cleanly (R1).""" config_dict = _base_config_dict() diff --git a/tests/unit/models/config/test_vector_store.py b/tests/unit/models/config/test_vector_store.py new file mode 100644 index 000000000..ad3c13fba --- /dev/null +++ b/tests/unit/models/config/test_vector_store.py @@ -0,0 +1,219 @@ +"""Unit tests for vector_store configuration models.""" + +import copy +from typing import Any + +import pytest +import yaml +from pydantic import SecretStr, TypeAdapter, ValidationError + +from models.config import Configuration, VectorStoreProvider + +_PROVIDER_ADAPTER = TypeAdapter(VectorStoreProvider) + +_BASE_CONFIG_PATH = "tests/configuration/lightspeed-stack.yaml" + + +def _base_config_dict() -> dict[str, Any]: + """Load the base lightspeed-stack.yaml fixture as a fresh dict.""" + with open(_BASE_CONFIG_PATH, "r", encoding="utf-8") as file: + return copy.deepcopy(yaml.safe_load(file)) + + +def _faiss_provider( + *, + provider_id: str = "notebooks", + path: str = "/var/lib/notebooks.db", +) -> dict[str, Any]: + """Return a minimal valid faiss vector_store.providers entry.""" + return { + "id": provider_id, + "type": "faiss", + "embedding_model": "/rag-content/embeddings_model", + "embedding_dimension": 768, + "config": {"path": path}, + } + + +def test_faiss_provider_accepts_nested_config() -> None: + """Faiss provider accepts nested config and required embedding fields.""" + provider = _PROVIDER_ADAPTER.validate_python( + { + "id": "notebooks", + "type": "faiss", + "embedding_model": "/rag-content/embeddings_model", + "embedding_dimension": 768, + "config": {"path": "/var/lib/notebooks.db"}, + } + ) + assert provider.id == "notebooks" + assert provider.type == "faiss" + assert provider.config.path == "/var/lib/notebooks.db" + assert provider.embedding_dimension == 768 + + +def test_faiss_requires_path() -> None: + """Faiss provider rejects missing path under config.""" + with pytest.raises(ValidationError): + _PROVIDER_ADAPTER.validate_python( + { + "id": "notebooks", + "type": "faiss", + "embedding_model": "/emb", + "embedding_dimension": 768, + "config": {}, + } + ) + + +def test_requires_embedding_model_and_dimension() -> None: + """Provider entry requires embedding_model and embedding_dimension.""" + with pytest.raises(ValidationError): + _PROVIDER_ADAPTER.validate_python( + { + "id": "notebooks", + "type": "faiss", + "config": {"path": "/tmp/x.db"}, + } + ) + + +def test_pgvector_applies_env_defaults() -> None: + """Pgvector provider fills unset connection fields with env placeholders.""" + provider = _PROVIDER_ADAPTER.validate_python( + { + "id": "nb-pg", + "type": "pgvector", + "embedding_model": "/emb", + "embedding_dimension": 768, + "config": {}, + } + ) + assert provider.config.host == "${env.POSTGRES_HOST}" + assert provider.config.password == SecretStr("${env.POSTGRES_PASSWORD}") + + +def test_rejects_byok_prefix_id() -> None: + """Provider id must not use the byok_ prefix reserved for BYOK RAG.""" + with pytest.raises(ValidationError, match="byok_"): + _PROVIDER_ADAPTER.validate_python( + { + "id": "byok_notebooks", + "type": "faiss", + "embedding_model": "/emb", + "embedding_dimension": 768, + "config": {"path": "/tmp/x.db"}, + } + ) + + +def test_rejects_invalid_id_chars() -> None: + """Provider id must match [a-z0-9_-]+.""" + with pytest.raises(ValidationError): + _PROVIDER_ADAPTER.validate_python( + { + "id": "NoteBooks", + "type": "faiss", + "embedding_model": "/emb", + "embedding_dimension": 768, + "config": {"path": "/tmp/x.db"}, + } + ) + + +def test_rejects_unknown_type() -> None: + """Unknown product type values are rejected.""" + with pytest.raises(ValidationError): + _PROVIDER_ADAPTER.validate_python( + { + "id": "x", + "type": "chroma", + "embedding_model": "/emb", + "embedding_dimension": 768, + "config": {"path": "/tmp/x.db"}, + } + ) + + +def test_rejects_non_empty_providers_without_default_provider() -> None: + """Non-empty providers without default_provider is rejected.""" + config_dict = _base_config_dict() + config_dict["vector_store"] = {"providers": [_faiss_provider()]} + with pytest.raises(ValidationError, match="default_provider"): + Configuration(**config_dict) + + +def test_rejects_default_provider_not_in_providers() -> None: + """default_provider must match one of providers[].id.""" + config_dict = _base_config_dict() + config_dict["vector_store"] = { + "default_provider": "missing", + "providers": [_faiss_provider(provider_id="notebooks")], + } + with pytest.raises(ValidationError, match="default_provider"): + Configuration(**config_dict) + + +def test_rejects_default_provider_when_providers_empty() -> None: + """default_provider must not be set when providers is empty.""" + config_dict = _base_config_dict() + config_dict["vector_store"] = { + "default_provider": "notebooks", + "providers": [], + } + with pytest.raises(ValidationError, match="default_provider"): + Configuration(**config_dict) + + +def test_duplicate_ids_rejected() -> None: + """Provider ids must be unique within vector_store.providers.""" + config_dict = _base_config_dict() + config_dict["vector_store"] = { + "default_provider": "notebooks", + "providers": [ + _faiss_provider(provider_id="notebooks", path="/tmp/a.db"), + _faiss_provider(provider_id="notebooks", path="/tmp/b.db"), + ], + } + with pytest.raises(ValidationError, match="unique|duplicate"): + Configuration(**config_dict) + + +def test_rejects_whitespace_only_default_provider() -> None: + """Whitespace-only default_provider is rejected.""" + config_dict = _base_config_dict() + config_dict["vector_store"] = { + "default_provider": " ", + "providers": [_faiss_provider(provider_id="notebooks")], + } + with pytest.raises(ValidationError, match="default_provider"): + Configuration(**config_dict) + + +def test_empty_providers_ok_without_default_provider() -> None: + """Empty providers list is valid without a designated default.""" + config_dict = _base_config_dict() + config_dict["vector_store"] = {"providers": []} + cfg = Configuration(**config_dict) + # pylint: disable=no-member + assert cfg.vector_store.providers == [] + assert cfg.vector_store.default_provider is None + + +def test_default_provider_accepted() -> None: + """Non-empty providers with matching default_provider validates.""" + config_dict = _base_config_dict() + config_dict["vector_store"] = { + "default_provider": "notebooks", + "providers": [ + _faiss_provider(provider_id="notebooks"), + _faiss_provider(provider_id="other", path="/tmp/other.db"), + ], + } + cfg = Configuration(**config_dict) + # pylint: disable=no-member + assert cfg.vector_store.default_provider == "notebooks" + assert [provider.id for provider in cfg.vector_store.providers] == [ + "notebooks", + "other", + ] diff --git a/tests/unit/test_llama_stack_configuration.py b/tests/unit/test_llama_stack_configuration.py index 9b23e8baa..74bd9d3fa 100644 --- a/tests/unit/test_llama_stack_configuration.py +++ b/tests/unit/test_llama_stack_configuration.py @@ -1,5 +1,7 @@ """Unit tests for src/llama_stack_configuration.py.""" +# pylint: disable=too-many-lines + from pathlib import Path from typing import Any @@ -16,6 +18,7 @@ enrich_azure_entra_id_inference, enrich_byok_rag, enrich_solr, + enrich_vector_store, generate_configuration, ) from models.config import ( @@ -881,6 +884,326 @@ def test_enrich_solr_user_chunk_filter_query_is_conjoined() -> None: ) +# ============================================================================= +# Test enrich_vector_store +# ============================================================================= + + +def test_enrich_vector_store_faiss_appends() -> None: + """Faiss provider appends vector_io, backend, and default_* settings.""" + ls_config: dict[str, Any] = { + "providers": { + "vector_io": [ + { + "provider_id": "faiss", + "provider_type": "inline::faiss", + "config": { + "persistence": { + "namespace": "vector_io::faiss", + "backend": "kv_default", + } + }, + } + ] + }, + "storage": { + "backends": {"kv_default": {"type": "kv_sqlite", "db_path": "/tmp/kv.db"}} + }, + "registered_resources": {"models": [], "vector_stores": []}, + "vector_stores": { + "annotation_prompt_params": {"enable_annotations": False}, + "default_provider_id": "faiss", + }, + } + enrich_vector_store( + ls_config, + { + "default_provider": "notebooks", + "providers": [ + { + "id": "notebooks", + "type": "faiss", + "embedding_model": "/rag-content/embeddings_model", + "embedding_dimension": 768, + "config": {"path": "/var/lib/notebooks.db"}, + } + ], + }, + ) + ids = {p["provider_id"] for p in ls_config["providers"]["vector_io"]} + assert ids == {"faiss", "notebooks"} + assert ( + ls_config["storage"]["backends"]["vsprov_notebooks_storage"]["db_path"] + == "/var/lib/notebooks.db" + ) + assert ls_config["vector_stores"]["default_provider_id"] == "notebooks" + assert ls_config["vector_stores"]["default_embedding_model"]["model_id"] == ( + "/rag-content/embeddings_model" + ) + assert ( + ls_config["vector_stores"]["annotation_prompt_params"]["enable_annotations"] + is False + ) + assert not ls_config["registered_resources"]["vector_stores"] + + +def test_enrich_vector_store_replaces_same_provider_id() -> None: + """Same provider_id replaces the baseline entry and leaves orphan backends.""" + ls_config: dict[str, Any] = { + "providers": { + "vector_io": [ + { + "provider_id": "notebooks", + "provider_type": "inline::faiss", + "config": { + "persistence": { + "namespace": "vector_io::faiss", + "backend": "kv_notebooks", + } + }, + } + ] + }, + "storage": { + "backends": { + "kv_notebooks": { + "type": "kv_sqlite", + "db_path": "/old/notebooks.db", + } + } + }, + "registered_resources": {"models": []}, + "vector_stores": {}, + } + enrich_vector_store( + ls_config, + { + "default_provider": "notebooks", + "providers": [ + { + "id": "notebooks", + "type": "faiss", + "embedding_model": "/emb", + "embedding_dimension": 768, + "config": {"path": "/new/notebooks.db"}, + } + ], + }, + ) + providers = ls_config["providers"]["vector_io"] + assert len(providers) == 1 + assert providers[0]["provider_id"] == "notebooks" + assert ( + providers[0]["config"]["persistence"]["backend"] == "vsprov_notebooks_storage" + ) + assert "kv_notebooks" in ls_config["storage"]["backends"] + assert ( + ls_config["storage"]["backends"]["vsprov_notebooks_storage"]["db_path"] + == "/new/notebooks.db" + ) + + +def test_enrich_vector_store_pgvector_no_kv_backend() -> None: + """Pgvector provider does not create a kv_sqlite storage backend.""" + ls_config: dict[str, Any] = { + "providers": {}, + "storage": {"backends": {}}, + "registered_resources": {}, + "vector_stores": {}, + } + enrich_vector_store( + ls_config, + { + "default_provider": "nb-pg", + "providers": [ + { + "id": "nb-pg", + "type": "pgvector", + "embedding_model": "/emb", + "embedding_dimension": 768, + "config": { + "host": "${env.POSTGRES_HOST}", + "port": "${env.POSTGRES_PORT}", + "db": "${env.POSTGRES_DATABASE}", + "user": "${env.POSTGRES_USER}", + "password": "${env.POSTGRES_PASSWORD}", + }, + } + ], + }, + ) + provider = ls_config["providers"]["vector_io"][0] + assert provider["provider_type"] == "remote::pgvector" + assert provider["config"]["persistence"]["backend"] == "kv_default" + assert provider["config"]["host"] == "${env.POSTGRES_HOST}" + assert provider["config"]["port"] == "${env.POSTGRES_PORT}" + assert provider["config"]["db"] == "${env.POSTGRES_DATABASE}" + assert provider["config"]["user"] == "${env.POSTGRES_USER}" + assert provider["config"]["password"] == "${env.POSTGRES_PASSWORD}" + assert "vsprov_nb-pg_storage" not in ls_config["storage"]["backends"] + + +def test_enrich_vector_store_multiple_entries() -> None: + """Multi-entry list: both providers, faiss-only backend, default_provider winner.""" + ls_config: dict[str, Any] = { + "providers": {}, + "storage": {"backends": {}}, + "registered_resources": {"models": [], "vector_stores": []}, + "vector_stores": { + "annotation_prompt_params": {"enable_annotations": False}, + }, + } + enrich_vector_store( + ls_config, + { + "default_provider": "notebooks", + "providers": [ + { + "id": "notebooks", + "type": "faiss", + "embedding_model": "/emb-faiss", + "embedding_dimension": 768, + "config": {"path": "/var/lib/notebooks.db"}, + }, + { + "id": "nb-pg", + "type": "pgvector", + "embedding_model": "/emb-pg", + "embedding_dimension": 768, + "config": { + "host": "${env.POSTGRES_HOST}", + "password": "${env.POSTGRES_PASSWORD}", + }, + }, + ], + }, + ) + ids = {p["provider_id"] for p in ls_config["providers"]["vector_io"]} + assert ids == {"notebooks", "nb-pg"} + assert "vsprov_notebooks_storage" in ls_config["storage"]["backends"] + assert "vsprov_nb-pg_storage" not in ls_config["storage"]["backends"] + assert ls_config["vector_stores"]["default_provider_id"] == "notebooks" + assert ls_config["vector_stores"]["default_embedding_model"]["model_id"] == ( + "/emb-faiss" + ) + assert ( + ls_config["vector_stores"]["annotation_prompt_params"]["enable_annotations"] + is False + ) + model_ids = { + m["provider_model_id"] for m in ls_config["registered_resources"]["models"] + } + assert model_ids == {"/emb-faiss", "/emb-pg"} + + +def test_enrich_vector_store_noop_without_entries() -> None: + """Empty list leaves baseline vector_stores defaults unchanged.""" + ls_config: dict[str, Any] = { + "providers": { + "vector_io": [ + {"provider_id": "faiss", "provider_type": "inline::faiss", "config": {}} + ] + }, + "vector_stores": {"default_provider_id": "faiss"}, + } + enrich_vector_store(ls_config, {"providers": []}) + assert ls_config["vector_stores"]["default_provider_id"] == "faiss" + + +def test_enrich_vector_store_dedupes_embedding_model() -> None: + """Same provider_model_id as an existing model does not add a second row.""" + ls_config: dict[str, Any] = { + "providers": {}, + "storage": {"backends": {}}, + "registered_resources": { + "models": [ + { + "model_id": "byok_rhdh-docs_embedding", + "model_type": "embedding", + "provider_id": "sentence-transformers", + "provider_model_id": "/rag-content/embeddings_model", + "metadata": {"embedding_dimension": 768}, + } + ], + "vector_stores": [], + }, + "vector_stores": {}, + } + enrich_vector_store( + ls_config, + { + "default_provider": "notebooks", + "providers": [ + { + "id": "notebooks", + "type": "faiss", + "embedding_model": "/rag-content/embeddings_model", + "embedding_dimension": 768, + "config": {"path": "/tmp/n.db"}, + } + ], + }, + ) + assert len(ls_config["registered_resources"]["models"]) == 1 + + +def test_enrich_vector_store_skips_embedding_without_dimension() -> None: + """embedding_model without embedding_dimension does not register a model.""" + ls_config: dict[str, Any] = { + "providers": {}, + "storage": {"backends": {}}, + "registered_resources": {"models": []}, + "vector_stores": {"default_provider_id": "faiss"}, + } + enrich_vector_store( + ls_config, + { + "default_provider": "notebooks", + "providers": [ + { + "id": "notebooks", + "type": "faiss", + "embedding_model": "/emb", + "config": {"path": "/var/lib/notebooks.db"}, + } + ], + }, + ) + assert ls_config["providers"]["vector_io"][0]["provider_id"] == "notebooks" + assert not ls_config["registered_resources"]["models"] + assert ls_config["vector_stores"]["default_provider_id"] == "notebooks" + assert "default_embedding_model" in ls_config["vector_stores"] + + +def test_enrich_vector_store_unmatched_default_provider_skips_defaults() -> None: + """Unmatched default_provider still enriches providers but skips default_*.""" + ls_config: dict[str, Any] = { + "providers": {}, + "storage": {"backends": {}}, + "registered_resources": {"models": []}, + "vector_stores": {"default_provider_id": "faiss"}, + } + enrich_vector_store( + ls_config, + { + "default_provider": "missing", + "providers": [ + { + "id": "notebooks", + "type": "faiss", + "embedding_model": "/emb", + "embedding_dimension": 768, + "config": {"path": "/var/lib/notebooks.db"}, + } + ], + }, + ) + assert ls_config["providers"]["vector_io"][0]["provider_id"] == "notebooks" + assert ls_config["vector_stores"]["default_provider_id"] == "faiss" + assert "default_embedding_model" not in ls_config["vector_stores"] + assert len(ls_config["registered_resources"]["models"]) == 1 + + # ============================================================================= # Test _build_vector_io_config # ============================================================================= diff --git a/tests/unit/test_llama_stack_synthesize.py b/tests/unit/test_llama_stack_synthesize.py index eb2998e45..dddb6f006 100644 --- a/tests/unit/test_llama_stack_synthesize.py +++ b/tests/unit/test_llama_stack_synthesize.py @@ -630,6 +630,35 @@ def test_synthesize_enriches_byok_rag_like_legacy() -> None: assert "vector_io" in result.get("providers", {}) +def test_synthesize_includes_vector_store() -> None: + """vector_store enrichment runs during unified synthesis.""" + lcs_config = { + "llama_stack": { + "use_as_library_client": True, + "config": {"baseline": "default"}, + }, + "vector_store": { + "default_provider": "notebooks", + "providers": [ + { + "id": "notebooks", + "type": "faiss", + "embedding_model": "/rag-content/embeddings_model", + "embedding_dimension": 768, + "config": {"path": "/tmp/notebooks.db"}, + } + ], + }, + } + result = synthesize_configuration(lcs_config) + ids = {p["provider_id"] for p in result["providers"]["vector_io"]} + assert "notebooks" in ids + assert result["vector_stores"]["default_provider_id"] == "notebooks" + assert result["vector_stores"]["default_embedding_model"]["model_id"] == ( + "/rag-content/embeddings_model" + ) + + # --------------------------------------------------------------------------- # synthesize_to_file # --------------------------------------------------------------------------- diff --git a/tests/unit/utils/test_models_dumper.py b/tests/unit/utils/test_models_dumper.py index 2902fa25f..3c0edf7df 100644 --- a/tests/unit/utils/test_models_dumper.py +++ b/tests/unit/utils/test_models_dumper.py @@ -9165,6 +9165,8 @@ def test_dump_models(tmpdir: Path) -> None: "FeedbackStatusUpdateResponse", "FileResponse", "FileTooLargeResponse", + "FaissVectorStoreProvider", + "FaissVectorStoreProviderConfig", "ForbiddenResponse", "HealthStatus", "InMemoryCacheConfig", @@ -9236,6 +9238,8 @@ def test_dump_models(tmpdir: Path) -> None: "OpenAIResponseUsageOutputTokensDetails", "OpenAITokenLogProb", "OpenAITopLogProb", + "PgvectorVectorStoreProvider", + "PgvectorVectorStoreProviderConfig", "PostgreSQLDatabaseConfiguration", "PromptCreateRequest", "PromptDeleteResponse", @@ -9314,6 +9318,7 @@ def test_dump_models(tmpdir: Path) -> None: "UnifiedLlamaStackConfig", "UnprocessableEntityResponse", "UserDataCollection", + "VectorStoreConfiguration", "VectorStoreCreateRequest", "VectorStoreDeleteResponse", "VectorStoreFileCreateRequest",