diff --git a/.github/workflows/ci_python.yml b/.github/workflows/ci_python.yml index 49d893ad0..8e26b866f 100644 --- a/.github/workflows/ci_python.yml +++ b/.github/workflows/ci_python.yml @@ -76,11 +76,11 @@ jobs: toolchain: stable cache: false - # Remove nemo-relay once we are using 0.7 (and thus the Python package for nemo-relay-cli) + # The Python nemo-relay package does not install the Rust gateway CLI. - name: Install test tools uses: taiki-e/install-action@c070f87102a1c75b3183910f391c1cb887fe13c8 # v2.77.6 with: - tool: just@1.50.0,nemo-relay-cli@0.6.0 + tool: just@1.50.0,nemo-relay-cli@0.7.2 - name: Set up uv uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8 diff --git a/ATTRIBUTIONS-Python.md b/ATTRIBUTIONS-Python.md index 5da95432f..1d16f4938 100644 --- a/ATTRIBUTIONS-Python.md +++ b/ATTRIBUTIONS-Python.md @@ -5470,7 +5470,7 @@ Copyright 2016 Andrew Svetlov and aio-libs contributors limitations under the License. ``` -## nemo-relay (0.6.0) +## nemo-relay (0.7.2) ### Licenses License: `Apache-2.0` diff --git a/TODO.md b/TODO.md index 577248f5b..7686169ee 100644 --- a/TODO.md +++ b/TODO.md @@ -36,24 +36,6 @@ cleanup validation. validate both installed-wheel and standalone Rust CLI behavior before removing this entry. -## NeMo Relay 0.6.x Request Decoding Release - -- **Status:** Waiting for an upstream release -- **Added:** July 16, 2026 -- **Affected documentation:** `adapters/codex/README.md` and - `docs/integrations/harness/codex.mdx` -- **Reason:** Released NeMo Relay versions do not yet decode the - `zstd`-compressed request bodies emitted by the Codex SDK, so semantic Relay - artifacts require a source installation. -- **Upstream resolution:** - [NVIDIA/NeMo-Relay#452](https://github.com/NVIDIA/NeMo-Relay/pull/452), merged - as `fe144d0d23e483c8216537118304e306abc20837` -- **Removal condition:** A published `nemo-relay-cli` version in NeMo Fabric's - supported `>=0.6.0,<0.7.0` range contains the merged request-decoding fix. -- **Cleanup:** Replace the pinned source-install instructions with the released - CLI installation, run the Codex Relay end-to-end test, update both affected - documentation files, and remove this entry. - # Planned Work ## Example Authoring Skill diff --git a/adapters/claude/README.md b/adapters/claude/README.md index 699585f99..295dc8f17 100644 --- a/adapters/claude/README.md +++ b/adapters/claude/README.md @@ -121,8 +121,8 @@ by the SDK and is not persisted as a NeMo Fabric artifact. ## Relay Observability -Relay requires a separately installed NeMo Relay 0.6.x CLI on `PATH`; the Python -`nemo-relay` package does not provide the executable. Follow the +Relay requires a NeMo Relay CLI in the `>=0.7.2,<0.8` range on `PATH`. NeMo +Fabric's `relay` extra does not install the CLI. Follow the [NeMo Relay installation instructions](https://docs.nvidia.com/nemo/fabric/getting-started/install#install-nemo-relay). Enable Relay through the normalized NeMo Fabric configuration: diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index d483063e4..af1fd5aa7 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -575,12 +575,11 @@ def prepare_claude_relay(payload: dict[str, Any]) -> ClaudeRelaySettings | None: ) from error try: - relay_contract = relay_gateway.relay_cli_contract(executable) + relay_gateway.relay_cli_contract(executable) plugin_config = common_utils.load_relay_plugin_config(payload) config_path, plugin_config_path = common_utils.write_relay_configs( relay_config={"agents": {"claude": {"command": "claude"}}}, plugin_config=plugin_config, - observability_version=relay_contract.observability_version, ) except ( OSError, diff --git a/adapters/codex/README.md b/adapters/codex/README.md index 28e7ee423..d1a34ef96 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -145,8 +145,8 @@ Codex state variables, the selected model's `api_key_env`, and explicit ## Relay Integration -Relay requires a separately installed NeMo Relay 0.6.x CLI on `PATH`; the Python -`nemo-relay` package does not provide the executable. Follow the +Relay requires a NeMo Relay CLI in the `>=0.7.2,<0.8` range on `PATH`. NeMo +Fabric's `relay` extra does not install the CLI. Follow the [NeMo Relay installation instructions](https://docs.nvidia.com/nemo/fabric/getting-started/install#install-nemo-relay). Enable Relay with `FabricConfig.enable_relay(...)`. The adapter starts the diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py index a213927f7..0e6174ce6 100644 --- a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -761,7 +761,7 @@ def prepare_codex_relay( ) from error try: - relay_contract = relay_gateway.relay_cli_contract(executable) + relay_gateway.relay_cli_contract(executable) plugin_config = common_utils.load_relay_plugin_config( { "agent_name": agent_name, @@ -774,7 +774,6 @@ def prepare_codex_relay( # Codex execution remains SDK-owned; Relay runs only as a gateway. relay_config={}, plugin_config=plugin_config, - observability_version=relay_contract.observability_version, ) except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as error: raise AdapterRelayError( diff --git a/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py b/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py index ae7529c30..fac723a4e 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py +++ b/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py @@ -5,6 +5,7 @@ from __future__ import annotations +import os import re import shutil import socket @@ -20,8 +21,8 @@ RELAY_HEALTH_TIMEOUT_SECONDS = 10.0 RELAY_STOP_TIMEOUT_SECONDS = 5.0 RELAY_VERSION_TIMEOUT_SECONDS = 5.0 -RELAY_MINIMUM_VERSION = (0, 6, 0) -RELAY_MAXIMUM_VERSION = (0, 7, 0) +RELAY_MINIMUM_VERSION = (0, 7, 2) +RELAY_MAXIMUM_VERSION = (0, 8, 0) class RelayGatewayError(RuntimeError): @@ -46,7 +47,6 @@ class RelayCliContract: """Versioned external Relay CLI contract consumed by Fabric adapters.""" version: tuple[int, int, int] - observability_version: int def resolve_relay_command(base_dir: Path, value: str | Path) -> Path: @@ -87,18 +87,25 @@ def relay_cli_contract(executable: Path) -> RelayCliContract: raise RelayGatewayError( "NeMo Relay CLI version could not be determined" ) from error - match = re.search(r"\b(\d+)\.(\d+)\.(\d+)", completed.stdout) + match = re.search( + r"\b(\d+)\.(\d+)\.(\d+)" + r"(-[0-9A-Za-z][0-9A-Za-z.-]*)?" + r"(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?(?=\s|$)", + completed.stdout, + ) if completed.returncode != 0 or match is None: raise RelayGatewayError("NeMo Relay CLI version could not be determined") - major, minor, patch = (int(value) for value in match.groups()) + major, minor, patch = (int(value) for value in match.groups()[:3]) version = (major, minor, patch) - if not RELAY_MINIMUM_VERSION <= version < RELAY_MAXIMUM_VERSION: + if match.group(4) is not None or not ( + RELAY_MINIMUM_VERSION <= version < RELAY_MAXIMUM_VERSION + ): raise RelayGatewayError( "unsupported NeMo Relay CLI version " f"{'.'.join(str(value) for value in version)}; " - "NeMo Fabric requires >=0.6.0,<0.7.0" + "NeMo Fabric requires >=0.7.2,<0.8.0" ) - return RelayCliContract(version=version, observability_version=2) + return RelayCliContract(version=version) def wait_for_relay_gateway( @@ -169,10 +176,16 @@ def start_relay_gateway( if launch.anthropic_base_url is not None: command.extend(["--anthropic-base-url", launch.anthropic_base_url]) try: + env = os.environ.copy() + # The explicit, runtime-owned plugins.toml is the only non-system + # plugin source for this gateway. Keep ambient project config out of + # concurrent Fabric runtimes without mutating process-global state. + env["NEMO_RELAY_CONFIG_SCOPE"] = "user" with launch.log_path.open("wb") as log_stream: process = subprocess.Popen( command, cwd=cwd, + env=env, stdout=log_stream, stderr=subprocess.STDOUT, start_new_session=True, diff --git a/adapters/common/src/nemo_fabric_adapters/common/utils.py b/adapters/common/src/nemo_fabric_adapters/common/utils.py index 43e482fcd..e82cb91ba 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/utils.py +++ b/adapters/common/src/nemo_fabric_adapters/common/utils.py @@ -249,6 +249,97 @@ def native_telemetry_config(payload: dict[str, Any]) -> dict[str, Any]: return config if isinstance(config, dict) else {} +def ambient_relay_plugin_config_paths() -> list[Path]: + """Return ambient user or project Relay plugin configs visible to Python.""" + + user_directory: Path | None = None + xdg_config_home = os.environ.get("XDG_CONFIG_HOME") + if xdg_config_home and Path(xdg_config_home).is_absolute(): + user_directory = Path(xdg_config_home) / "nemo-relay" + else: + home = os.environ.get("HOME") or os.environ.get("USERPROFILE") + if home is not None: + user_directory = Path(home) / ".config" / "nemo-relay" + + candidates: list[Path] = [] + if user_directory is not None: + candidates.append(user_directory / "plugins.toml") + try: + cwd = Path.cwd() + except OSError: + cwd = None + if cwd is not None: + project_config = next( + ( + ancestor / ".nemo-relay" / "plugins.toml" + for ancestor in (cwd, *cwd.parents) + if (ancestor / ".nemo-relay" / "plugins.toml").exists() + ), + None, + ) + if project_config is not None: + candidates.append(project_config) + + visible: list[Path] = [] + for candidate in candidates: + if candidate.exists() and candidate not in visible: + visible.append(candidate) + return visible + + +def reject_ambient_relay_plugin_config() -> None: + """Fail before Relay's Python API can merge unmanaged plugin config.""" + + paths = ambient_relay_plugin_config_paths() + if not paths: + return + joined = ", ".join(str(path) for path in paths) + raise RuntimeError( + "NeMo Fabric cannot isolate Relay's Python plugin runtime from ambient " + f"user or project configuration: {joined}. Move or remove these files " + "before starting this Relay-enabled runtime." + ) + + +def reject_inherited_relay_plugin_config(report: Any) -> None: + """Reject discovered user or project config while allowing system policy.""" + + if not isinstance(report, dict): + raise RuntimeError("NeMo Relay did not return a plugin activation report") + diagnostics = report.get("diagnostics") + if not isinstance(diagnostics, list): + raise RuntimeError("NeMo Relay returned an invalid plugin activation report") + inherited = [] + # Relay 0.7.2 exposes the source only in this message. Keep the system-policy + # allowlist exact and fail closed until Relay provides a structured source path. + message_prefix = "inherited plugin configuration from discovered file: " + system_config = Path("/etc/nemo-relay/plugins.toml") + for diagnostic in diagnostics: + if ( + not isinstance(diagnostic, dict) + or diagnostic.get("code") != "plugin.configuration_inherited" + ): + continue + message = diagnostic.get("message") + if ( + isinstance(message, str) + and message.startswith(message_prefix) + and Path(message.removeprefix(message_prefix)) == system_config + ): + continue + inherited.append(diagnostic) + if not inherited: + return + details = "; ".join( + str(diagnostic.get("message") or "inherited plugin configuration") + for diagnostic in inherited + ) + raise RuntimeError( + "NeMo Fabric refuses Relay plugin configuration inherited from ambient " + f"user or project files: {details}" + ) + + def capability_plan(payload: dict[str, Any]) -> dict[str, Any]: return payload.get("capability_plan") or payload.get("capabilities") or {} @@ -302,6 +393,65 @@ def dump_yaml(value: dict[str, Any]) -> str: return json.dumps(value, indent=2, sort_keys=False) + "\n" +def validate_relay_observability_v3(plugin_config: dict[str, Any]) -> None: + """Validate Fabric's Relay observability schema boundary without mutation.""" + + legacy_flat_otel_fields = { + "attribute_mappings", + "capture_content", + "endpoint", + "header_env", + "headers", + "instrumentation_scope", + "mark_exclude_names", + "mark_projection", + "resource_attributes", + "semantic_selector", + "service_name", + "service_namespace", + "service_version", + "timeout_millis", + "transport", + } + for component in plugin_config.get("components") or []: + if not isinstance(component, dict) or component.get("kind") != "observability": + continue + config = component.get("config") + if not isinstance(config, dict): + raise ValueError( + "NeMo Relay observability component config must be an object" + ) + if "version" in config: + version = config["version"] + if isinstance(version, bool) or not isinstance(version, int) or version != 3: + raise ValueError( + "unsupported NeMo Relay observability config version " + f"{version!r}; expected version 3" + ) + if "openinference" in config: + raise ValueError( + "NeMo Relay observability config version 3 removed the standalone " + "openinference section; use an opentelemetry endpoint with type " + "'openinference'" + ) + opentelemetry = config.get("opentelemetry") + if isinstance(opentelemetry, dict): + legacy_fields = sorted(legacy_flat_otel_fields.intersection(opentelemetry)) + if legacy_fields: + raise ValueError( + "NeMo Relay observability config version 3 requires exporter " + "fields inside opentelemetry.endpoints: " + + ", ".join(legacy_fields) + ) + endpoints = opentelemetry.get("endpoints") + if opentelemetry.get("enabled") is True and ( + not isinstance(endpoints, list) or not endpoints + ): + raise ValueError( + "enabled NeMo Relay OpenTelemetry requires at least one endpoint" + ) + + def load_relay_plugin_config(payload: dict[str, Any]) -> dict[str, Any]: config_path = os.environ.get("FABRIC_RELAY_CONFIG_PATH") if not config_path: @@ -313,16 +463,18 @@ def load_relay_plugin_config(payload: dict[str, Any]) -> dict[str, Any]: relay = wrapper.get("relay", {}) plugin_config = relay.get("config") or {} if "components" not in plugin_config: - plugin_config = { - "version": 1, - "components": [ + components = ( + [ { "kind": "observability", "enabled": True, - "config": plugin_config or {"version": 2}, + "config": plugin_config, } - ], - } + ] + if plugin_config + else [] + ) + plugin_config = {"version": 1, "components": components} plugin_config.setdefault("version", 1) plugin_config.setdefault("components", []) normalize_relay_output_dirs(plugin_config, payload) @@ -332,13 +484,14 @@ def load_relay_plugin_config(payload: dict[str, Any]) -> dict[str, Any]: def normalize_relay_output_dirs( plugin_config: dict[str, Any], payload: dict[str, Any] ) -> None: + validate_relay_observability_v3(plugin_config) + base = Path(base_dir(payload)).resolve() runtime_id = runtime_context(payload)["runtime_id"] for component in plugin_config.get("components", []): - if component.get("kind") != "observability": + if not isinstance(component, dict) or component.get("kind") != "observability": continue - config = component.setdefault("config", {}) - config.setdefault("version", 2) + config = component["config"] atof = config.get("atof") if isinstance(atof, dict) and atof.get("enabled"): @@ -412,7 +565,6 @@ def _artifact_glob(directory: Path, pattern: str) -> list[Path]: def collect_relay_artifacts(plugin_config: dict[str, Any]) -> list[dict[str, str]]: - artifacts: list[dict[str, str]] = [] for component in plugin_config.get("components", []): if component.get("kind") != "observability": @@ -458,7 +610,6 @@ def write_relay_configs( *, relay_config: dict[str, Any] | None = None, plugin_config: dict[str, Any] | None = None, - observability_version: int = 2, ) -> tuple[Path | None, Path | None]: try: import tomli_w @@ -471,6 +622,8 @@ def write_relay_configs( config_path = Path(config_path) config_dir = config_path.parent / "relay-config" + if plugin_config is not None: + validate_relay_observability_v3(plugin_config) config_dir.mkdir(parents=True, exist_ok=True) relay_config_path = None plugin_config_path = None @@ -480,10 +633,6 @@ def write_relay_configs( relay_config_path.write_text(tomli_w.dumps(relay_config), encoding="utf-8") if plugin_config is not None: - if observability_version not in {2, 3}: - raise ValueError( - f"unsupported NeMo Relay observability config version {observability_version}" - ) plugin_config_path = config_dir / "plugins.toml" plugin_config_path.write_text( tomli_w.dumps(plugin_config), diff --git a/adapters/deepagents/pyproject.toml b/adapters/deepagents/pyproject.toml index cd4979ee2..b1773de5a 100644 --- a/adapters/deepagents/pyproject.toml +++ b/adapters/deepagents/pyproject.toml @@ -41,13 +41,13 @@ harness = [ "langgraph>=1.2,<2.0", ] relay = [ - "nemo-relay>=0.6.0,<0.7", + "nemo-relay>=0.7.2,<0.8", ] full = [ "deepagents>=0.6.12,<0.7.0", "langchain>=1.3,<2.0", "langgraph>=1.2,<2.0", - "nemo-relay[deepagents]>=0.6.0,<0.7", + "nemo-relay[deepagents]>=0.7.2,<0.8", ] [project.urls] diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index 9947fc34f..fc5e934af 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -567,6 +567,7 @@ def _configure_observability(self, agent_kwargs: dict[str, Any]) -> dict[str, An if importlib.util.find_spec("nemo_relay") is None: raise _relay_dependency_error() + common_utils.reject_ambient_relay_plugin_config() try: from nemo_relay import ScopeType, plugin, scope from nemo_relay.integrations.deepagents import ( @@ -663,8 +664,12 @@ async def _invoke_with_telemetry( outcome: TurnOutcome | None = None scope_error: str | None = None try: + common_utils.reject_ambient_relay_plugin_config() callback_handler = self._callback_handler_type() - async with self._relay_plugin.plugin(self._relay_plugin_config): + async with self._relay_plugin.plugin( + self._relay_plugin_config + ) as activation_report: + common_utils.reject_inherited_relay_plugin_config(activation_report) # Caught here rather than left to propagate: an exception crossing the # plugin's ``__aexit__`` is replaced by any fault the plugin raises in # turn, which would lose one of the two. @@ -935,15 +940,16 @@ def resolve_observability( """ if relay_enabled and telemetry_provider == "relay": + plugin_config = common_utils.load_relay_plugin_config( + { + "agent_name": agent_name, + "base_dir": base_dir, + "config": {"models": {"default": {"model": model_name}}}, + "runtime_context": runtime_context.to_mapping(), + } + ) return Observability( - common_utils.load_relay_plugin_config( - { - "agent_name": agent_name, - "base_dir": base_dir, - "config": {"models": {"default": {"model": model_name}}}, - "runtime_context": runtime_context.to_mapping(), - } - ), + plugin_config, "deepagents.observability/nemo_relay", True, ) diff --git a/adapters/deepagents/uv.lock b/adapters/deepagents/uv.lock index 6df65597e..a1f2824fa 100644 --- a/adapters/deepagents/uv.lock +++ b/adapters/deepagents/uv.lock @@ -887,24 +887,24 @@ requires-dist = [ { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0,<4.0" }, { name = "nemo-fabric-adapter-contract", editable = "../../adapter-contract" }, { name = "nemo-fabric-adapters-common", editable = "../common" }, - { name = "nemo-relay", marker = "extra == 'relay'", specifier = ">=0.6.0,<0.7" }, - { name = "nemo-relay", extras = ["deepagents"], marker = "extra == 'full'", specifier = ">=0.6.0,<0.7" }, + { name = "nemo-relay", marker = "extra == 'relay'", specifier = ">=0.7.2,<0.8" }, + { name = "nemo-relay", extras = ["deepagents"], marker = "extra == 'full'", specifier = ">=0.7.2,<0.8" }, ] provides-extras = ["harness", "relay", "full"] [[package]] name = "nemo-relay" -version = "0.6.0" +version = "0.7.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/db/44d7258ee620c5cce6dc588983fcb11be3ee03ae71089a65686c14d9ee02/nemo_relay-0.6.0.tar.gz", hash = "sha256:f3d3088019609bc953357b5598a47481dc3e7dc8f11ecf27002ede251f37eb7b", size = 1071046, upload-time = "2026-08-03T14:55:49.702Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/81/a7a545ac3a2f8c670d261c89df599aa8fbf49d8be45fd1f52efb36b489eb/nemo_relay-0.7.2.tar.gz", hash = "sha256:828d9f6c7d7e4e42276bb7192bd44202c761e0c76fa4943d84e051b5a99028e5", size = 1295616, upload-time = "2026-08-08T01:54:00.953Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/65/d320016505457cc30971f575e8dadffb923b7cfc780ab8bb25a4ce9d305c/nemo_relay-0.6.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:ad5dae6febf6532d7b113abc2a404679c8feffc499df3034b93d9a078185d2bb", size = 9917779, upload-time = "2026-07-22T20:07:48.961Z" }, - { url = "https://files.pythonhosted.org/packages/ae/c0/f33250e71c4206da1b339072893f9a1e39295fe1aceb9a2fef4b8620a0f2/nemo_relay-0.6.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0cd9570f64c6956fe3bfb82af1cdb3ee70cb50b51098cdb0de831c3f9b4e904", size = 8888375, upload-time = "2026-07-22T20:07:51.049Z" }, - { url = "https://files.pythonhosted.org/packages/a3/f4/d1dfaed022da0f6f14765a122867f976a69cc520fe1faaf99757f5719d1f/nemo_relay-0.6.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:849daa9e45158ac581e54506e0fcc7a24f557d1ed06dbdc074f5de7a00393cbc", size = 9336372, upload-time = "2026-07-22T20:07:53.224Z" }, - { url = "https://files.pythonhosted.org/packages/60/9e/f8b80509eef5e05702b940a3d1e2f60c962548d87dec2d712fd3804e6cd4/nemo_relay-0.6.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8c80e534b76bb0455cfc222aaf5c10fa064c088b3c0d5e137eb3c97db46dbc47", size = 10578834, upload-time = "2026-07-30T15:44:44.726Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a0/84ee49d45a1a874457f2f9260d30fd573af30c9be360d9d97b8bb7835ad9/nemo_relay-0.6.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c8bc4a792a2f8c35ddef1b900cc43be2b2bbbfcd5e7cf65aa0829c04d25eb77f", size = 10849651, upload-time = "2026-07-30T15:44:40.48Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b0/908d77f75b9054e1e403da78f7d9430249a45939070d4298abbb41a04b5b/nemo_relay-0.6.0-cp311-abi3-win_amd64.whl", hash = "sha256:bfbbedfd130fa95c9b8c04643c30910df850e0ed3500beeab82b00ca2d94e7ea", size = 9613425, upload-time = "2026-07-22T20:07:55.175Z" }, - { url = "https://files.pythonhosted.org/packages/cd/71/c438b9d746303ff7f270d99f13b250bf947cdac3e52de2a83fd132cbca0b/nemo_relay-0.6.0-cp311-abi3-win_arm64.whl", hash = "sha256:82fe132943399d89e6ec34dc28df0be7bbe41b84f6698c545928b8816b6010f6", size = 9034810, upload-time = "2026-07-22T20:07:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/f50440257f01bc5ab3d668331c90e06cf4edcc84dc7dc582d322ad05b622/nemo_relay-0.7.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:e7c7977f0903793cc34c5542bf2b2e44d107def8a5ae9f1b28f06dd61ddec4ed", size = 9246341, upload-time = "2026-08-08T01:53:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9f/4041446dd134218799a34b5b5fad3a62d3e1d0a6c322ba2ca4b896ba1393/nemo_relay-0.7.2-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ae77c1f3d58eabda264e82ffaca54548df80caede7dd6af8cbd8f72b4a82ed", size = 8454070, upload-time = "2026-08-08T01:53:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/11/83/90230c2e9fae1aee39f768d4a9ef57e9f2716bcaed1a5923cce8b526c66b/nemo_relay-0.7.2-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ce7103aec546766649c182619d16aa6ad07439e4d0ebd16d95c5004afb3e56a", size = 8954377, upload-time = "2026-08-08T01:53:25.267Z" }, + { url = "https://files.pythonhosted.org/packages/71/e7/463fa461d0801146fec6a00cbc02e8961b30089d65ba170f9dfa9e6e3dcd/nemo_relay-0.7.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2e7d0c2629ade7313aaed71d2272dca96a2fafad0248d0f87cf40a7720b252a0", size = 10322132, upload-time = "2026-08-08T01:53:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/32/8c/e20ec9c52bd1edd953157aaf24d0d9f9ab8afcbf108fc2356f398e252da8/nemo_relay-0.7.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b841c92395d7686c7f233036008294b9d362af1ec5123ab0babbfd11cbb04054", size = 10704141, upload-time = "2026-08-08T01:53:30.453Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c1/92a73961ea759b433b1f897b225662d499123cb962b48dc8ece19f610a09/nemo_relay-0.7.2-cp311-abi3-win_amd64.whl", hash = "sha256:0cdcc5e09d6d62d5c1d385dc62c9233eb714a25f36a09da81e5b9731e3c67903", size = 8803938, upload-time = "2026-08-08T01:53:33.437Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ec/2de114dab437431173988b9b11f46e8d377e12d57e1b4903258f3e03c2df/nemo_relay-0.7.2-cp311-abi3-win_arm64.whl", hash = "sha256:ca5f66e617311f836a10d96f120f3f32a99b4267d65048453b31951de3419a9d", size = 8438997, upload-time = "2026-08-08T01:53:36.12Z" }, ] [package.optional-dependencies] diff --git a/adapters/hermes/pyproject.toml b/adapters/hermes/pyproject.toml index 4fab2ed52..a48a1e6a4 100644 --- a/adapters/hermes/pyproject.toml +++ b/adapters/hermes/pyproject.toml @@ -35,11 +35,11 @@ harness = [ "hermes-agent[mcp]>=0.19.0; python_version < '3.14'", ] relay = [ - "nemo-relay>=0.6.0,<0.7", + "nemo-relay>=0.7.2,<0.8", ] full = [ "hermes-agent[mcp]>=0.19.0; python_version < '3.14'", - "nemo-relay>=0.6.0,<0.7", + "nemo-relay>=0.7.2,<0.8", ] [project.urls] diff --git a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py index 8cfc1106d..2408c78f7 100755 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py @@ -143,6 +143,8 @@ async def start(self, payload: dict[str, Any]) -> None: from run_agent import AIAgent with redirect_stdout(StringIO()): + if relay_enabled: + common_utils.reject_ambient_relay_plugin_config() discover_plugins(force=True) loaded_hermes_config = load_config() # Hermes 0.12+ no longer discovers MCP tools as an import side effect @@ -235,6 +237,8 @@ async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: await self._authenticate_mcp_servers() def run_hermes_turn() -> tuple[dict[str, Any], str]: + if self._relay_plugin_config is not None: + common_utils.reject_ambient_relay_plugin_config() try: return _invoke_hermes_turn( agent=self._agent, diff --git a/adapters/hermes/src/nemo_fabric_adapters/hermes/telemetry.py b/adapters/hermes/src/nemo_fabric_adapters/hermes/telemetry.py index 65f606b61..989cd24a2 100644 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/telemetry.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/telemetry.py @@ -5,8 +5,6 @@ from __future__ import annotations -import copy -from importlib.metadata import version as distribution_version from pathlib import Path from typing import Any @@ -50,18 +48,8 @@ def write_hermes_relay_plugin_config( ) -> tuple[Path, dict[str, Any]]: """Stage Fabric's resolved Relay config for Hermes' bundled integration.""" - plugin_config = common_utils.load_relay_plugin_config(payload) - hermes_plugin_config = copy.deepcopy(plugin_config) - relay_version = distribution_version("nemo-relay") - try: - relay_major, relay_minor = ( - int(part) for part in relay_version.split(".", maxsplit=2)[:2] - ) - except ValueError as error: - raise RuntimeError( - f"unsupported NeMo Relay version {relay_version!r}" - ) from error - observability_version = 3 if (relay_major, relay_minor) >= (0, 7) else 2 + common_utils.reject_ambient_relay_plugin_config() + hermes_plugin_config = common_utils.load_relay_plugin_config(payload) for component in hermes_plugin_config.get("components", []): if component.get("kind") != "observability": continue @@ -69,31 +57,6 @@ def write_hermes_relay_plugin_config( if not isinstance(observability, dict): continue - if observability_version == 3 and observability.get("version") != 3: - # Relay 0.7 combines Fabric's legacy OTLP and OpenInference exporter - # settings into typed OpenTelemetry endpoints in its v3 schema. - endpoints = [] - for config_name, endpoint_type in ( - ("opentelemetry", "full"), - ("openinference", "openinference"), - ): - exporter = observability.pop(config_name, None) - if not isinstance(exporter, dict) or not exporter.get("enabled"): - continue - endpoint = { - key: value - for key, value in exporter.items() - if key != "enabled" and value is not None - } - endpoint["type"] = endpoint_type - endpoints.append(endpoint) - if endpoints: - observability["opentelemetry"] = { - "enabled": True, - "endpoints": endpoints, - } - observability["version"] = 3 - # Fabric finalizes Hermes' Relay session after every invocation. Each # finalization reinitializes Relay for the next turn, so a file sink # cannot overwrite the runtime-scoped artifact it created previously. @@ -102,9 +65,8 @@ def write_hermes_relay_plugin_config( if sink.get("mode") == "overwrite": sink["mode"] = "append" _, plugin_config_path = common_utils.write_relay_configs( - plugin_config=hermes_plugin_config, - observability_version=observability_version, + plugin_config=hermes_plugin_config ) if plugin_config_path is None: raise RuntimeError("Hermes Relay plugin configuration was not generated") - return plugin_config_path, plugin_config + return plugin_config_path, hermes_plugin_config diff --git a/adapters/hermes/uv.lock b/adapters/hermes/uv.lock index 90895680f..eba811969 100644 --- a/adapters/hermes/uv.lock +++ b/adapters/hermes/uv.lock @@ -658,24 +658,24 @@ requires-dist = [ { name = "hermes-agent", extras = ["mcp"], marker = "python_full_version < '3.14' and extra == 'harness'", specifier = ">=0.19.0" }, { name = "nemo-fabric-adapter-contract", editable = "../../adapter-contract" }, { name = "nemo-fabric-adapters-common", editable = "../common" }, - { name = "nemo-relay", marker = "extra == 'full'", specifier = ">=0.6.0,<0.7" }, - { name = "nemo-relay", marker = "extra == 'relay'", specifier = ">=0.6.0,<0.7" }, + { name = "nemo-relay", marker = "extra == 'full'", specifier = ">=0.7.2,<0.8" }, + { name = "nemo-relay", marker = "extra == 'relay'", specifier = ">=0.7.2,<0.8" }, ] provides-extras = ["harness", "relay", "full"] [[package]] name = "nemo-relay" -version = "0.6.0" +version = "0.7.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/db/44d7258ee620c5cce6dc588983fcb11be3ee03ae71089a65686c14d9ee02/nemo_relay-0.6.0.tar.gz", hash = "sha256:f3d3088019609bc953357b5598a47481dc3e7dc8f11ecf27002ede251f37eb7b", size = 1071046, upload-time = "2026-08-03T14:55:49.702Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/81/a7a545ac3a2f8c670d261c89df599aa8fbf49d8be45fd1f52efb36b489eb/nemo_relay-0.7.2.tar.gz", hash = "sha256:828d9f6c7d7e4e42276bb7192bd44202c761e0c76fa4943d84e051b5a99028e5", size = 1295616, upload-time = "2026-08-08T01:54:00.953Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/65/d320016505457cc30971f575e8dadffb923b7cfc780ab8bb25a4ce9d305c/nemo_relay-0.6.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:ad5dae6febf6532d7b113abc2a404679c8feffc499df3034b93d9a078185d2bb", size = 9917779, upload-time = "2026-07-22T20:07:48.961Z" }, - { url = "https://files.pythonhosted.org/packages/ae/c0/f33250e71c4206da1b339072893f9a1e39295fe1aceb9a2fef4b8620a0f2/nemo_relay-0.6.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0cd9570f64c6956fe3bfb82af1cdb3ee70cb50b51098cdb0de831c3f9b4e904", size = 8888375, upload-time = "2026-07-22T20:07:51.049Z" }, - { url = "https://files.pythonhosted.org/packages/a3/f4/d1dfaed022da0f6f14765a122867f976a69cc520fe1faaf99757f5719d1f/nemo_relay-0.6.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:849daa9e45158ac581e54506e0fcc7a24f557d1ed06dbdc074f5de7a00393cbc", size = 9336372, upload-time = "2026-07-22T20:07:53.224Z" }, - { url = "https://files.pythonhosted.org/packages/60/9e/f8b80509eef5e05702b940a3d1e2f60c962548d87dec2d712fd3804e6cd4/nemo_relay-0.6.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8c80e534b76bb0455cfc222aaf5c10fa064c088b3c0d5e137eb3c97db46dbc47", size = 10578834, upload-time = "2026-07-30T15:44:44.726Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a0/84ee49d45a1a874457f2f9260d30fd573af30c9be360d9d97b8bb7835ad9/nemo_relay-0.6.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c8bc4a792a2f8c35ddef1b900cc43be2b2bbbfcd5e7cf65aa0829c04d25eb77f", size = 10849651, upload-time = "2026-07-30T15:44:40.48Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b0/908d77f75b9054e1e403da78f7d9430249a45939070d4298abbb41a04b5b/nemo_relay-0.6.0-cp311-abi3-win_amd64.whl", hash = "sha256:bfbbedfd130fa95c9b8c04643c30910df850e0ed3500beeab82b00ca2d94e7ea", size = 9613425, upload-time = "2026-07-22T20:07:55.175Z" }, - { url = "https://files.pythonhosted.org/packages/cd/71/c438b9d746303ff7f270d99f13b250bf947cdac3e52de2a83fd132cbca0b/nemo_relay-0.6.0-cp311-abi3-win_arm64.whl", hash = "sha256:82fe132943399d89e6ec34dc28df0be7bbe41b84f6698c545928b8816b6010f6", size = 9034810, upload-time = "2026-07-22T20:07:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/f50440257f01bc5ab3d668331c90e06cf4edcc84dc7dc582d322ad05b622/nemo_relay-0.7.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:e7c7977f0903793cc34c5542bf2b2e44d107def8a5ae9f1b28f06dd61ddec4ed", size = 9246341, upload-time = "2026-08-08T01:53:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9f/4041446dd134218799a34b5b5fad3a62d3e1d0a6c322ba2ca4b896ba1393/nemo_relay-0.7.2-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ae77c1f3d58eabda264e82ffaca54548df80caede7dd6af8cbd8f72b4a82ed", size = 8454070, upload-time = "2026-08-08T01:53:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/11/83/90230c2e9fae1aee39f768d4a9ef57e9f2716bcaed1a5923cce8b526c66b/nemo_relay-0.7.2-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ce7103aec546766649c182619d16aa6ad07439e4d0ebd16d95c5004afb3e56a", size = 8954377, upload-time = "2026-08-08T01:53:25.267Z" }, + { url = "https://files.pythonhosted.org/packages/71/e7/463fa461d0801146fec6a00cbc02e8961b30089d65ba170f9dfa9e6e3dcd/nemo_relay-0.7.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2e7d0c2629ade7313aaed71d2272dca96a2fafad0248d0f87cf40a7720b252a0", size = 10322132, upload-time = "2026-08-08T01:53:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/32/8c/e20ec9c52bd1edd953157aaf24d0d9f9ab8afcbf108fc2356f398e252da8/nemo_relay-0.7.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b841c92395d7686c7f233036008294b9d362af1ec5123ab0babbfd11cbb04054", size = 10704141, upload-time = "2026-08-08T01:53:30.453Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c1/92a73961ea759b433b1f897b225662d499123cb962b48dc8ece19f610a09/nemo_relay-0.7.2-cp311-abi3-win_amd64.whl", hash = "sha256:0cdcc5e09d6d62d5c1d385dc62c9233eb714a25f36a09da81e5b9731e3c67903", size = 8803938, upload-time = "2026-08-08T01:53:33.437Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ec/2de114dab437431173988b9b11f46e8d377e12d57e1b4903258f3e03c2df/nemo_relay-0.7.2-cp311-abi3-win_arm64.whl", hash = "sha256:ca5f66e617311f836a10d96f120f3f32a99b4267d65048453b31951de3419a9d", size = 8438997, upload-time = "2026-08-08T01:53:36.12Z" }, ] [[package]] @@ -782,7 +782,7 @@ name = "portalocker" version = "3.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "pywin32" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } wheels = [ diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index fb800a001..500feb78a 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -955,8 +955,11 @@ pub struct RelayComponentConfig { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RelayObservabilityConfig { /// Relay observability config version. - #[serde(default = "default_relay_config_version")] - #[schemars(range(max = u32::MAX))] + #[serde( + default = "default_relay_observability_version", + deserialize_with = "deserialize_relay_observability_version" + )] + #[schemars(schema_with = "relay_observability_version_schema")] pub version: u32, /// ATOF export configuration. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -964,15 +967,15 @@ pub struct RelayObservabilityConfig { /// ATIF export configuration. #[serde(default, skip_serializing_if = "Option::is_none")] pub atif: Option, - /// OpenTelemetry export configuration. + /// OpenTelemetry and OpenInference export configuration. #[serde(default, skip_serializing_if = "Option::is_none")] - pub opentelemetry: Option, - /// OpenInference export configuration. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub openinference: Option, + pub opentelemetry: Option, /// Relay config validation policy. #[serde(default, skip_serializing_if = "Option::is_none")] pub policy: Option, + /// Retain complete sanitized request data on LLM start events. + #[serde(default)] + pub enable_full_payloads: bool, /// Additive observability fields. #[serde(default, flatten)] pub extensions: BTreeMap, @@ -981,12 +984,12 @@ pub struct RelayObservabilityConfig { impl Default for RelayObservabilityConfig { fn default() -> Self { Self { - version: default_relay_config_version(), + version: default_relay_observability_version(), atof: None, atif: None, opentelemetry: None, - openinference: None, policy: None, + enable_full_payloads: false, extensions: BTreeMap::new(), } } @@ -1161,26 +1164,42 @@ pub enum RelayAtifStorageConfig { }, } -/// Relay OpenTelemetry/OpenInference export configuration. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -pub struct RelayOtlpConfig { - /// Whether OTLP export is enabled. +/// Relay OpenTelemetry export section. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RelayOpenTelemetryConfig { + /// Whether OpenTelemetry export is enabled. #[serde(default)] pub enabled: bool, + /// Typed OTLP destinations. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub endpoints: Vec, + /// Additive OpenTelemetry section fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + +/// One typed Relay OpenTelemetry OTLP destination. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RelayOpenTelemetryEndpointConfig { + /// Span projection emitted to this destination. + pub r#type: RelayOpenTelemetryEndpointType, + /// OTLP endpoint. + #[schemars(length(min = 1), regex(pattern = r"\S"))] + pub endpoint: String, + /// Mark projection policy. + #[serde(default)] + pub mark_projection: RelayOpenTelemetryMarkProjection, + /// Event names excluded from mark projection. + #[serde(default = "default_relay_mark_exclude_names")] + pub mark_exclude_names: Vec, + /// Attribute mapping rules. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attribute_mappings: Vec>, /// OTLP transport. #[serde(default)] pub transport: RelayOtlpTransport, - /// OTLP endpoint. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub endpoint: Option, - /// OTLP headers. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub headers: BTreeMap, - /// OTLP resource attributes. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub resource_attributes: BTreeMap, /// OTLP service name. - #[serde(default = "default_relay_service_name")] + #[serde(default = "default_relay_otel_service_name")] pub service_name: String, /// OTLP service namespace. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1189,35 +1208,26 @@ pub struct RelayOtlpConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub service_version: Option, /// OTLP instrumentation scope. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub instrumentation_scope: Option, + #[serde(default = "default_relay_instrumentation_scope")] + pub instrumentation_scope: String, /// Request timeout in milliseconds. #[serde(default = "default_relay_timeout_millis")] #[schemars(range(max = u64::MAX))] pub timeout_millis: u64, - /// Additive OTLP fields. + /// Static OTLP headers. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + /// Environment-variable-backed OTLP headers. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub header_env: BTreeMap, + /// OTLP resource attributes. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub resource_attributes: BTreeMap, + /// Additive endpoint fields. #[serde(default, flatten)] pub extensions: BTreeMap, } -impl Default for RelayOtlpConfig { - fn default() -> Self { - Self { - enabled: false, - transport: RelayOtlpTransport::default(), - endpoint: None, - headers: BTreeMap::new(), - resource_attributes: BTreeMap::new(), - service_name: default_relay_service_name(), - service_namespace: None, - service_version: None, - instrumentation_scope: None, - timeout_millis: default_relay_timeout_millis(), - extensions: BTreeMap::new(), - } - } -} - /// Relay validation policy. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RelayConfigPolicy { @@ -1301,6 +1311,31 @@ pub enum RelayOtlpTransport { Grpc, } +/// Relay OpenTelemetry span projection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RelayOpenTelemetryEndpointType { + /// Emit full Relay spans. + Full, + /// Emit GenAI semantic-convention spans. + GenAi, + /// Emit OpenInference semantic-convention spans. + Openinference, +} + +/// Relay OpenTelemetry mark projection policy. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RelayOpenTelemetryMarkProjection { + /// Inherit the endpoint type's projection behavior. + #[default] + Inherit, + /// Project marks as events. + Event, + /// Project marks as tool spans. + Tool, +} + /// Telemetry runtime provider. #[derive( Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, @@ -1326,8 +1361,32 @@ impl TelemetryProvider { } } -fn default_relay_config_version() -> u32 { - 2 +const RELAY_OBSERVABILITY_VERSION: u32 = 3; + +fn relay_observability_version_schema(generator: &mut SchemaGenerator) -> Schema { + let mut schema = u32::json_schema(generator); + schema.insert("const".into(), RELAY_OBSERVABILITY_VERSION.into()); + schema +} + +fn default_relay_observability_version() -> u32 { + RELAY_OBSERVABILITY_VERSION +} + +fn deserialize_relay_observability_version<'de, D>( + deserializer: D, +) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + let version = u32::deserialize(deserializer)?; + if version == RELAY_OBSERVABILITY_VERSION { + Ok(version) + } else { + Err(serde::de::Error::custom(format!( + "NeMo Relay 0.7 requires observability config version {RELAY_OBSERVABILITY_VERSION}; found {version}" + ))) + } } fn default_enabled() -> bool { @@ -1350,8 +1409,16 @@ fn default_relay_timeout_millis() -> u64 { 3000 } -fn default_relay_service_name() -> String { - "nemo-relay".to_string() +fn default_relay_otel_service_name() -> String { + "unknown_service".to_string() +} + +fn default_relay_instrumentation_scope() -> String { + "opentelemetry".to_string() +} + +fn default_relay_mark_exclude_names() -> Vec { + vec!["llm.chunk".to_string()] } fn default_relay_unsupported_value_behavior() -> RelayUnsupportedBehavior { @@ -1441,6 +1508,94 @@ pub(crate) fn validate_config(config: &FabricConfig) -> Result<()> { } } } + if let Some(relay) = &config.relay { + if let Some(observability) = relay.observability.as_ref() { + if observability.version != RELAY_OBSERVABILITY_VERSION { + return invalid_config( + "relay.observability.version", + format!( + "NeMo Relay 0.7 requires observability config version {RELAY_OBSERVABILITY_VERSION}" + ), + ); + } + if observability.extensions.contains_key("openinference") { + return invalid_config( + "relay.observability.openinference", + "was removed in observability config version 3; use an OpenTelemetry endpoint with type `openinference`", + ); + } + if let Some(opentelemetry) = observability.opentelemetry.as_ref() { + if let Some(field) = relay_legacy_flat_otel_field(|field| { + opentelemetry.extensions.contains_key(field) + }) { + return invalid_config( + format!("relay.observability.opentelemetry.{field}"), + "moved into each typed endpoint in observability config version 3", + ); + } + if opentelemetry.enabled && opentelemetry.endpoints.is_empty() { + return invalid_config( + "relay.observability.opentelemetry.endpoints", + "must contain at least one endpoint when OpenTelemetry export is enabled", + ); + } + for (index, endpoint) in opentelemetry.endpoints.iter().enumerate() { + if endpoint.endpoint.trim().is_empty() { + return invalid_config( + format!("relay.observability.opentelemetry.endpoints.{index}.endpoint"), + "must be a non-empty string", + ); + } + } + } + } + for (index, component) in relay.components.iter().enumerate() { + if component.kind != "observability" { + continue; + } + if let Some(version) = component.config.get("version") + && version.as_u64() != Some(u64::from(RELAY_OBSERVABILITY_VERSION)) + { + return invalid_config( + format!("relay.components.{index}.config.version"), + format!( + "NeMo Relay 0.7 requires observability config version {RELAY_OBSERVABILITY_VERSION}" + ), + ); + } + if component.config.contains_key("openinference") { + return invalid_config( + format!("relay.components.{index}.config.openinference"), + "was removed in observability config version 3; use an OpenTelemetry endpoint with type `openinference`", + ); + } + if let Some(opentelemetry) = component + .config + .get("opentelemetry") + .and_then(Value::as_object) + { + if let Some(field) = + relay_legacy_flat_otel_field(|field| opentelemetry.contains_key(field)) + { + return invalid_config( + format!("relay.components.{index}.config.opentelemetry.{field}"), + "moved into each typed endpoint in observability config version 3", + ); + } + if opentelemetry.get("enabled").and_then(Value::as_bool) == Some(true) + && opentelemetry + .get("endpoints") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty) + { + return invalid_config( + format!("relay.components.{index}.config.opentelemetry.endpoints"), + "must contain at least one endpoint when OpenTelemetry export is enabled", + ); + } + } + } + } if let Some(tools) = &config.tools { for (name, definition) in &tools.definitions { if name.trim().is_empty() { @@ -1622,6 +1777,29 @@ pub(crate) fn validate_config(config: &FabricConfig) -> Result<()> { Ok(()) } +fn relay_legacy_flat_otel_field(contains_field: impl Fn(&str) -> bool) -> Option<&'static str> { + const LEGACY_FLAT_OTEL_FIELDS: [&str; 15] = [ + "attribute_mappings", + "capture_content", + "endpoint", + "header_env", + "headers", + "instrumentation_scope", + "mark_exclude_names", + "mark_projection", + "resource_attributes", + "semantic_selector", + "service_name", + "service_namespace", + "service_version", + "timeout_millis", + "transport", + ]; + LEGACY_FLAT_OTEL_FIELDS + .into_iter() + .find(|field| contains_field(field)) +} + fn validate_names(field: &str, names: &[String]) -> Result<()> { if names.iter().any(|name| name.trim().is_empty()) { return invalid_config(field, "entries must be non-empty strings"); @@ -3189,7 +3367,7 @@ mod tests { } #[test] - fn relay_observability_uses_v2_typed_atof_sinks() { + fn relay_observability_uses_v3_typed_endpoints() { let observability: RelayObservabilityConfig = serde_json::from_value(serde_json::json!({ "atof": { "enabled": true, @@ -3208,14 +3386,180 @@ mod tests { "name": "live-events" } ] + }, + "opentelemetry": { + "enabled": true, + "endpoints": [ + { + "type": "openinference", + "endpoint": "http://localhost:4318/v1/traces", + "header_env": {"authorization": "OTEL_AUTHORIZATION"} + } + ] } })) - .expect("Relay v2 observability config"); + .expect("Relay v3 observability config"); let value = serde_json::to_value(observability).expect("serialized observability"); - assert_eq!(value["version"], 2); + assert_eq!(value["version"], 3); assert_eq!(value["atof"]["sinks"][0]["type"], "file"); assert_eq!(value["atof"]["sinks"][1]["type"], "stream"); + assert_eq!( + value["opentelemetry"]["endpoints"][0]["type"], + "openinference" + ); + assert_eq!( + value["opentelemetry"]["endpoints"][0]["service_name"], + "unknown_service" + ); + } + + #[test] + fn relay_observability_rejects_explicit_v2() { + let error = serde_json::from_value::(serde_json::json!({ + "version": 2, + "atif": {"enabled": true} + })) + .expect_err("Relay v2 observability config must fail"); + + assert!( + error + .to_string() + .contains("NeMo Relay 0.7 requires observability config version 3") + ); + } + + #[test] + fn generic_relay_observability_component_rejects_explicit_v2() { + for component_config in [ + BTreeMap::from([("version".to_string(), serde_json::json!(2))]), + BTreeMap::from([ + ("version".to_string(), serde_json::json!(2)), + ( + "opentelemetry".to_string(), + serde_json::json!({ + "enabled": true, + "endpoint": "http://localhost:4318/v1/traces" + }), + ), + ]), + ] { + let mut config = typed_config("nvidia.fabric.hermes"); + config.relay = Some(RelayConfig { + components: vec![RelayComponentConfig { + kind: "observability".to_string(), + enabled: true, + config: component_config, + extensions: BTreeMap::new(), + }], + ..RelayConfig::default() + }); + + let error = validate_config(&config).expect_err("Relay v2 component must fail"); + assert!(matches!( + error, + FabricError::InvalidConfig { field, .. } + if field == "relay.components.0.config.version" + )); + } + } + + #[test] + fn generic_relay_observability_component_accepts_implicit_v3() { + let mut config = typed_config("nvidia.fabric.hermes"); + config.relay = Some(RelayConfig { + components: vec![RelayComponentConfig { + kind: "observability".to_string(), + enabled: true, + config: BTreeMap::new(), + extensions: BTreeMap::new(), + }], + ..RelayConfig::default() + }); + + validate_config(&config).expect("Relay 0.7 defaults a missing version to v3"); + } + + #[test] + fn generic_relay_observability_component_requires_enabled_endpoint() { + for (opentelemetry, valid) in [ + (serde_json::json!({"enabled": true}), false), + (serde_json::json!({"enabled": true, "endpoints": []}), false), + (serde_json::json!({"enabled": false, "endpoints": []}), true), + ] { + let mut config = typed_config("nvidia.fabric.hermes"); + config.relay = Some(RelayConfig { + components: vec![RelayComponentConfig { + kind: "observability".to_string(), + enabled: true, + config: BTreeMap::from([ + ("version".to_string(), serde_json::json!(3)), + ("opentelemetry".to_string(), opentelemetry), + ]), + extensions: BTreeMap::new(), + }], + ..RelayConfig::default() + }); + + if valid { + validate_config(&config).expect("disabled exporter may omit endpoints"); + } else { + let error = + validate_config(&config).expect_err("enabled exporter needs an endpoint"); + assert!(matches!( + error, + FabricError::InvalidConfig { field, .. } + if field == "relay.components.0.config.opentelemetry.endpoints" + )); + } + } + } + + #[test] + fn enabled_relay_opentelemetry_requires_an_endpoint() { + let mut config = typed_config("nvidia.fabric.hermes"); + config.relay = Some(RelayConfig { + observability: Some(RelayObservabilityConfig { + opentelemetry: Some(RelayOpenTelemetryConfig { + enabled: true, + ..RelayOpenTelemetryConfig::default() + }), + ..RelayObservabilityConfig::default() + }), + ..RelayConfig::default() + }); + + let error = validate_config(&config).expect_err("enabled exporter needs an endpoint"); + assert!(matches!( + error, + FabricError::InvalidConfig { field, .. } + if field == "relay.observability.opentelemetry.endpoints" + )); + } + + #[test] + fn relay_observability_rejects_removed_v2_exporter_fields() { + let mut config = typed_config("nvidia.fabric.hermes"); + config.relay = Some(RelayConfig { + observability: Some( + serde_json::from_value(serde_json::json!({ + "version": 3, + "openinference": { + "enabled": true, + "endpoint": "http://localhost:6006/v1/traces" + } + })) + .expect("additive field deserializes before semantic validation"), + ), + ..RelayConfig::default() + }); + + let error = validate_config(&config).expect_err("removed exporter must fail"); + assert!(matches!( + error, + FabricError::InvalidConfig { field, .. } + if field == "relay.observability.openinference" + )); } #[test] diff --git a/docs/getting-started/install.mdx b/docs/getting-started/install.mdx index c76f76fed..379cf0476 100644 --- a/docs/getting-started/install.mdx +++ b/docs/getting-started/install.mdx @@ -43,7 +43,7 @@ install the NeMo Fabric runtime. For Claude and Codex, `full` is equivalent to LangChain Deep Agents and Hermes Agent, `full` installs both the harness and the NeMo Relay Python package. -For Claude and Codex, no Python package extra installs the external +For Claude and Codex, no NeMo Fabric package extra installs the external `nemo-relay` CLI required by Relay telemetry and `Runtime.invoke_stream()`. ### Install the NeMo Fabric Runtime @@ -159,28 +159,51 @@ install the harness and Relay package together. Claude and Codex do not provide a `relay` extra because their adapters launch the external CLI instead of importing this Python package. +NeMo Fabric requires `nemo-relay>=0.7.2,<0.8`. For Python-backed Relay +integrations, NeMo Fabric does not merge user or workspace `plugins.toml` +files with its runtime-owned configuration. Remove or relocate a user +`plugins.toml` or the nearest `.nemo-relay/plugins.toml` before starting a +Relay-enabled Deep Agents or Hermes Agent runtime. System configuration in +`/etc/nemo-relay/plugins.toml` remains administrator controlled. + +#### Migrate Relay observability configs from 0.6 + +Relay 0.7 requires observability schema version 3. Move flat OpenTelemetry +exporter fields into entries under `opentelemetry.endpoints`. In the Python SDK, +replace the removed `RelayOtlpConfig` model with `RelayOpenTelemetryConfig` and +one or more `RelayOpenTelemetryEndpointConfig` entries. + #### NeMo Relay CLI +Install the compatible Python package and CLI binary together: + +```bash +pip install "nemo-relay[cli]>=0.7.2,<0.8" +``` + **Shell installation method (Linux and macOS):** ```bash -curl -fsSL https://raw.githubusercontent.com/NVIDIA/NeMo-Relay/main/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/NVIDIA/NeMo-Relay/main/install.sh | + NEMO_RELAY_VERSION=0.7.2 sh -s -- ``` **Windows PowerShell installation method:** ```powershell +$env:NEMO_RELAY_VERSION = "0.7.2" irm https://raw.githubusercontent.com/NVIDIA/NeMo-Relay/main/install.ps1 | iex ``` **Cargo installation method (requires `cargo-binstall`):** ```bash -cargo binstall nemo-relay-cli +cargo binstall nemo-relay-cli --version 0.7.2 ``` -After installation, ensure `nemo-relay` is on `PATH`. When Relay is enabled -with `FabricConfig.enable_relay(...)`, the Claude and Codex adapters start and +After installation, run `nemo-relay --version` and confirm the version is in +the `>=0.7.2,<0.8` range. When Relay is enabled with +`FabricConfig.enable_relay(...)`, the Claude and Codex adapters start and supervise the CLI gateway automatically. ## Source Installation Method diff --git a/docs/integrations/harness/claude.mdx b/docs/integrations/harness/claude.mdx index 75d0038e8..d693ba962 100644 --- a/docs/integrations/harness/claude.mdx +++ b/docs/integrations/harness/claude.mdx @@ -31,8 +31,8 @@ pip install "nemo-fabric-adapters-claude[harness]" ``` The adapter package's `full` extra installs the same dependencies as `harness`; -it does not install the NeMo Fabric runtime. Claude Relay integration uses the -external `nemo-relay` CLI, not the Python `nemo-relay` package, so this adapter +it does not install the NeMo Fabric runtime. Claude Relay integration launches +the `nemo-relay` CLI and does not import the Relay Python API, so this adapter does not provide a `relay` extra. Follow the [NeMo Relay CLI installation procedure](../../getting-started/install.mdx#install-nemo-relay), then ensure `nemo-relay` is on `PATH`. @@ -170,7 +170,8 @@ gateway is scoped to that single invocation. Claude still resolves its credentia through the selected mode, and NeMo Fabric does not write authentication values to Relay configuration or artifacts. -NeMo Fabric supports the external NeMo Relay CLI from `0.6.0` up to, but not -including, `0.7.0`. The Python package named `nemo-relay` does not install this -CLI. NeMo Fabric owns sidecar supervision, Claude configuration, and upstream -selection. Relay owns the gateway transport and semantic observability pipeline. +NeMo Fabric supports the external NeMo Relay CLI in the `>=0.7.2,<0.8` range. +NeMo Fabric's `relay` extra installs only the Relay Python library and does not +install the CLI. NeMo Fabric owns sidecar supervision, Claude configuration, +and upstream selection. Relay owns the gateway transport and semantic +observability pipeline. diff --git a/docs/integrations/harness/codex.mdx b/docs/integrations/harness/codex.mdx index 2bb3332d2..fe8139e16 100644 --- a/docs/integrations/harness/codex.mdx +++ b/docs/integrations/harness/codex.mdx @@ -26,8 +26,8 @@ pip install "nemo-fabric-adapters-codex[harness]" ``` The adapter package's `full` extra installs the same dependencies as `harness`; -it does not install the NeMo Fabric runtime. Codex Relay integration uses the -external `nemo-relay` CLI, not the Python `nemo-relay` package, so this adapter +it does not install the NeMo Fabric runtime. Codex Relay integration launches +the `nemo-relay` CLI and does not import the Relay Python API, so this adapter does not provide a `relay` extra. Follow the [NeMo Relay CLI installation procedure](../../getting-started/install.mdx#install-nemo-relay), then ensure `nemo-relay` is on `PATH`. @@ -204,10 +204,10 @@ NeMo Fabric supplies runtime-scoped Relay configuration to the SDK. It does not copy the Codex credential store into Relay configuration or persist credentials in Relay artifacts. -NeMo Fabric supports the external NeMo Relay CLI from `0.6.0` up to, but not -including, `0.7.0`. The Python package named `nemo-relay` is a separate library -dependency and does not install the CLI. NeMo Fabric owns sidecar supervision and -runtime-scoped SDK configuration. Relay owns gateway transport behavior, +NeMo Fabric supports the external NeMo Relay CLI in the `>=0.7.2,<0.8` range. +NeMo Fabric's `relay` extra installs only the Relay Python library and does not +install the CLI. NeMo Fabric owns sidecar supervision and runtime-scoped SDK +configuration. Relay owns gateway transport behavior, including decoding `Content-Encoding` before it constructs managed LLM events. There is no NeMo Fabric compression setting. diff --git a/docs/reference/api/python-library-reference/index.md b/docs/reference/api/python-library-reference/index.md index 2a3db1a8f..d27c7e2d9 100644 --- a/docs/reference/api/python-library-reference/index.md +++ b/docs/reference/api/python-library-reference/index.md @@ -43,7 +43,8 @@ SPDX-License-Identifier: Apache-2.0 --> - [`models.RelayConfigPolicy`](./nemo_fabric.models.md#class-relayconfigpolicy): NeMo Relay config validation policy. - [`models.RelayHttpStorageConfig`](./nemo_fabric.models.md#class-relayhttpstorageconfig): NeMo Relay ATIF HTTP storage configuration. - [`models.RelayObservabilityConfig`](./nemo_fabric.models.md#class-relayobservabilityconfig): NeMo Relay observability component configuration. -- [`models.RelayOtlpConfig`](./nemo_fabric.models.md#class-relayotlpconfig): NeMo Relay OTLP export configuration for OpenTelemetry/OpenInference. +- [`models.RelayOpenTelemetryConfig`](./nemo_fabric.models.md#class-relayopentelemetryconfig): NeMo Relay typed OpenTelemetry destination configuration. +- [`models.RelayOpenTelemetryEndpointConfig`](./nemo_fabric.models.md#class-relayopentelemetryendpointconfig): One typed NeMo Relay OpenTelemetry destination. - [`models.RelayS3StorageConfig`](./nemo_fabric.models.md#class-relays3storageconfig): NeMo Relay ATIF S3 storage configuration. - [`models.RunRequest`](./nemo_fabric.models.md#class-runrequest): One validated NeMo Fabric invocation request. - [`models.RuntimeConfig`](./nemo_fabric.models.md#class-runtimeconfig): Invocation runtime contract. diff --git a/docs/reference/api/python-library-reference/nemo_fabric.models.md b/docs/reference/api/python-library-reference/nemo_fabric.models.md index 438bfa9e1..103dab96e 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.models.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.models.md @@ -1613,9 +1613,9 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- -## class `RelayOtlpConfig` +## class `RelayOpenTelemetryEndpointConfig` -NeMo Relay OTLP export configuration for OpenTelemetry/OpenInference. +One typed NeMo Relay OpenTelemetry destination. @@ -1625,15 +1625,19 @@ The model defines the following fields: | Field | Type | Required | Default | Constraints | Description | | --- | --- | --- | --- | --- | --- | -| `enabled` | `bool` | No | `False` | — | — | +| `type` | `Literal['full', 'gen_ai', 'openinference']` | Yes | — | — | — | +| `endpoint` | `str` | Yes | — | `MinLen(min_length=1), _PydanticGeneralMetadata(pattern='\\S')` | — | +| `mark_projection` | `Literal['inherit', 'event', 'tool']` | No | `'inherit'` | — | — | +| `mark_exclude_names` | `list[str]` | No | `` | — | — | +| `attribute_mappings` | `list[dict[str, str]]` | No | `list()` | — | — | | `transport` | `Literal['http_binary', 'grpc']` | No | `'http_binary'` | — | — | -| `endpoint` | `str \| None` | No | `None` | — | — | | `headers` | `dict[str, str]` | No | `dict()` | — | — | +| `header_env` | `dict[str, str]` | No | `dict()` | — | — | | `resource_attributes` | `dict[str, str]` | No | `dict()` | — | — | -| `service_name` | `str` | No | `'nemo-relay'` | — | — | +| `service_name` | `str` | No | `'unknown_service'` | — | — | | `service_namespace` | `str \| None` | No | `None` | — | — | | `service_version` | `str \| None` | No | `None` | — | — | -| `instrumentation_scope` | `str \| None` | No | `None` | — | — | +| `instrumentation_scope` | `str` | No | `'opentelemetry'` | — | — | | `timeout_millis` | `int` | No | `3000` | — | — | --- @@ -1661,6 +1665,77 @@ Returns the set of fields that have been explicitly set on this model instance. +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +def from_mapping(value: Mapping[str, Any]) -> Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +def to_mapping() -> dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `RelayOpenTelemetryConfig` + +NeMo Relay typed OpenTelemetry destination configuration. + + + +### Fields + +The model defines the following fields: + +| Field | Type | Required | Default | Constraints | Description | +| --- | --- | --- | --- | --- | --- | +| `enabled` | `bool` | No | `False` | — | — | +| `endpoints` | `list[RelayOpenTelemetryEndpointConfig]` | No | `list()` | — | — | + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + **Returns:** A set of strings representing the fields that have been set, i.e. that were not filled from defaults. @@ -1704,12 +1779,12 @@ The model defines the following fields: | Field | Type | Required | Default | Constraints | Description | | --- | --- | --- | --- | --- | --- | -| `version` | `int` | No | `2` | — | — | +| `version` | `Literal[3]` | No | `3` | — | — | | `atof` | `RelayAtofConfig \| dict[str, Any] \| None` | No | `None` | — | — | | `atif` | `RelayAtifConfig \| dict[str, Any] \| None` | No | `None` | — | — | -| `opentelemetry` | `RelayOtlpConfig \| dict[str, Any] \| None` | No | `None` | — | — | -| `openinference` | `RelayOtlpConfig \| dict[str, Any] \| None` | No | `None` | — | — | +| `opentelemetry` | `RelayOpenTelemetryConfig \| None` | No | `None` | — | — | | `policy` | `RelayConfigPolicy \| dict[str, Any] \| None` | No | `None` | — | — | +| `enable_full_payloads` | `bool` | No | `False` | — | — | --- @@ -1853,7 +1928,7 @@ The model defines the following fields: | --- | --- | --- | --- | --- | --- | | `project` | `str \| None` | No | `None` | — | — | | `output_dir` | `str \| Path \| None` | No | `None` | — | — | -| `observability` | `RelayObservabilityConfig \| dict[str, Any] \| None` | No | `None` | — | — | +| `observability` | `RelayObservabilityConfig \| None` | No | `None` | — | — | | `components` | `list[RelayComponentConfig \| dict[str, Any]]` | No | `list()` | — | — | | `policy` | `RelayConfigPolicy \| dict[str, Any] \| None` | No | `None` | — | — | @@ -2284,7 +2359,7 @@ The model defines the following fields: | `mcp` | `McpConfig \| None` | No | `None` | — | — | | `skills` | `SkillConfig \| None` | No | `None` | — | — | | `telemetry` | `TelemetryConfig \| None` | No | `None` | — | — | -| `relay` | `RelayConfig \| dict[str, Any] \| None` | No | `None` | — | — | +| `relay` | `RelayConfig \| None` | No | `None` | — | — | | `tools` | `ToolsConfig \| None` | No | `None` | — | — | --- diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx index c1805d349..ac89386a2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx @@ -2,7 +2,7 @@ title: "Enum Capability Kind" sidebar-title: "CapabilityKind" description: "Capability kind." -position: 60 +position: 61 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx index 93fa46eba..6e16a64a6 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx @@ -2,7 +2,7 @@ title: "Enum Capability Target" sidebar-title: "CapabilityTarget" description: "Component responsible for executing a configured capability." -position: 61 +position: 62 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx index 7dcfe167d..f9bebec10 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atif Storage Config" sidebar-title: "RelayAtifStorageConfig" description: "Relay ATIF remote storage configuration." -position: 69 +position: 70 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx index 898670318..f70516dbf 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Mode" sidebar-title: "RelayAtofMode" description: "Relay ATOF file mode." -position: 70 +position: 71 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx index a2737ff83..5e70a8088 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Sink Config" sidebar-title: "RelayAtofSinkConfig" description: "Relay ATOF sink configuration." -position: 71 +position: 72 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx index 6d14c925b..6505e38b4 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Stream Field Name Policy" sidebar-title: "RelayAtofStreamFieldNamePolicy" description: "Relay ATOF stream field-name policy." -position: 72 +position: 73 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx index e418962b6..7154c5cd2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Stream Transport" sidebar-title: "RelayAtofStreamTransport" description: "Relay ATOF stream transport." -position: 73 +position: 74 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetryendpointtype.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetryendpointtype.mdx new file mode 100644 index 000000000..879851bda --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetryendpointtype.mdx @@ -0,0 +1,122 @@ +--- +title: "Enum Relay Open Telemetry Endpoint Type" +sidebar-title: "RelayOpenTelemetryEndpointType" +description: "Relay OpenTelemetry span projection." +position: 75 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +```rust +pub enum RelayOpenTelemetryEndpointType { + Full, + GenAi, + Openinference, +} +``` + +Relay OpenTelemetry span projection. + +## Variants + +### `Full` + +
+ +Emit full Relay spans. + +### `GenAi` + +
+ +Emit GenAI semantic-convention spans. + +### `Openinference` + +
+ +Emit OpenInference semantic-convention spans. + +## Trait Implementations + +### `impl Clone for RelayOpenTelemetryEndpointType` + +
Clone for RelayOpenTelemetryEndpointType"}} />
+ +#### `clone` + +
clone(&self) -> RelayOpenTelemetryEndpointType"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayOpenTelemetryEndpointType` + +
Debug for RelayOpenTelemetryEndpointType"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for RelayOpenTelemetryEndpointType` + +
Deserialize<'de> for RelayOpenTelemetryEndpointType"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayOpenTelemetryEndpointType` + +
RelayOpenTelemetryEndpointType"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayOpenTelemetryEndpointType` + +
PartialEq for RelayOpenTelemetryEndpointType"}} />
+ +#### `eq` + +
eq(&self, other: &RelayOpenTelemetryEndpointType) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayOpenTelemetryEndpointType` + +
Serialize for RelayOpenTelemetryEndpointType"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for RelayOpenTelemetryEndpointType` + +
Copy for RelayOpenTelemetryEndpointType"}} />
+ +### `impl Eq for RelayOpenTelemetryEndpointType` + +
Eq for RelayOpenTelemetryEndpointType"}} />
+ +### `impl StructuralPartialEq for RelayOpenTelemetryEndpointType` + +
StructuralPartialEq for RelayOpenTelemetryEndpointType"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetrymarkprojection.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetrymarkprojection.mdx new file mode 100644 index 000000000..b5d0c7086 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetrymarkprojection.mdx @@ -0,0 +1,130 @@ +--- +title: "Enum Relay Open Telemetry Mark Projection" +sidebar-title: "RelayOpenTelemetryMarkProjection" +description: "Relay OpenTelemetry mark projection policy." +position: 76 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +```rust +pub enum RelayOpenTelemetryMarkProjection { + Inherit, + Event, + Tool, +} +``` + +Relay OpenTelemetry mark projection policy. + +## Variants + +### `Inherit` + +
+ +Inherit the endpoint type's projection behavior. + +### `Event` + +
+ +Project marks as events. + +### `Tool` + +
+ +Project marks as tool spans. + +## Trait Implementations + +### `impl Clone for RelayOpenTelemetryMarkProjection` + +
Clone for RelayOpenTelemetryMarkProjection"}} />
+ +#### `clone` + +
clone(&self) -> RelayOpenTelemetryMarkProjection"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayOpenTelemetryMarkProjection` + +
Debug for RelayOpenTelemetryMarkProjection"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for RelayOpenTelemetryMarkProjection` + +
Default for RelayOpenTelemetryMarkProjection"}} />
+ +#### `default` + +
default() -> RelayOpenTelemetryMarkProjection"}} />
+ +### `impl<'de> Deserialize<'de> for RelayOpenTelemetryMarkProjection` + +
Deserialize<'de> for RelayOpenTelemetryMarkProjection"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayOpenTelemetryMarkProjection` + +
RelayOpenTelemetryMarkProjection"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayOpenTelemetryMarkProjection` + +
PartialEq for RelayOpenTelemetryMarkProjection"}} />
+ +#### `eq` + +
eq(&self, other: &RelayOpenTelemetryMarkProjection) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayOpenTelemetryMarkProjection` + +
Serialize for RelayOpenTelemetryMarkProjection"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for RelayOpenTelemetryMarkProjection` + +
Copy for RelayOpenTelemetryMarkProjection"}} />
+ +### `impl Eq for RelayOpenTelemetryMarkProjection` + +
Eq for RelayOpenTelemetryMarkProjection"}} />
+ +### `impl StructuralPartialEq for RelayOpenTelemetryMarkProjection` + +
StructuralPartialEq for RelayOpenTelemetryMarkProjection"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx index 1b35c1708..2f5bfb58e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Otlp Transport" sidebar-title: "RelayOtlpTransport" description: "Relay OTLP transport." -position: 74 +position: 77 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx index dc29ff649..4d643a08e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Unsupported Behavior" sidebar-title: "RelayUnsupportedBehavior" description: "Relay unsupported/unknown config handling." -position: 75 +position: 78 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx index eeb456ebb..da7744ae2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx @@ -56,7 +56,8 @@ NeMo Fabric config models and loading helpers. - [RelayConfig](struct-relayconfig.mdx): NeMo Relay integration configuration. - [RelayConfigPolicy](struct-relayconfigpolicy.mdx): Relay validation policy. - [RelayObservabilityConfig](struct-relayobservabilityconfig.mdx): NeMo Relay observability component configuration. -- [RelayOtlpConfig](struct-relayotlpconfig.mdx): Relay OpenTelemetry/OpenInference export configuration. +- [RelayOpenTelemetryConfig](struct-relayopentelemetryconfig.mdx): Relay OpenTelemetry export section. +- [RelayOpenTelemetryEndpointConfig](struct-relayopentelemetryendpointconfig.mdx): One typed Relay OpenTelemetry OTLP destination. - [ResolveContext](struct-resolvecontext.mdx): Source context used when resolving an in-memory NeMo Fabric config. - [ResolvedAdapterDescriptor](struct-resolvedadapterdescriptor.mdx): Adapter descriptor selected for a run plan. - [RunPlan](struct-runplan.mdx): Resolved NeMo Fabric run plan. @@ -92,6 +93,8 @@ NeMo Fabric config models and loading helpers. - [RelayAtofSinkConfig](enum-relayatofsinkconfig.mdx): Relay ATOF sink configuration. - [RelayAtofStreamFieldNamePolicy](enum-relayatofstreamfieldnamepolicy.mdx): Relay ATOF stream field-name policy. - [RelayAtofStreamTransport](enum-relayatofstreamtransport.mdx): Relay ATOF stream transport. +- [RelayOpenTelemetryEndpointType](enum-relayopentelemetryendpointtype.mdx): Relay OpenTelemetry span projection. +- [RelayOpenTelemetryMarkProjection](enum-relayopentelemetrymarkprojection.mdx): Relay OpenTelemetry mark projection policy. - [RelayOtlpTransport](enum-relayotlptransport.mdx): Relay OTLP transport. - [RelayUnsupportedBehavior](enum-relayunsupportedbehavior.mdx): Relay unsupported/unknown config handling. - [ResolutionStrategy](enum-resolutionstrategy.mdx): Adapter install or availability strategy. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx index 4848b8383..706204b76 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
u32,\n    pub atof: Option<RelayAtofConfig>,\n    pub atif: Option<RelayAtifConfig>,\n    pub opentelemetry: Option<RelayOtlpConfig>,\n    pub openinference: Option<RelayOtlpConfig>,\n    pub policy: Option<RelayConfigPolicy>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
u32,\n    pub atof: Option<RelayAtofConfig>,\n    pub atif: Option<RelayAtifConfig>,\n    pub opentelemetry: Option<RelayOpenTelemetryConfig>,\n    pub policy: Option<RelayConfigPolicy>,\n    pub enable_full_payloads: bool,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
NeMo Relay observability component configuration. @@ -27,18 +27,18 @@ ATOF export configuration. ATIF export configuration. -### `opentelemetry: Option` +### `opentelemetry: Option` -OpenTelemetry export configuration. - -### `openinference: Option` - -OpenInference export configuration. +OpenTelemetry and OpenInference export configuration. ### `policy: Option` Relay config validation policy. +### `enable_full_payloads: bool` + +Retain complete sanitized request data on LLM start events. + ### `extensions: BTreeMap` Additive observability fields. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayotlpconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayopentelemetryconfig.mdx similarity index 57% rename from docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayotlpconfig.mdx rename to docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayopentelemetryconfig.mdx index 702ff1def..8c4cd538e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayotlpconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayopentelemetryconfig.mdx @@ -1,7 +1,7 @@ --- -title: "Struct Relay Otlp Config" -sidebar-title: "RelayOtlpConfig" -description: "Relay OpenTelemetry/OpenInference export configuration." +title: "Struct Relay Open Telemetry Config" +sidebar-title: "RelayOpenTelemetryConfig" +description: "Relay OpenTelemetry export section." position: 41 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. @@ -9,97 +9,65 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
bool,\n    pub transport: RelayOtlpTransport,\n    pub endpoint: Option<String>,\n    pub headers: BTreeMap<String, String>,\n    pub resource_attributes: BTreeMap<String, String>,\n    pub service_name: String,\n    pub service_namespace: Option<String>,\n    pub service_version: Option<String>,\n    pub instrumentation_scope: Option<String>,\n    pub timeout_millis: u64,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
bool,\n    pub endpoints: Vec<RelayOpenTelemetryEndpointConfig>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
-Relay OpenTelemetry/OpenInference export configuration. +Relay OpenTelemetry export section. ## Fields ### `enabled: bool` -Whether OTLP export is enabled. +Whether OpenTelemetry export is enabled. -### `transport: RelayOtlpTransport` +### `endpoints: Vec` -OTLP transport. - -### `endpoint: Option` - -OTLP endpoint. - -### `headers: BTreeMap` - -OTLP headers. - -### `resource_attributes: BTreeMap` - -OTLP resource attributes. - -### `service_name: String` - -OTLP service name. - -### `service_namespace: Option` - -OTLP service namespace. - -### `service_version: Option` - -OTLP service version. - -### `instrumentation_scope: Option` - -OTLP instrumentation scope. - -### `timeout_millis: u64` - -Request timeout in milliseconds. +Typed OTLP destinations. ### `extensions: BTreeMap` -Additive OTLP fields. +Additive OpenTelemetry section fields. ## Trait Implementations -### `impl Clone for RelayOtlpConfig` +### `impl Clone for RelayOpenTelemetryConfig` -
Clone for RelayOtlpConfig"}} />
+
Clone for RelayOpenTelemetryConfig"}} />
#### `clone` -
clone(&self) -> RelayOtlpConfig"}} />
+
clone(&self) -> RelayOpenTelemetryConfig"}} />
#### `clone_from`
clone_from(&mut self, source: &Self)"}} />
-### `impl Debug for RelayOtlpConfig` +### `impl Debug for RelayOpenTelemetryConfig` -
Debug for RelayOtlpConfig"}} />
+
Debug for RelayOpenTelemetryConfig"}} />
#### `fmt`
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
-### `impl Default for RelayOtlpConfig` +### `impl Default for RelayOpenTelemetryConfig` -
Default for RelayOtlpConfig"}} />
+
Default for RelayOpenTelemetryConfig"}} />
#### `default` -
default() -> Self"}} />
+
default() -> RelayOpenTelemetryConfig"}} />
-### `impl<'de> Deserialize<'de> for RelayOtlpConfig` +### `impl<'de> Deserialize<'de> for RelayOpenTelemetryConfig` -
Deserialize<'de> for RelayOtlpConfig"}} />
+
Deserialize<'de> for RelayOpenTelemetryConfig"}} />
#### `deserialize`
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
-### `impl JsonSchema for RelayOtlpConfig` +### `impl JsonSchema for RelayOpenTelemetryConfig` -
RelayOtlpConfig"}} />
+
RelayOpenTelemetryConfig"}} />
#### `schema_name` @@ -117,26 +85,26 @@ Additive OTLP fields.
bool"}} />
-### `impl PartialEq for RelayOtlpConfig` +### `impl PartialEq for RelayOpenTelemetryConfig` -
PartialEq for RelayOtlpConfig"}} />
+
PartialEq for RelayOpenTelemetryConfig"}} />
#### `eq` -
eq(&self, other: &RelayOtlpConfig) -> bool"}} />
+
eq(&self, other: &RelayOpenTelemetryConfig) -> bool"}} />
#### `ne`
ne(&self, other: &Rhs) -> bool"}} />
-### `impl Serialize for RelayOtlpConfig` +### `impl Serialize for RelayOpenTelemetryConfig` -
Serialize for RelayOtlpConfig"}} />
+
Serialize for RelayOpenTelemetryConfig"}} />
#### `serialize`
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
-### `impl StructuralPartialEq for RelayOtlpConfig` +### `impl StructuralPartialEq for RelayOpenTelemetryConfig` -
StructuralPartialEq for RelayOtlpConfig"}} />
+
StructuralPartialEq for RelayOpenTelemetryConfig"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayopentelemetryendpointconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayopentelemetryendpointconfig.mdx new file mode 100644 index 000000000..1678f7e6d --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayopentelemetryendpointconfig.mdx @@ -0,0 +1,150 @@ +--- +title: "Struct Relay Open Telemetry Endpoint Config" +sidebar-title: "RelayOpenTelemetryEndpointConfig" +description: "One typed Relay OpenTelemetry OTLP destination." +position: 42 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
RelayOpenTelemetryEndpointType,\n    pub endpoint: String,\n    pub mark_projection: RelayOpenTelemetryMarkProjection,\n    pub mark_exclude_names: Vec<String>,\n    pub attribute_mappings: Vec<BTreeMap<String, String>>,\n    pub transport: RelayOtlpTransport,\n    pub service_name: String,\n    pub service_namespace: Option<String>,\n    pub service_version: Option<String>,\n    pub instrumentation_scope: String,\n    pub timeout_millis: u64,\n    pub headers: BTreeMap<String, String>,\n    pub header_env: BTreeMap<String, String>,\n    pub resource_attributes: BTreeMap<String, String>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+ +One typed Relay OpenTelemetry OTLP destination. + +## Fields + +### `type: RelayOpenTelemetryEndpointType` + +Span projection emitted to this destination. + +### `endpoint: String` + +OTLP endpoint. + +### `mark_projection: RelayOpenTelemetryMarkProjection` + +Mark projection policy. + +### `mark_exclude_names: Vec` + +Event names excluded from mark projection. + +### `attribute_mappings: Vec>` + +Attribute mapping rules. + +### `transport: RelayOtlpTransport` + +OTLP transport. + +### `service_name: String` + +OTLP service name. + +### `service_namespace: Option` + +OTLP service namespace. + +### `service_version: Option` + +OTLP service version. + +### `instrumentation_scope: String` + +OTLP instrumentation scope. + +### `timeout_millis: u64` + +Request timeout in milliseconds. + +### `headers: BTreeMap` + +Static OTLP headers. + +### `header_env: BTreeMap` + +Environment-variable-backed OTLP headers. + +### `resource_attributes: BTreeMap` + +OTLP resource attributes. + +### `extensions: BTreeMap` + +Additive endpoint fields. + +## Trait Implementations + +### `impl Clone for RelayOpenTelemetryEndpointConfig` + +
Clone for RelayOpenTelemetryEndpointConfig"}} />
+ +#### `clone` + +
clone(&self) -> RelayOpenTelemetryEndpointConfig"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayOpenTelemetryEndpointConfig` + +
Debug for RelayOpenTelemetryEndpointConfig"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for RelayOpenTelemetryEndpointConfig` + +
Deserialize<'de> for RelayOpenTelemetryEndpointConfig"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayOpenTelemetryEndpointConfig` + +
RelayOpenTelemetryEndpointConfig"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayOpenTelemetryEndpointConfig` + +
PartialEq for RelayOpenTelemetryEndpointConfig"}} />
+ +#### `eq` + +
eq(&self, other: &RelayOpenTelemetryEndpointConfig) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayOpenTelemetryEndpointConfig` + +
Serialize for RelayOpenTelemetryEndpointConfig"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for RelayOpenTelemetryEndpointConfig` + +
StructuralPartialEq for RelayOpenTelemetryEndpointConfig"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx index 1784e77d0..a4c740a77 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx @@ -2,7 +2,7 @@ title: "Struct Tools Plan" sidebar-title: "ToolsPlan" description: "Normalized tool policy for a run." -position: 53 +position: 54 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index f663b2566..f7a372098 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -381,7 +381,7 @@ variant = review_agent_config(config, github_mcp=True, relay=True) ``` NeMo Relay observability is represented directly in the SDK config's top-level -`relay` block. ATOF uses NeMo Relay 0.6 file and stream sinks: +`relay` block. Configure ATOF file and stream sinks as follows: ```python from nemo_fabric import ( @@ -610,9 +610,9 @@ Streaming has the following v0.1 constraints: `stream.result()` still receives the terminal result and does not run this warning check. -Claude and Codex streaming use the NeMo Relay `nemo-relay` gateway CLI and require a -stream-sink-capable release from `0.6.0` up to, but not including, `0.7.0`. Follow -the [NeMo Relay CLI installation instructions](../getting-started/install.mdx#install-nemo-relay) +Claude and Codex streaming use the NeMo Relay `nemo-relay` gateway CLI and +require a release in the `>=0.7.2,<0.8` range. Follow the +[NeMo Relay CLI installation instructions](../getting-started/install.mdx#install-nemo-relay) to provision it. Hermes Agent and Deep Agents use their in-process NeMo Relay integrations. diff --git a/examples/code_review_agent/README.md b/examples/code_review_agent/README.md index b471ec978..37a7b10e6 100644 --- a/examples/code_review_agent/README.md +++ b/examples/code_review_agent/README.md @@ -74,10 +74,9 @@ The entrypoint exposes complete harness configs defined in Add `--relay` to any variant to enable the Relay ATOF and ATIF configuration: Relay requirements depend on the selected adapter. The Codex and Claude -adapters require an external `nemo-relay` CLI in the supported `0.6.x` range; -the Python package named `nemo-relay` does not install the `nemo-relay` CLI -tool. Hermes Agent and Deep Agents require the Relay Python package in their -selected adapter environment. Refer to the +adapters require a `nemo-relay` CLI in the `>=0.7.2,<0.8` range. NeMo Fabric's +`relay` extra does not install the CLI. Hermes Agent and Deep Agents require the +Relay Python package in their selected adapter environment. Refer to the [installation guide](../../docs/getting-started/install.mdx#install-nemo-relay) for the current compatibility requirements. @@ -117,3 +116,7 @@ Each function returns a deep copy. The four configs can therefore be planned or run independently with `base_dir=BASE_DIR`. Set `GITHUB_MCP_URL` before running `github_config`; it maps the server into the selected harness's native MCP configuration. The default smoke does not configure or contact that server. + +`with_native_otel` supports the Codex and Deep Agents adapters. It raises +`ValueError` for Hermes Agent and Claude, whose native telemetry contracts do +not accept this configuration. diff --git a/examples/code_review_agent/config.py b/examples/code_review_agent/config.py index 3a706f28d..b418b0741 100644 --- a/examples/code_review_agent/config.py +++ b/examples/code_review_agent/config.py @@ -6,6 +6,7 @@ from __future__ import annotations from pathlib import Path +from typing import Any from nemo_fabric import EnvironmentConfig from nemo_fabric import FabricConfig @@ -18,7 +19,8 @@ from nemo_fabric import RelayAtofConfig from nemo_fabric import RelayAtofFileSinkConfig from nemo_fabric import RelayObservabilityConfig -from nemo_fabric import RelayOtlpConfig +from nemo_fabric import RelayOpenTelemetryConfig +from nemo_fabric import RelayOpenTelemetryEndpointConfig from nemo_fabric import RuntimeConfig from nemo_fabric import TelemetryConfig from nemo_fabric import ToolsConfig @@ -263,16 +265,20 @@ def with_relay_otel(base: FabricConfig) -> FabricConfig: config.enable_relay( output_dir="./artifacts/relay-otel", observability=RelayObservabilityConfig( - opentelemetry=RelayOtlpConfig( + opentelemetry=RelayOpenTelemetryConfig( enabled=True, - transport="http_binary", - endpoint="http://localhost:4318/v1/traces", - service_name="code-review-agent", - service_namespace="fabric", - service_version="fabric-sdk-example", - instrumentation_scope="nemo-relay-otel", - timeout_millis=3000, - resource_attributes={"deployment.environment": "dev"}, + endpoints=[ + RelayOpenTelemetryEndpointConfig( + type="full", + transport="http_binary", + endpoint="http://localhost:4318/v1/traces", + service_name="code-review-agent", + service_namespace="fabric", + service_version="fabric-sdk-example", + timeout_millis=3000, + resource_attributes={"deployment.environment": "dev"}, + ) + ], ), ), ) @@ -292,10 +298,15 @@ def with_relay_openinference(base: FabricConfig) -> FabricConfig: assert not isinstance(observability, dict) relay.output_dir = "./artifacts/relay-openinference" - observability.openinference = RelayOtlpConfig( + observability.opentelemetry = RelayOpenTelemetryConfig( enabled=True, - transport="http_binary", - endpoint="http://localhost:6006/v1/traces", + endpoints=[ + RelayOpenTelemetryEndpointConfig( + type="openinference", + transport="http_binary", + endpoint="http://localhost:6006/v1/traces", + ) + ], ) if isinstance(observability.atif, RelayAtifConfig): observability.atif.output_directory = "./artifacts/relay-openinference" @@ -310,6 +321,38 @@ def with_native_otel(base: FabricConfig) -> FabricConfig: """Return a copy with adapter-native OpenTelemetry enabled.""" config = base.model_copy(deep=True) + adapter_id = config.harness.adapter_id + observability: dict[str, Any] + if adapter_id == "nvidia.fabric.codex": + observability = { + "version": 1, + "opentelemetry": { + "enabled": True, + "transport": "http_binary", + "endpoint": "http://localhost:4318/v1/traces", + "resource_attributes": {"deployment.environment": "dev"}, + }, + } + elif adapter_id == "nvidia.fabric.langchain.deepagents": + observability = { + "version": 3, + "opentelemetry": { + "enabled": True, + "endpoints": [ + { + "type": "full", + "transport": "http_binary", + "endpoint": "http://localhost:4318/v1/traces", + "resource_attributes": {"deployment.environment": "dev"}, + } + ], + }, + } + else: + raise ValueError( + f"adapter {adapter_id!r} does not support native OpenTelemetry" + ) + config.telemetry = TelemetryConfig( providers={ "native": { @@ -319,15 +362,7 @@ def with_native_otel(base: FabricConfig) -> FabricConfig: { "kind": "observability", "enabled": True, - "config": { - "version": 1, - "opentelemetry": { - "enabled": True, - "transport": "http_binary", - "endpoint": "http://localhost:4318/v1/traces", - "resource_attributes": {"deployment.environment": "dev"}, - }, - }, + "config": observability, }, ], }, diff --git a/examples/harbor/README.md b/examples/harbor/README.md index 9f45ca168..fe712f9c2 100644 --- a/examples/harbor/README.md +++ b/examples/harbor/README.md @@ -73,7 +73,7 @@ version `0.2.0`. | --- | --- | --- | | Harbor host | `nemo-fabric[harbor]==0.2.0` | Harbor CLI, `FabricAgent`, and typed `FabricConfig` construction | | Claude task without Relay | `nemo-fabric[claude]==0.2.0` | NeMo Fabric runner, Claude adapter, and supported Claude harness | -| Claude task with Relay | `nemo-fabric[claude]==0.2.0` plus a NeMo Relay 0.6.x CLI on `PATH` | NeMo Fabric runner, Claude adapter and harness, and the adapter-managed Relay gateway and hooks | +| Claude task with Relay | `nemo-fabric[claude]==0.2.0` plus a NeMo Relay CLI in the `>=0.7.2,<0.8` range on `PATH` | NeMo Fabric runner, Claude adapter and harness, and the adapter-managed Relay gateway and hooks | | Hermes Agent task with Relay | `nemo-fabric[hermes-agent,relay]==0.2.0` | NeMo Fabric runner, Hermes Agent adapter and harness, and the NeMo Relay Python package | The `nemo-fabric` package installs the runtime. The `relay` extra installs the diff --git a/examples/harbor/swebench/README.md b/examples/harbor/swebench/README.md index d6f8556a5..8ae28dbc1 100644 --- a/examples/harbor/swebench/README.md +++ b/examples/harbor/swebench/README.md @@ -31,11 +31,11 @@ export FABRIC_PACKAGE='nemo-fabric[claude,hermes-agent,relay]==0.2.0' export RUNS_DIR="$PWD/.tmp/harbor/fabric-swebench" curl -fsSL https://raw.githubusercontent.com/NVIDIA/NeMo-Relay/main/install.sh | - NEMO_RELAY_VERSION=0.6.0 sh -s -- \ + NEMO_RELAY_VERSION=0.7.2 sh -s -- \ --install-dir "$FABRIC_BUNDLE/.relay/bin" ``` -The curl command downloads and installs the standalone NeMo Relay 0.6.0 CLI tool +The curl command downloads and installs the standalone NeMo Relay 0.7.2 CLI tool and verifies its checksum. For other installation methods, refer to the [NeMo Relay installation instructions](../../../docs/getting-started/install.mdx#install-nemo-relay). diff --git a/examples/langgraph_custom_agent/adapter/telemetry.py b/examples/langgraph_custom_agent/adapter/telemetry.py index 5eba43433..b0ac3e9ce 100644 --- a/examples/langgraph_custom_agent/adapter/telemetry.py +++ b/examples/langgraph_custom_agent/adapter/telemetry.py @@ -49,9 +49,7 @@ def _load_plugin_config( "Relay is enabled but RuntimeContext has no Relay configuration path", ) try: - wrapper = json.loads( - Path(telemetry.config_path).read_text(encoding="utf-8") - ) + wrapper = json.loads(Path(telemetry.config_path).read_text(encoding="utf-8")) plugin_config = wrapper.get("relay", {}).get("config") or {} if "components" not in plugin_config: plugin_config = { @@ -60,7 +58,7 @@ def _load_plugin_config( { "kind": "observability", "enabled": True, - "config": plugin_config or {"version": 2}, + "config": plugin_config or {"version": 3}, } ], } @@ -125,12 +123,14 @@ async def observe_invocation( agent_name=agent_name, model_name=model_name, ) + common_utils.reject_ambient_relay_plugin_config() plugin, scope, scope_type, callback_handler = _relay_api() observation = InvocationTelemetry( runnable_config={"callbacks": [callback_handler()]}, plugin_config=plugin_config, ) - async with plugin.plugin(plugin_config): + async with plugin.plugin(plugin_config) as activation_report: + common_utils.reject_inherited_relay_plugin_config(activation_report) with scope.scope( "email-phishing-invocation", scope_type.Agent, diff --git a/pyproject.toml b/pyproject.toml index 97e7f2517..d72482d50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ hermes-agent = [ ] relay = [ - "nemo-relay>=0.6.0,<0.7", + "nemo-relay>=0.7.2,<0.8", ] [dependency-groups] diff --git a/python/src/nemo_fabric/__init__.py b/python/src/nemo_fabric/__init__.py index 2f676f3e3..5c656292f 100644 --- a/python/src/nemo_fabric/__init__.py +++ b/python/src/nemo_fabric/__init__.py @@ -30,7 +30,8 @@ from nemo_fabric.models import RelayConfigPolicy from nemo_fabric.models import RelayHttpStorageConfig from nemo_fabric.models import RelayObservabilityConfig -from nemo_fabric.models import RelayOtlpConfig +from nemo_fabric.models import RelayOpenTelemetryConfig +from nemo_fabric.models import RelayOpenTelemetryEndpointConfig from nemo_fabric.models import RelayS3StorageConfig from nemo_fabric.models import RunRequest from nemo_fabric.models import RuntimeConfig @@ -90,7 +91,8 @@ "RelayConfigPolicy", "RelayHttpStorageConfig", "RelayObservabilityConfig", - "RelayOtlpConfig", + "RelayOpenTelemetryConfig", + "RelayOpenTelemetryEndpointConfig", "RelayS3StorageConfig", "RelayConfig", "FabricNativeUnavailableError", diff --git a/python/src/nemo_fabric/models.py b/python/src/nemo_fabric/models.py index d57c3f496..7df70eb8c 100644 --- a/python/src/nemo_fabric/models.py +++ b/python/src/nemo_fabric/models.py @@ -30,6 +30,27 @@ from pydantic import model_validator +_LEGACY_FLAT_OTEL_FIELDS = frozenset( + { + "attribute_mappings", + "capture_content", + "endpoint", + "header_env", + "headers", + "instrumentation_scope", + "mark_exclude_names", + "mark_projection", + "resource_attributes", + "semantic_selector", + "service_name", + "service_namespace", + "service_version", + "timeout_millis", + "transport", + } +) + + def _json_value(value: Any, name: str) -> Any: """Validate and detach a JSON-compatible value.""" @@ -650,30 +671,82 @@ class RelayAtifConfig(FabricBaseModel): ) = None -class RelayOtlpConfig(FabricBaseModel): - """NeMo Relay OTLP export configuration for OpenTelemetry/OpenInference.""" +class RelayOpenTelemetryEndpointConfig(FabricBaseModel): + """One typed NeMo Relay OpenTelemetry destination.""" - enabled: bool = False + type: Literal["full", "gen_ai", "openinference"] + endpoint: str = Field(min_length=1, pattern=r"\S") + mark_projection: Literal["inherit", "event", "tool"] = "inherit" + mark_exclude_names: list[str] = Field(default_factory=lambda: ["llm.chunk"]) + attribute_mappings: list[dict[str, str]] = Field( + default_factory=list, exclude_if=lambda value: not value + ) transport: Literal["http_binary", "grpc"] = "http_binary" - endpoint: str | None = None - headers: dict[str, str] = Field(default_factory=dict) - resource_attributes: dict[str, str] = Field(default_factory=dict) - service_name: str = "nemo-relay" + headers: dict[str, str] = Field( + default_factory=dict, exclude_if=lambda value: not value + ) + header_env: dict[str, str] = Field( + default_factory=dict, exclude_if=lambda value: not value + ) + resource_attributes: dict[str, str] = Field( + default_factory=dict, exclude_if=lambda value: not value + ) + service_name: str = "unknown_service" service_namespace: str | None = None service_version: str | None = None - instrumentation_scope: str | None = None + instrumentation_scope: str = "opentelemetry" timeout_millis: int = 3000 +class RelayOpenTelemetryConfig(FabricBaseModel): + """NeMo Relay typed OpenTelemetry destination configuration.""" + + enabled: bool = False + endpoints: list[RelayOpenTelemetryEndpointConfig] = Field( + default_factory=list, exclude_if=lambda value: not value + ) + + @model_validator(mode="after") + def _validate_v3_shape(self) -> Self: + legacy_fields = _LEGACY_FLAT_OTEL_FIELDS.intersection(self.model_extra or {}) + if legacy_fields: + fields = ", ".join(sorted(legacy_fields)) + raise ValueError( + "NeMo Relay observability config version 3 requires exporter " + f"fields inside opentelemetry.endpoints: {fields}" + ) + if self.enabled and not self.endpoints: + raise ValueError( + "enabled NeMo Relay OpenTelemetry requires at least one endpoint" + ) + return self + + class RelayObservabilityConfig(FabricBaseModel): """NeMo Relay observability component configuration.""" - version: int = 2 + version: Literal[3] = 3 atof: RelayAtofConfig | dict[str, Any] | None = None atif: RelayAtifConfig | dict[str, Any] | None = None - opentelemetry: RelayOtlpConfig | dict[str, Any] | None = None - openinference: RelayOtlpConfig | dict[str, Any] | None = None + opentelemetry: RelayOpenTelemetryConfig | None = None policy: RelayConfigPolicy | dict[str, Any] | None = None + enable_full_payloads: bool = False + + @field_validator("version", mode="before") + @classmethod + def _validate_version(cls, value: Any) -> Any: + if not isinstance(value, int) or isinstance(value, bool) or value != 3: + raise ValueError("NeMo Relay 0.7 requires observability config version 3") + return value + + @model_validator(mode="after") + def _reject_legacy_openinference_section(self) -> Self: + if "openinference" in (self.model_extra or {}): + raise ValueError( + "NeMo Relay observability config version 3 requires OpenInference " + "as an opentelemetry endpoint" + ) + return self class RelayComponentConfig(FabricBaseModel): @@ -689,12 +762,61 @@ class RelayConfig(FabricBaseModel): project: str | None = None output_dir: str | Path | None = None - observability: RelayObservabilityConfig | dict[str, Any] | None = None + observability: RelayObservabilityConfig | None = None components: list[RelayComponentConfig | dict[str, Any]] = Field( default_factory=list ) policy: RelayConfigPolicy | dict[str, Any] | None = None + @model_validator(mode="after") + def _validate_observability_component_versions(self) -> Self: + for index, component in enumerate(self.components): + if isinstance(component, RelayComponentConfig): + kind = component.kind + config = component.config + else: + kind = component.get("kind") + config = component.get("config", {}) + if kind != "observability": + continue + if "version" in config: + version = config["version"] + if ( + not isinstance(version, int) + or isinstance(version, bool) + or version != 3 + ): + raise ValueError( + "NeMo Relay 0.7 requires observability config version 3 " + f"for relay.components[{index}]" + ) + if "openinference" in config: + raise ValueError( + "NeMo Relay observability config version 3 requires OpenInference " + f"as an opentelemetry endpoint for relay.components[{index}]" + ) + opentelemetry = config.get("opentelemetry") + if isinstance(opentelemetry, Mapping): + legacy_fields = sorted( + _LEGACY_FLAT_OTEL_FIELDS.intersection(opentelemetry) + ) + if legacy_fields: + fields = ", ".join(legacy_fields) + raise ValueError( + "NeMo Relay observability config version 3 requires exporter " + "fields inside opentelemetry.endpoints for " + f"relay.components[{index}]: {fields}" + ) + endpoints = opentelemetry.get("endpoints") + if opentelemetry.get("enabled") is True and ( + not isinstance(endpoints, list) or not endpoints + ): + raise ValueError( + "enabled NeMo Relay OpenTelemetry requires at least one " + f"endpoint for relay.components[{index}]" + ) + return self + class TelemetryProviderConfig(FabricBaseModel): """Provider-specific telemetry configuration.""" @@ -849,7 +971,7 @@ class FabricConfig(FabricBaseModel): mcp: McpConfig | None = None skills: SkillConfig | None = None telemetry: TelemetryConfig | None = None - relay: RelayConfig | dict[str, Any] | None = None + relay: RelayConfig | None = None tools: ToolsConfig | None = None @classmethod @@ -989,15 +1111,10 @@ def enable_relay( ) -> Self: """Enable NeMo Relay telemetry and return this config.""" - if self.telemetry is None: - self.telemetry = TelemetryConfig() - self.telemetry.enable_relay() if self.relay is None: relay = RelayConfig() - elif isinstance(self.relay, RelayConfig): - relay = self.relay.model_copy(deep=True) else: - relay = RelayConfig.from_mapping(self.relay) + relay = self.relay.model_copy(deep=True) if project is not None: relay.project = project if output_dir is not None: @@ -1006,17 +1123,25 @@ def enable_relay( relay.observability = ( observability if isinstance(observability, RelayObservabilityConfig) - else dict(observability) + else RelayObservabilityConfig.model_validate(observability) ) if components is not None: relay.components = [ - item if isinstance(item, RelayComponentConfig) else dict(item) + item + if isinstance(item, RelayComponentConfig) + else RelayComponentConfig.model_validate(item) for item in components ] if policy is not None: relay.policy = ( - policy if isinstance(policy, RelayConfigPolicy) else dict(policy) + policy + if isinstance(policy, RelayConfigPolicy) + else RelayConfigPolicy.model_validate(policy) ) + relay = RelayConfig.model_validate(relay.model_dump(mode="python")) + if self.telemetry is None: + self.telemetry = TelemetryConfig() + self.telemetry.enable_relay() self.relay = relay return self diff --git a/schemas/agent.schema.json b/schemas/agent.schema.json index 0eb3bab99..888194bba 100644 --- a/schemas/agent.schema.json +++ b/schemas/agent.schema.json @@ -971,27 +971,21 @@ ], "description": "ATOF export configuration." }, - "openinference": { - "anyOf": [ - { - "$ref": "#/$defs/RelayOtlpConfig" - }, - { - "type": "null" - } - ], - "description": "OpenInference export configuration." + "enable_full_payloads": { + "default": false, + "description": "Retain complete sanitized request data on LLM start events.", + "type": "boolean" }, "opentelemetry": { "anyOf": [ { - "$ref": "#/$defs/RelayOtlpConfig" + "$ref": "#/$defs/RelayOpenTelemetryConfig" }, { "type": "null" } ], - "description": "OpenTelemetry export configuration." + "description": "OpenTelemetry and OpenInference export configuration." }, "policy": { "anyOf": [ @@ -1005,45 +999,88 @@ "description": "Relay config validation policy." }, "version": { - "default": 2, + "const": 3, + "default": 3, "description": "Relay observability config version.", "format": "uint32", - "maximum": 4294967295, "minimum": 0, "type": "integer" } }, "type": "object" }, - "RelayOtlpConfig": { + "RelayOpenTelemetryConfig": { "additionalProperties": true, - "description": "Relay OpenTelemetry/OpenInference export configuration.", + "description": "Relay OpenTelemetry export section.", "properties": { "enabled": { "default": false, - "description": "Whether OTLP export is enabled.", + "description": "Whether OpenTelemetry export is enabled.", "type": "boolean" }, + "endpoints": { + "description": "Typed OTLP destinations.", + "items": { + "$ref": "#/$defs/RelayOpenTelemetryEndpointConfig" + }, + "type": "array" + } + }, + "type": "object" + }, + "RelayOpenTelemetryEndpointConfig": { + "additionalProperties": true, + "description": "One typed Relay OpenTelemetry OTLP destination.", + "properties": { + "attribute_mappings": { + "description": "Attribute mapping rules.", + "items": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": "array" + }, "endpoint": { "description": "OTLP endpoint.", - "type": [ - "string", - "null" - ] + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "header_env": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment-variable-backed OTLP headers.", + "type": "object" }, "headers": { "additionalProperties": { "type": "string" }, - "description": "OTLP headers.", + "description": "Static OTLP headers.", "type": "object" }, "instrumentation_scope": { + "default": "opentelemetry", "description": "OTLP instrumentation scope.", - "type": [ - "string", - "null" - ] + "type": "string" + }, + "mark_exclude_names": { + "default": [ + "llm.chunk" + ], + "description": "Event names excluded from mark projection.", + "items": { + "type": "string" + }, + "type": "array" + }, + "mark_projection": { + "$ref": "#/$defs/RelayOpenTelemetryMarkProjection", + "default": "inherit", + "description": "Mark projection policy." }, "resource_attributes": { "additionalProperties": { @@ -1053,7 +1090,7 @@ "type": "object" }, "service_name": { - "default": "nemo-relay", + "default": "unknown_service", "description": "OTLP service name.", "type": "string" }, @@ -1083,10 +1120,58 @@ "$ref": "#/$defs/RelayOtlpTransport", "default": "http_binary", "description": "OTLP transport." + }, + "type": { + "$ref": "#/$defs/RelayOpenTelemetryEndpointType", + "description": "Span projection emitted to this destination." } }, + "required": [ + "type", + "endpoint" + ], "type": "object" }, + "RelayOpenTelemetryEndpointType": { + "description": "Relay OpenTelemetry span projection.", + "oneOf": [ + { + "const": "full", + "description": "Emit full Relay spans.", + "type": "string" + }, + { + "const": "gen_ai", + "description": "Emit GenAI semantic-convention spans.", + "type": "string" + }, + { + "const": "openinference", + "description": "Emit OpenInference semantic-convention spans.", + "type": "string" + } + ] + }, + "RelayOpenTelemetryMarkProjection": { + "description": "Relay OpenTelemetry mark projection policy.", + "oneOf": [ + { + "const": "inherit", + "description": "Inherit the endpoint type's projection behavior.", + "type": "string" + }, + { + "const": "event", + "description": "Project marks as events.", + "type": "string" + }, + { + "const": "tool", + "description": "Project marks as tool spans.", + "type": "string" + } + ] + }, "RelayOtlpTransport": { "description": "Relay OTLP transport.", "oneOf": [ diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index 574fde9de..1f31a6423 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -2206,27 +2206,21 @@ ], "description": "ATOF export configuration." }, - "openinference": { - "anyOf": [ - { - "$ref": "#/$defs/RelayOtlpConfig" - }, - { - "type": "null" - } - ], - "description": "OpenInference export configuration." + "enable_full_payloads": { + "default": false, + "description": "Retain complete sanitized request data on LLM start events.", + "type": "boolean" }, "opentelemetry": { "anyOf": [ { - "$ref": "#/$defs/RelayOtlpConfig" + "$ref": "#/$defs/RelayOpenTelemetryConfig" }, { "type": "null" } ], - "description": "OpenTelemetry export configuration." + "description": "OpenTelemetry and OpenInference export configuration." }, "policy": { "anyOf": [ @@ -2240,45 +2234,88 @@ "description": "Relay config validation policy." }, "version": { - "default": 2, + "const": 3, + "default": 3, "description": "Relay observability config version.", "format": "uint32", - "maximum": 4294967295, "minimum": 0, "type": "integer" } }, "type": "object" }, - "RelayOtlpConfig": { + "RelayOpenTelemetryConfig": { "additionalProperties": true, - "description": "Relay OpenTelemetry/OpenInference export configuration.", + "description": "Relay OpenTelemetry export section.", "properties": { "enabled": { "default": false, - "description": "Whether OTLP export is enabled.", + "description": "Whether OpenTelemetry export is enabled.", "type": "boolean" }, + "endpoints": { + "description": "Typed OTLP destinations.", + "items": { + "$ref": "#/$defs/RelayOpenTelemetryEndpointConfig" + }, + "type": "array" + } + }, + "type": "object" + }, + "RelayOpenTelemetryEndpointConfig": { + "additionalProperties": true, + "description": "One typed Relay OpenTelemetry OTLP destination.", + "properties": { + "attribute_mappings": { + "description": "Attribute mapping rules.", + "items": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": "array" + }, "endpoint": { "description": "OTLP endpoint.", - "type": [ - "string", - "null" - ] + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "header_env": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment-variable-backed OTLP headers.", + "type": "object" }, "headers": { "additionalProperties": { "type": "string" }, - "description": "OTLP headers.", + "description": "Static OTLP headers.", "type": "object" }, "instrumentation_scope": { + "default": "opentelemetry", "description": "OTLP instrumentation scope.", - "type": [ - "string", - "null" - ] + "type": "string" + }, + "mark_exclude_names": { + "default": [ + "llm.chunk" + ], + "description": "Event names excluded from mark projection.", + "items": { + "type": "string" + }, + "type": "array" + }, + "mark_projection": { + "$ref": "#/$defs/RelayOpenTelemetryMarkProjection", + "default": "inherit", + "description": "Mark projection policy." }, "resource_attributes": { "additionalProperties": { @@ -2288,7 +2325,7 @@ "type": "object" }, "service_name": { - "default": "nemo-relay", + "default": "unknown_service", "description": "OTLP service name.", "type": "string" }, @@ -2318,10 +2355,58 @@ "$ref": "#/$defs/RelayOtlpTransport", "default": "http_binary", "description": "OTLP transport." + }, + "type": { + "$ref": "#/$defs/RelayOpenTelemetryEndpointType", + "description": "Span projection emitted to this destination." } }, + "required": [ + "type", + "endpoint" + ], "type": "object" }, + "RelayOpenTelemetryEndpointType": { + "description": "Relay OpenTelemetry span projection.", + "oneOf": [ + { + "const": "full", + "description": "Emit full Relay spans.", + "type": "string" + }, + { + "const": "gen_ai", + "description": "Emit GenAI semantic-convention spans.", + "type": "string" + }, + { + "const": "openinference", + "description": "Emit OpenInference semantic-convention spans.", + "type": "string" + } + ] + }, + "RelayOpenTelemetryMarkProjection": { + "description": "Relay OpenTelemetry mark projection policy.", + "oneOf": [ + { + "const": "inherit", + "description": "Inherit the endpoint type's projection behavior.", + "type": "string" + }, + { + "const": "event", + "description": "Project marks as events.", + "type": "string" + }, + { + "const": "tool", + "description": "Project marks as tool spans.", + "type": "string" + } + ] + }, "RelayOtlpTransport": { "description": "Relay OTLP transport.", "oneOf": [ diff --git a/skills/nemo-fabric-integrate/references/config-mapping.md b/skills/nemo-fabric-integrate/references/config-mapping.md index cdcdf4dcc..4fe0bbe18 100644 --- a/skills/nemo-fabric-integrate/references/config-mapping.md +++ b/skills/nemo-fabric-integrate/references/config-mapping.md @@ -108,7 +108,7 @@ def with_relay(base: FabricConfig) -> FabricConfig: Use this function-and-copy pattern for every variant; keep all variation in ordinary Python. -For ATOF, author the NeMo Relay 0.6 sink model directly. Put +For ATOF, author the file and stream sink model directly. Put `RelayAtofFileSinkConfig` and `RelayAtofStreamSinkConfig` instances in `RelayAtofConfig.sinks`, and set `RelayAtofConfig.enabled=True`. diff --git a/tests/adapters/test_adapaters_common_utils.py b/tests/adapters/test_adapaters_common_utils.py index 75c7b5d70..db7d1c6d4 100644 --- a/tests/adapters/test_adapaters_common_utils.py +++ b/tests/adapters/test_adapaters_common_utils.py @@ -211,9 +211,7 @@ def test_selected_model_config( def test_normalized_instruction_runtime_and_tool_accessors(): payload = { "config": { - "instructions": { - "system": {"content": "Be concise.", "mode": "replace"} - }, + "instructions": {"system": {"content": "Be concise.", "mode": "replace"}}, "runtime": {"timeout_seconds": 12.5, "max_turns": 7}, "tools": { "enabled": [], @@ -311,6 +309,125 @@ def test_runtime_id_requires_runtime_context(): common_utils.runtime_id({"runtime_context": {}}) +def test_ambient_relay_plugin_config_paths_prefers_xdg_and_nearest_project( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + xdg = tmp_path / "xdg" + home = tmp_path / "home" + project = tmp_path / "workspace" + nested = project / "repos" / "service" + user_config = xdg / "nemo-relay" / "plugins.toml" + project_config = project / ".nemo-relay" / "plugins.toml" + ignored_home_config = home / ".config" / "nemo-relay" / "plugins.toml" + for path in (user_config, project_config, ignored_home_config): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("version = 1\n", encoding="utf-8") + nested.mkdir(parents=True) + os.environ["XDG_CONFIG_HOME"] = str(xdg) + os.environ["HOME"] = str(home) + monkeypatch.chdir(nested) + + assert common_utils.ambient_relay_plugin_config_paths() == [ + user_config, + project_config, + ] + + +def test_ambient_relay_plugin_config_paths_falls_back_to_home( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + home = tmp_path / "home" + user_config = home / ".config" / "nemo-relay" / "plugins.toml" + user_config.parent.mkdir(parents=True) + user_config.write_text("version = 1\n", encoding="utf-8") + os.environ.pop("XDG_CONFIG_HOME", None) + os.environ["HOME"] = str(home) + monkeypatch.chdir(tmp_path) + + assert common_utils.ambient_relay_plugin_config_paths() == [user_config] + + +@pytest.mark.parametrize("xdg_config_home", ["", "relative-config"]) +def test_ambient_relay_plugin_config_paths_ignores_invalid_xdg_home( + xdg_config_home: str, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + home = tmp_path / "home" + user_config = home / ".config" / "nemo-relay" / "plugins.toml" + user_config.parent.mkdir(parents=True) + user_config.write_text("version = 1\n", encoding="utf-8") + os.environ["XDG_CONFIG_HOME"] = xdg_config_home + os.environ["HOME"] = str(home) + monkeypatch.chdir(tmp_path) + + assert common_utils.ambient_relay_plugin_config_paths() == [user_config] + + +def test_reject_ambient_relay_plugin_config_allows_clean_environment( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + os.environ["XDG_CONFIG_HOME"] = str(tmp_path / "xdg") + monkeypatch.chdir(tmp_path) + + common_utils.reject_ambient_relay_plugin_config() + + +def test_reject_ambient_relay_plugin_config_reports_paths( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + project_config = tmp_path / ".nemo-relay" / "plugins.toml" + project_config.parent.mkdir() + project_config.write_text("version = 1\n", encoding="utf-8") + os.environ["XDG_CONFIG_HOME"] = str(tmp_path / "xdg") + monkeypatch.chdir(tmp_path) + + with pytest.raises(RuntimeError, match=re.escape(str(project_config))): + common_utils.reject_ambient_relay_plugin_config() + + +def test_reject_inherited_relay_plugin_config_allows_system_policy(): + common_utils.reject_inherited_relay_plugin_config( + { + "diagnostics": [ + { + "level": "warning", + "code": "plugin.configuration_inherited", + "message": ( + "inherited plugin configuration from discovered file: " + "/etc/nemo-relay/plugins.toml" + ), + } + ] + } + ) + + +@pytest.mark.parametrize( + "message", + [ + "inherited plugin configuration from discovered file: " + "/workspace/.nemo-relay/plugins.toml", + "inherited plugin configuration from discovered file: " + "/etc/nemo-relay/../nemo-relay/plugins.toml", + "inherited plugin configuration from an unknown source", + ], +) +def test_reject_inherited_relay_plugin_config_rejects_unmanaged_sources(message): + with pytest.raises(RuntimeError, match="user or project files"): + common_utils.reject_inherited_relay_plugin_config( + { + "diagnostics": [ + { + "level": "warning", + "code": "plugin.configuration_inherited", + "message": message, + } + ] + } + ) + + def test_dump_yaml_falls_back_to_json_when_yaml_is_unavailable( monkeypatch: pytest.MonkeyPatch, ): @@ -357,7 +474,7 @@ def test_without_none_preserves_falsey_values(): } -def test_load_relay_plugin_config_wraps_and_normalizes_bare_observability_config( +def test_load_relay_plugin_config_wraps_and_normalizes_bare_v3_observability_config( tmp_path: Path, ): config_path = tmp_path / "relay.json" @@ -366,6 +483,7 @@ def test_load_relay_plugin_config_wraps_and_normalizes_bare_observability_config { "relay": { "config": { + "version": 3, "atof": { "enabled": True, "sinks": [ @@ -408,7 +526,7 @@ def test_load_relay_plugin_config_wraps_and_normalizes_bare_observability_config assert plugin_config["version"] == 1 assert plugin_config["components"][0]["kind"] == "observability" - assert observability["version"] == 2 + assert observability["version"] == 3 file_sink, stream_sink = observability["atof"]["sinks"] assert file_sink["output_directory"] == str( tmp_path / "custom-relay" / "runtime-current" @@ -444,6 +562,44 @@ def test_load_relay_plugin_config_wraps_and_normalizes_bare_observability_config ] +def test_load_relay_plugin_config_keeps_empty_config_component_free(tmp_path: Path): + config_path = tmp_path / "relay.json" + config_path.write_text(json.dumps({"relay": {"config": {}}}), encoding="utf-8") + os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(config_path) + + plugin_config = common_utils.load_relay_plugin_config( + { + "base_dir": str(tmp_path), + "runtime_context": {"runtime_id": "runtime-current"}, + } + ) + + assert plugin_config == {"version": 1, "components": []} + assert not (tmp_path / "artifacts").exists() + + +def test_load_relay_plugin_config_accepts_implicit_v3_without_inserting_version( + tmp_path: Path, +): + config_path = tmp_path / "relay.json" + config_path.write_text( + json.dumps({"relay": {"config": {"atif": {"enabled": False}}}}), + encoding="utf-8", + ) + os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(config_path) + + plugin_config = common_utils.load_relay_plugin_config( + { + "base_dir": str(tmp_path), + "runtime_context": {"runtime_id": "runtime-current"}, + } + ) + + observability = plugin_config["components"][0]["config"] + assert observability == {"atif": {"enabled": False}} + assert "version" not in observability + + def test_collect_relay_artifacts(tmp_path: Path): atof_dir = tmp_path / "atof" atif_dir = tmp_path / "atif" @@ -651,12 +807,16 @@ def resolve(path: Path, *, strict: bool = False) -> Path: monkeypatch.setattr(Path, "resolve", resolve) - assert common_utils.collect_relay_artifacts( - _atof_artifact_config(tmp_path / "loop") - ) == [] - assert common_utils.collect_relay_artifacts( - _atof_artifact_config(artifact_dir, filename="loop") - ) == [] + assert ( + common_utils.collect_relay_artifacts(_atof_artifact_config(tmp_path / "loop")) + == [] + ) + assert ( + common_utils.collect_relay_artifacts( + _atof_artifact_config(artifact_dir, filename="loop") + ) + == [] + ) def test_collect_relay_artifacts_non_string_atof_filename_uses_glob(tmp_path: Path): @@ -697,7 +857,7 @@ def test_collect_relay_artifacts_ignores_malformed_paths(tmp_path: Path): assert common_utils.collect_relay_artifacts(plugin_config) == [] -def test_relay_validates_raw_v06_plugin_config(): +def test_relay_0_7_validates_v3_plugin_config(): from nemo_relay import plugin os.environ["TOKEN"] = "test-token" @@ -708,7 +868,7 @@ def test_relay_validates_raw_v06_plugin_config(): "kind": "observability", "enabled": True, "config": { - "version": 2, + "version": 3, "atof": { "enabled": True, "sinks": [ @@ -735,6 +895,115 @@ def test_relay_validates_raw_v06_plugin_config(): ], } + common_utils.validate_relay_observability_v3(plugin_config) + assert plugin.validate(plugin_config)["diagnostics"] == [] + + +async def test_relay_0_7_initializes_v3_atof_atif_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + """Regress the initialize-only failure that prompted the Relay 0.6 pin.""" + + from nemo_relay import plugin + + os.environ["XDG_CONFIG_HOME"] = str(tmp_path / "xdg") + monkeypatch.chdir(tmp_path) + artifact_directory = tmp_path / "artifacts" + artifact_directory.mkdir() + plugin_config = { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": { + "version": 3, + "atof": { + "enabled": True, + "sinks": [ + { + "type": "file", + "output_directory": str(artifact_directory), + "filename": "events.atof.jsonl", + "mode": "append", + } + ], + }, + "atif": { + "enabled": True, + "output_directory": str(artifact_directory), + "filename_template": "trajectory-{session_id}.atif.json", + "agent_name": "fabric-agent", + "agent_version": "test", + "model_name": "test-model", + }, + }, + } + ], + } + common_utils.validate_relay_observability_v3(plugin_config) + assert plugin.validate(plugin_config)["diagnostics"] == [] + async with plugin.plugin(plugin_config) as activation_report: + assert activation_report["diagnostics"] == [] + + +def test_relay_0_7_validates_all_v3_otlp_fields(): + from nemo_relay import plugin + + os.environ["OTEL_TOKEN"] = "test-token" + plugin_config = { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": { + "version": 3, + "atif": { + "enabled": True, + "output_directory": "/tmp/atif", + "filename_template": "trajectory-{session_id}.atif.json", + }, + "opentelemetry": { + "enabled": True, + "endpoints": [ + { + "type": "full", + "endpoint": "http://localhost:4318/v1/traces", + "mark_projection": "tool", + "mark_exclude_names": ["llm.chunk"], + "attribute_mappings": [ + { + "key": "gen_ai.request.model", + "alias": "llm.model", + } + ], + "transport": "http_binary", + "headers": {"x-tenant": "evaluation"}, + "header_env": {"authorization": "OTEL_TOKEN"}, + "resource_attributes": { + "deployment.environment": "test" + }, + "service_name": "fabric", + "service_namespace": "platform", + "service_version": "0.4.0", + "instrumentation_scope": "fabric.relay", + "timeout_millis": 1000, + }, + { + "type": "openinference", + "endpoint": "http://localhost:6006/v1/traces", + "transport": "grpc", + }, + ], + }, + }, + } + ], + } + + common_utils.validate_relay_observability_v3(plugin_config) assert plugin.validate(plugin_config)["diagnostics"] == [] @@ -748,7 +1017,7 @@ def test_relay_validates_unknown_atof_sink_type(): "kind": "observability", "enabled": True, "config": { - "version": 2, + "version": 3, "atof": { "enabled": True, "sinks": [{"type": "unknown"}], @@ -804,8 +1073,90 @@ def test_write_relay_configs( assert tomllib.load(stream) == config -def test_write_relay_configs_preserves_current_cli_contract(tmp_path: Path): - os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(tmp_path / "relay.json") +def test_validate_relay_observability_v3_accepts_v3_and_implicit_v3_without_mutation(): + plugin_config = { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": {"version": 3, "atif": {"enabled": False}}, + }, + { + "kind": "observability", + "enabled": False, + "config": {"atof": {"enabled": False}}, + }, + { + "kind": "model_pricing", + "enabled": True, + "config": {"version": 2, "currency": "USD"}, + }, + ], + } + original = json.loads(json.dumps(plugin_config)) + + common_utils.validate_relay_observability_v3(plugin_config) + + assert plugin_config == original + + +def test_validate_relay_observability_v3_matches_relay_implicit_version(): + from nemo_relay import plugin + + plugin_config = { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": {}, + } + ], + } + + common_utils.validate_relay_observability_v3(plugin_config) + + assert plugin.validate(plugin_config)["diagnostics"] == [] + + +@pytest.mark.parametrize( + ("version", "enabled"), + [ + (1, True), + (2, True), + (2, False), + (4, True), + (True, True), + (False, True), + ("3", True), + (3.0, True), + (None, True), + ], +) +def test_validate_relay_observability_v3_rejects_explicit_non_v3_versions( + version: object, + enabled: bool, +): + plugin_config = { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": enabled, + "config": {"version": version}, + } + ], + } + + with pytest.raises( + ValueError, + match=r"unsupported NeMo Relay observability config version .*expected version 3", + ): + common_utils.validate_relay_observability_v3(plugin_config) + + +def test_validate_relay_observability_v3_reports_version_before_v3_shape(): plugin_config = { "version": 1, "components": [ @@ -814,64 +1165,256 @@ def test_write_relay_configs_preserves_current_cli_contract(tmp_path: Path): "enabled": True, "config": { "version": 2, + "opentelemetry": { + "enabled": True, + "endpoint": "http://localhost:4318/v1/traces", + }, + }, + } + ], + } + + with pytest.raises( + ValueError, + match=r"unsupported NeMo Relay observability config version 2", + ): + common_utils.validate_relay_observability_v3(plugin_config) + + +@pytest.mark.parametrize( + ("opentelemetry", "valid"), + [ + ({"enabled": True}, False), + ({"enabled": True, "endpoints": []}, False), + ({"enabled": False, "endpoints": []}, True), + ], +) +def test_validate_relay_observability_v3_requires_endpoint_when_enabled( + opentelemetry: dict[str, object], + valid: bool, +): + plugin_config = { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": { + "version": 3, + "opentelemetry": opentelemetry, + }, + } + ], + } + + if valid: + common_utils.validate_relay_observability_v3(plugin_config) + else: + with pytest.raises(ValueError, match="requires at least one endpoint"): + common_utils.validate_relay_observability_v3(plugin_config) + + +@pytest.mark.parametrize("config", [None, [], "version = 3", 3]) +def test_validate_relay_observability_v3_requires_object_config(config: object): + plugin_config = { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": False, + "config": config, + } + ], + } + + with pytest.raises( + ValueError, + match="NeMo Relay observability component config must be an object", + ): + common_utils.validate_relay_observability_v3(plugin_config) + + +@pytest.mark.parametrize( + "config", + [ + { + "version": 3, + "openinference": { + "enabled": True, + "endpoint": "http://localhost:6006/v1/traces", + }, + }, + { + "version": 3, + "opentelemetry": { + "enabled": True, + "endpoint": "http://localhost:4318/v1/traces", + }, + }, + ], +) +def test_validate_relay_observability_v3_rejects_legacy_exporter_shapes(config): + plugin_config = { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": config, + } + ], + } + + with pytest.raises(ValueError, match="observability config version 3"): + common_utils.validate_relay_observability_v3(plugin_config) + + +def test_normalize_relay_output_dirs_validates_all_components_before_mutation( + tmp_path: Path, +): + plugin_config = { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": { + "version": 3, "atof": { "enabled": True, "sinks": [ { "type": "file", - "output_directory": "/tmp/atof", - "filename": "events.jsonl", - "mode": "overwrite", - }, - { - "type": "stream", - "url": "https://example.test/events", - "transport": "http_post", - "headers": {"x-test": "value"}, - "header_env": {"authorization": "TOKEN"}, - "timeout_millis": 1000, - "field_name_policy": "replace_dots", - }, + "output_directory": "first", + } ], }, - "atif": {"enabled": True, "output_directory": "/tmp/atif"}, }, - } + }, + { + "kind": "observability", + "enabled": False, + "config": { + "version": 2, + "atif": {"enabled": True}, + }, + }, ], } + original = json.loads(json.dumps(plugin_config)) - _, plugin_path = common_utils.write_relay_configs( - plugin_config=plugin_config, - observability_version=2, + with pytest.raises(ValueError, match="config version 2"): + common_utils.normalize_relay_output_dirs( + plugin_config, + { + "base_dir": str(tmp_path), + "runtime_context": {"runtime_id": "runtime-current"}, + }, + ) + + assert plugin_config == original + assert not (tmp_path / "first").exists() + assert not (tmp_path / "artifacts").exists() + + +def test_load_relay_plugin_config_rejects_v2_before_creating_directories( + tmp_path: Path, +): + config_path = tmp_path / "relay.json" + config_path.write_text( + json.dumps( + { + "relay": { + "config": { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": { + "version": 2, + "atif": {"enabled": True}, + }, + } + ], + } + } + } + ), + encoding="utf-8", ) + os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(config_path) - assert plugin_path is not None - with plugin_path.open("rb") as stream: - rendered = tomllib.load(stream) - observability = rendered["components"][0]["config"] - assert observability["version"] == 2 - assert observability["atof"] == { - "enabled": True, - "sinks": [ + with pytest.raises(ValueError, match="config version 2"): + common_utils.load_relay_plugin_config( + { + "base_dir": str(tmp_path), + "runtime_context": {"runtime_id": "runtime-current"}, + } + ) + + assert not (tmp_path / "artifacts").exists() + + +def test_write_relay_configs_preserves_v3_plugin_config_exactly(tmp_path: Path): + os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(tmp_path / "relay.json") + plugin_config = { + "version": 1, + "policy": {"unknown_component": "warn"}, + "components": [ { - "type": "file", - "output_directory": "/tmp/atof", - "filename": "events.jsonl", - "mode": "overwrite", + "kind": "observability", + "enabled": True, + "config": { + "version": 3, + "atif": {"enabled": False}, + "opentelemetry": { + "enabled": True, + "endpoints": [ + { + "type": "openinference", + "endpoint": "http://localhost:6006/v1/traces", + } + ], + }, + }, }, { - "type": "stream", - "url": "https://example.test/events", - "transport": "http_post", - "headers": {"x-test": "value"}, - "header_env": {"authorization": "TOKEN"}, - "timeout_millis": 1000, - "field_name_policy": "replace_dots", + "kind": "model_pricing", + "enabled": True, + "config": {"version": 2, "currency": "USD"}, }, ], } - assert observability["atif"] == { - "enabled": True, - "output_directory": "/tmp/atif", + original = json.loads(json.dumps(plugin_config)) + + _, plugin_path = common_utils.write_relay_configs(plugin_config=plugin_config) + + assert plugin_path is not None + with plugin_path.open("rb") as stream: + assert tomllib.load(stream) == original + assert plugin_config == original + + +def test_write_relay_configs_rejects_v2_before_creating_config_directory( + tmp_path: Path, +): + os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(tmp_path / "nested" / "relay.json") + plugin_config = { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": {"version": 2}, + } + ], } - assert rendered == plugin_config + + with pytest.raises(ValueError, match="config version 2"): + common_utils.write_relay_configs( + relay_config={"agents": {}}, + plugin_config=plugin_config, + ) + + assert not (tmp_path / "nested" / "relay-config").exists() diff --git a/tests/adapters/test_adapter_package_metadata.py b/tests/adapters/test_adapter_package_metadata.py index 8b91d9a7e..cf503b460 100644 --- a/tests/adapters/test_adapter_package_metadata.py +++ b/tests/adapters/test_adapter_package_metadata.py @@ -41,8 +41,8 @@ def load_pyproject(path: str) -> dict: "langchain>=1.3,<2.0", "langgraph>=1.2,<2.0", ], - "relay": ["nemo-relay>=0.6.0,<0.7"], - "full_relay": ["nemo-relay[deepagents]>=0.6.0,<0.7"], + "relay": ["nemo-relay>=0.7.2,<0.8"], + "full_relay": ["nemo-relay[deepagents]>=0.7.2,<0.8"], }, "hermes-agent": { "path": "adapters/hermes", @@ -51,7 +51,7 @@ def load_pyproject(path: str) -> dict: "python_version < '3.14'" ), "harness": ["hermes-agent[mcp]>=0.19.0; python_version < '3.14'"], - "relay": ["nemo-relay>=0.6.0,<0.7"], + "relay": ["nemo-relay>=0.7.2,<0.8"], }, } @@ -129,7 +129,7 @@ def test_root_package_installs_runtime_unconditionally(): def test_root_relay_extra_installs_only_relay(): extras = load_pyproject("")["project"]["optional-dependencies"] - assert extras["relay"] == ["nemo-relay>=0.6.0,<0.7"] + assert extras["relay"] == ["nemo-relay>=0.7.2,<0.8"] @pytest.mark.parametrize("name", ADAPTER_EXTRAS) diff --git a/tests/adapters/test_adapters_common_relay_gateway.py b/tests/adapters/test_adapters_common_relay_gateway.py index 757e1da56..a0827b7ca 100644 --- a/tests/adapters/test_adapters_common_relay_gateway.py +++ b/tests/adapters/test_adapters_common_relay_gateway.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import os import re import subprocess from unittest.mock import MagicMock, call @@ -48,8 +49,8 @@ def test_resolve_relay_command_rejects_missing_executable(monkeypatch, tmp_path) @pytest.mark.parametrize( ("output", "expected_version"), [ - ("nemo-relay 0.6.0-alpha.20260714\n", (0, 6, 0)), - ("nemo-relay 0.6.99\n", (0, 6, 99)), + ("nemo-relay 0.7.2\n", (0, 7, 2)), + ("nemo-relay 0.7.99\n", (0, 7, 99)), ], ) def test_relay_cli_contract_selects_compatible_contract( @@ -63,13 +64,18 @@ def test_relay_cli_contract_selects_compatible_contract( assert relay_gateway.relay_cli_contract( tmp_path / "nemo-relay" - ) == relay_gateway.RelayCliContract( - version=expected_version, - observability_version=2, - ) + ) == relay_gateway.RelayCliContract(version=expected_version) -@pytest.mark.parametrize("output", ["nemo-relay 0.5.9", "nemo-relay 0.7.0"]) +@pytest.mark.parametrize( + "output", + [ + "nemo-relay 0.7.1", + "nemo-relay 0.7.2-alpha.20260811", + "nemo-relay 0.8.0", + "nemo-relay 1.0.0", + ], +) def test_relay_cli_contract_rejects_unsupported_version(monkeypatch, tmp_path, output): monkeypatch.setattr( relay_gateway.subprocess, @@ -79,7 +85,7 @@ def test_relay_cli_contract_rejects_unsupported_version(monkeypatch, tmp_path, o with pytest.raises( relay_gateway.RelayGatewayError, - match=r"NeMo Fabric requires >=0\.6\.0,<0\.7\.0", + match=r"NeMo Fabric requires >=0\.7\.2,<0\.8\.0", ): relay_gateway.relay_cli_contract(tmp_path / "nemo-relay") @@ -98,6 +104,7 @@ def test_relay_cli_contract_rejects_unparseable_output(monkeypatch, tmp_path): def test_start_relay_gateway_captures_logs_and_waits_for_health(monkeypatch, tmp_path): + os.environ["NEMO_RELAY_CONFIG_SCOPE"] = "project" executable = tmp_path / "nemo-relay" config_path = tmp_path / "relay-config" / "config.toml" config_path.parent.mkdir() @@ -136,6 +143,8 @@ def test_start_relay_gateway_captures_logs_and_waits_for_health(monkeypatch, tmp "https://anthropic.example", ] assert mock_popen.call_args.kwargs["cwd"] == tmp_path + assert mock_popen.call_args.kwargs["env"]["NEMO_RELAY_CONFIG_SCOPE"] == "user" + assert os.environ["NEMO_RELAY_CONFIG_SCOPE"] == "project" assert mock_popen.call_args.kwargs["stderr"] is subprocess.STDOUT assert mock_popen.call_args.kwargs["start_new_session"] is True assert mock_popen.call_args.kwargs["stdout"].name == str(log_path) diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index 157ea02bc..0dbbdfd09 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -394,6 +394,7 @@ def relay_payload_fixture(claude_payload, tmp_path) -> dict[str, Any]: { "relay": { "config": { + "version": 3, "atof": {"enabled": True}, "atif": {"enabled": True}, } @@ -436,9 +437,7 @@ def test_prepare_claude_relay_writes_gateway_config_and_complete_hook_plugin( adapter.relay_gateway, "relay_cli_contract", MagicMock( - return_value=adapter.relay_gateway.RelayCliContract( - version=(0, 6, 0), observability_version=2 - ) + return_value=adapter.relay_gateway.RelayCliContract(version=(0, 7, 2)) ), ) @@ -455,6 +454,8 @@ def test_prepare_claude_relay_writes_gateway_config_and_complete_hook_plugin( with (relay.gateway.config_path.parent / "plugins.toml").open("rb") as stream: plugin_config = tomllib.load(stream) assert plugin_config["components"][0]["kind"] == "observability" + assert plugin_config["components"][0]["config"]["version"] == 3 + assert relay.plugin_config["components"][0]["config"]["version"] == 3 manifest = json.loads( (relay.plugin_path / ".claude-plugin" / "plugin.json").read_text( @@ -497,6 +498,39 @@ def test_prepare_claude_relay_writes_gateway_config_and_complete_hook_plugin( assert hooks["PermissionRequest"][0]["matcher"] == "*" +def test_prepare_claude_relay_rejects_v2_observability_config( + relay_payload, monkeypatch, tmp_path +): + relay_intent_path = Path(os.environ["FABRIC_RELAY_CONFIG_PATH"]) + relay_intent_path.write_text( + json.dumps({"relay": {"config": {"version": 2}}}), + encoding="utf-8", + ) + executable = tmp_path / "nemo-relay" + executable.touch() + monkeypatch.setattr( + adapter.relay_gateway, + "resolve_relay_command", + MagicMock(return_value=executable), + ) + monkeypatch.setattr( + adapter.relay_gateway, + "relay_cli_contract", + MagicMock( + return_value=adapter.relay_gateway.RelayCliContract(version=(0, 7, 2)) + ), + ) + + with pytest.raises(adapter.AdapterRelayError) as caught: + adapter.prepare_claude_relay(relay_payload) + + assert caught.value.code == "claude_relay_configuration_failed" + assert isinstance(caught.value.__cause__, ValueError) + assert str(caught.value.__cause__) == ( + "unsupported NeMo Relay observability config version 2; expected version 3" + ) + + def test_build_options_adds_relay_plugin_and_gateway_environment( relay_payload, monkeypatch, tmp_path ): @@ -516,9 +550,7 @@ def test_build_options_adds_relay_plugin_and_gateway_environment( adapter.relay_gateway, "relay_cli_contract", MagicMock( - return_value=adapter.relay_gateway.RelayCliContract( - version=(0, 6, 0), observability_version=2 - ) + return_value=adapter.relay_gateway.RelayCliContract(version=(0, 7, 2)) ), ) relay = adapter.prepare_claude_relay(relay_payload) @@ -915,6 +947,7 @@ def atif_plugin_config(output_directory: Path) -> dict[str, Any]: "kind": "observability", "enabled": True, "config": { + "version": 3, "atif": { "enabled": True, "output_directory": str(output_directory), diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index e575bdf9f..6321e29b8 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -171,6 +171,7 @@ def atif_plugin_config(output_directory: Path) -> dict[str, Any]: { "kind": "observability", "config": { + "version": 3, "atif": { "enabled": True, "output_directory": str(output_directory), @@ -1369,12 +1370,20 @@ def test_prepare_relay_reuses_one_resolved_executable( plugin_path = config_path.parent / "plugins.toml" resolve = MagicMock(return_value=executable) contract = MagicMock( - return_value=adapter.relay_gateway.RelayCliContract( - version=(0, 6, 0), observability_version=2 - ) + return_value=adapter.relay_gateway.RelayCliContract(version=(0, 7, 2)) ) write = MagicMock(return_value=(config_path, plugin_path)) - load = MagicMock(return_value={"version": 1, "components": []}) + load = MagicMock( + return_value={ + "version": 1, + "components": [ + { + "kind": "observability", + "config": {"version": 3, "atif": {"enabled": True}}, + } + ], + } + ) monkeypatch.setattr(adapter.relay_gateway, "resolve_relay_command", resolve) monkeypatch.setattr(adapter.relay_gateway, "relay_cli_contract", contract) monkeypatch.setattr(adapter.relay_gateway, "find_available_tcp_port", lambda: 43210) @@ -1390,6 +1399,7 @@ def test_prepare_relay_reuses_one_resolved_executable( assert relay.gateway.executable == executable assert relay.gateway.url == "http://127.0.0.1:43210" assert relay.gateway.openai_base_url == "https://acme.example/v1" + assert relay.plugin_config["components"][0]["config"]["version"] == 3 resolve.assert_called_once_with( Path(codex_payload["base_dir"]).resolve(), "nemo-relay", @@ -1405,8 +1415,56 @@ def test_prepare_relay_reuses_one_resolved_executable( ) write.assert_called_once_with( relay_config={}, - plugin_config={"version": 1, "components": []}, - observability_version=2, + plugin_config={ + "version": 1, + "components": [ + { + "kind": "observability", + "config": {"version": 3, "atif": {"enabled": True}}, + } + ], + }, + ) + + +def test_prepare_relay_rejects_v2_observability_config( + codex_payload, monkeypatch, tmp_path +): + codex_payload["runtime_context"]["telemetry"] = { + "relay_enabled": True, + "metadata": {"telemetry_providers": ["relay"]}, + } + relay_intent_path = tmp_path / "relay.json" + relay_intent_path.write_text( + json.dumps({"relay": {"config": {"version": 2}}}), + encoding="utf-8", + ) + os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(relay_intent_path) + executable = tmp_path / "nemo-relay" + executable.touch() + monkeypatch.setattr( + adapter.relay_gateway, + "resolve_relay_command", + MagicMock(return_value=executable), + ) + monkeypatch.setattr( + adapter.relay_gateway, + "relay_cli_contract", + MagicMock( + return_value=adapter.relay_gateway.RelayCliContract(version=(0, 7, 2)) + ), + ) + + config, context, base_dir = runtime_input(codex_payload) + with pytest.raises(adapter.AdapterRelayError) as caught: + adapter.prepare_codex_relay( + codex_payload["agent_name"], config, context, base_dir + ) + + assert caught.value.code == "codex_relay_configuration_failed" + assert isinstance(caught.value.__cause__, ValueError) + assert str(caught.value.__cause__) == ( + "unsupported NeMo Relay observability config version 2; expected version 3" ) diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index 069548a7f..4e01c1a20 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -214,6 +214,13 @@ def fake_relay_fixture(monkeypatch): import contextlib calls: dict[str, Any] = {} + ambient_guard = MagicMock() + calls["ambient_guard"] = ambient_guard + monkeypatch.setattr( + adapter.common_utils, + "reject_ambient_relay_plugin_config", + ambient_guard, + ) def add_nemo_relay_integration(kwargs, **_): merged = dict(kwargs) @@ -223,12 +230,12 @@ def add_nemo_relay_integration(kwargs, **_): return merged @contextlib.asynccontextmanager - async def plugin_ctx(config: object) -> AsyncIterator[None]: + async def plugin_ctx(config: object) -> AsyncIterator[dict[str, object]]: calls["plugin_open"] = True calls["plugin_enters"] = calls.get("plugin_enters", 0) + 1 calls.setdefault("plugin_configs", []).append(config) try: - yield + yield {"diagnostics": [], "runtime_diagnostics": []} finally: calls["plugin_exits"] = calls.get("plugin_exits", 0) + 1 @@ -395,6 +402,77 @@ def boom(**_kwargs): ) +def test_resolve_relay_observability_passes_through_relay_v3( + tmp_path, make_payload, monkeypatch +): + source = { + "version": 1, + "components": [ + { + "kind": "observability", + "config": { + "version": 3, + "opentelemetry": { + "enabled": True, + "endpoints": [ + { + "type": "openinference", + "endpoint": "http://localhost:6006/v1/traces", + } + ], + }, + }, + } + ], + } + monkeypatch.setattr( + adapter.common_utils, "load_relay_plugin_config", lambda _payload: source + ) + runtime_context = RuntimeContext.from_mapping( + make_payload(tmp_path)["runtime_context"] + ) + + observability = adapter.resolve_observability( + runtime_context, + str(tmp_path), + "deepagents-test", + "test-model", + "relay", + True, + ) + + assert observability is not None + assert observability.plugin_config is source + config = observability.plugin_config["components"][0]["config"] + assert config["version"] == 3 + assert config["opentelemetry"]["endpoints"][0]["type"] == "openinference" + + +def test_resolve_relay_observability_rejects_v2(tmp_path, make_payload): + relay_config_path = tmp_path / "relay.json" + relay_config_path.write_text( + '{"relay":{"config":{"version":2}}}', + encoding="utf-8", + ) + os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(relay_config_path) + runtime_context = RuntimeContext.from_mapping( + make_payload(tmp_path)["runtime_context"] + ) + + with pytest.raises( + ValueError, + match="unsupported NeMo Relay observability config version 2", + ): + adapter.resolve_observability( + runtime_context, + str(tmp_path), + "deepagents-test", + "test-model", + "relay", + True, + ) + + async def test_relay_telemetry_wraps_agent_and_reports_artifacts( tmp_path, make_payload, monkeypatch, fake_sdks, fake_relay ): @@ -418,6 +496,7 @@ async def test_relay_telemetry_wraps_agent_and_reports_artifacts( assert fake_relay["wrapped"] assert fake_relay["plugin_open"] + assert fake_relay["ambient_guard"].call_count == 2 assert fake_relay["plugin_configs"] == [plugin_config] assert output["telemetry"] == { "enabled": True, @@ -438,6 +517,55 @@ async def test_relay_telemetry_wraps_agent_and_reports_artifacts( ) +async def test_ambient_relay_config_fails_runtime_start_before_agent_creation( + tmp_path, make_payload, monkeypatch, fake_sdks, fake_relay +): + payload = make_payload(tmp_path) + payload["runtime_context"]["telemetry"] = { + "relay_enabled": True, + "metadata": {"telemetry_providers": ["relay"]}, + } + monkeypatch.setattr( + adapter.common_utils, + "load_relay_plugin_config", + lambda _payload: {"version": 1, "components": []}, + ) + fake_relay["ambient_guard"].side_effect = RuntimeError("ambient Relay config") + + with pytest.raises(RuntimeError, match="ambient Relay config"): + await adapter.DeepAgentsRuntime().start(lifecycle_start_payload(payload)) + + assert "create_kwargs" not in fake_sdks + + +async def test_inherited_relay_config_report_fails_before_agent_invocation( + tmp_path, relay_payload, monkeypatch, fake_sdks, fake_relay +): + import contextlib + + @contextlib.asynccontextmanager + async def inherited_plugin(_config: object): + yield { + "diagnostics": [ + { + "level": "warning", + "code": "plugin.configuration_inherited", + "message": "inherited plugin configuration from discovered file", + } + ], + "runtime_diagnostics": [], + } + + monkeypatch.setattr(sys.modules["nemo_relay.plugin"], "plugin", inherited_plugin) + + output = await invoke_once(relay_payload(tmp_path)) + + assert output["completed"] is False + assert output["failed"] is True + assert "refuses Relay plugin configuration inherited" in output["error"] + assert fake_relay.get("scopes", []) == [] + + @pytest.fixture(name="relay_payload") def relay_payload_fixture(make_payload, monkeypatch): """Build a payload with Relay telemetry enabled and its plugin config stubbed.""" @@ -507,7 +635,7 @@ async def test_relay_plugin_teardown_failure_keeps_the_invocation_completed( @contextlib.asynccontextmanager async def exploding_plugin(config: object): - yield + yield {"diagnostics": [], "runtime_diagnostics": []} raise RuntimeError("relay plugin flush failed") monkeypatch.setattr(sys.modules["nemo_relay.plugin"], "plugin", exploding_plugin) @@ -706,7 +834,7 @@ async def test_a_fault_that_unwinds_cleanly_does_not_quarantine_later_turns( @contextlib.asynccontextmanager async def flaky_plugin(config: object): - yield + yield {"diagnostics": [], "runtime_diagnostics": []} flushes["count"] += 1 if flushes["count"] == 1: raise RuntimeError("relay plugin flush failed") @@ -737,7 +865,7 @@ def exploding_scope(name: str, scope_type: object, **kwargs: object): @contextlib.asynccontextmanager async def exploding_plugin(config: object): - yield + yield {"diagnostics": [], "runtime_diagnostics": []} raise RuntimeError("relay plugin flush failed") monkeypatch.setattr(sys.modules["nemo_relay.scope"], "scope", exploding_scope) @@ -925,10 +1053,15 @@ async def test_native_telemetry_exports_without_artifacts( "kind": "observability", "enabled": True, "config": { - "version": 1, + "version": 3, "opentelemetry": { "enabled": True, - "endpoint": "http://localhost:4318/v1/traces", + "endpoints": [ + { + "type": "full", + "endpoint": "http://localhost:4318/v1/traces", + } + ], }, }, } @@ -1028,7 +1161,7 @@ def fake_find_spec( { "kind": "observability", "enabled": True, - "config": {"version": 1}, + "config": {"version": 3}, } ], }, @@ -1055,7 +1188,7 @@ async def test_incomplete_nemo_relay_install_fails_runtime_start( { "kind": "observability", "enabled": True, - "config": {"version": 1}, + "config": {"version": 3}, } ], }, diff --git a/tests/adapters/test_hermes_adapter.py b/tests/adapters/test_hermes_adapter.py index 1e5eeea01..0c822bcfd 100644 --- a/tests/adapters/test_hermes_adapter.py +++ b/tests/adapters/test_hermes_adapter.py @@ -119,23 +119,35 @@ def test_descriptor_uses_the_typed_agent_config_contract(): ] -def test_write_hermes_relay_plugin_config_uses_upstream_toml( +def test_write_hermes_relay_plugin_config_passes_through_v3( monkeypatch, tmp_path: Path, ): - monkeypatch.setattr(telemetry, "distribution_version", lambda _name: "0.6.0") + ambient_guard = MagicMock() + monkeypatch.setattr( + telemetry.common_utils, + "reject_ambient_relay_plugin_config", + ambient_guard, + ) relay_config_path = tmp_path / "relay.json" relay_config_path.write_text( json.dumps( { "relay": { "config": { + "version": 3, "atof": {"enabled": True, "sinks": [{"type": "file"}]}, "atif": {"enabled": True}, "opentelemetry": { "enabled": True, - "endpoint": "https://otel.example/v1/traces", - "service_name": "fabric", + "endpoints": [ + { + "type": "full", + "endpoint": "https://otel.example/v1/traces", + "service_name": "fabric", + "instrumentation_scope": "nemo-relay-otel", + } + ], }, } } @@ -161,43 +173,65 @@ def test_write_hermes_relay_plugin_config_uses_upstream_toml( with plugin_config_path.open("rb") as stream: staged_plugin_config = tomllib.load(stream) staged_observability = staged_plugin_config["components"][0]["config"] - assert staged_observability["version"] == 2 + assert staged_observability["version"] == 3 assert staged_observability["atif"]["enabled"] is True assert staged_observability["atof"]["sinks"][0]["mode"] == "append" assert staged_observability["opentelemetry"] == { "enabled": True, - "endpoint": "https://otel.example/v1/traces", - "service_name": "fabric", + "endpoints": [ + { + "type": "full", + "endpoint": "https://otel.example/v1/traces", + "service_name": "fabric", + "instrumentation_scope": "nemo-relay-otel", + } + ], } assert plugin_config["components"][0]["config"]["atof"]["sinks"][0][ "output_directory" ] == str(tmp_path / "artifacts" / "relay" / "runtime-hermes-relay") assert ( - plugin_config["components"][0]["config"]["atof"]["sinks"][0]["mode"] - == "overwrite" + plugin_config["components"][0]["config"]["atof"]["sinks"][0]["mode"] == "append" ) + ambient_guard.assert_called_once_with() -def test_write_hermes_relay_plugin_config_migrates_otlp_exporters_to_relay_v3( +def test_write_hermes_relay_plugin_config_preserves_typed_v3_otlp_endpoints( monkeypatch, tmp_path: Path, ): - monkeypatch.setattr(telemetry, "distribution_version", lambda _name: "0.7.2") + monkeypatch.setattr( + telemetry.common_utils, + "reject_ambient_relay_plugin_config", + MagicMock(), + ) relay_config_path = tmp_path / "relay.json" relay_config_path.write_text( json.dumps( { "relay": { "config": { + "version": 3, "opentelemetry": { "enabled": True, - "endpoint": "https://otel.example/v1/traces", - "service_name": "fabric", - }, - "openinference": { - "enabled": True, - "endpoint": "https://openinference.example/v1/traces", - "service_name": "fabric", + "endpoints": [ + { + "type": "full", + "endpoint": "https://otel.example/v1/traces", + "service_name": "fabric", + "instrumentation_scope": "nemo-relay-otel", + }, + { + "type": "openinference", + "endpoint": ( + "https://openinference.example/v1/traces" + ), + "service_name": "fabric", + "instrumentation_scope": ( + "nemo-relay-openinference" + ), + }, + ], }, } } @@ -227,16 +261,49 @@ def test_write_hermes_relay_plugin_config_migrates_otlp_exporters_to_relay_v3( "type": "full", "endpoint": "https://otel.example/v1/traces", "service_name": "fabric", + "instrumentation_scope": "nemo-relay-otel", }, { "type": "openinference", "endpoint": "https://openinference.example/v1/traces", "service_name": "fabric", + "instrumentation_scope": "nemo-relay-openinference", }, ], } +def test_write_hermes_relay_plugin_config_rejects_v2( + monkeypatch, + tmp_path: Path, +): + monkeypatch.setattr( + telemetry.common_utils, + "reject_ambient_relay_plugin_config", + MagicMock(), + ) + relay_config_path = tmp_path / "relay.json" + relay_config_path.write_text( + '{"relay":{"config":{"version":2}}}', + encoding="utf-8", + ) + os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(relay_config_path) + payload = { + "agent_name": "hermes-test-agent", + "base_dir": str(tmp_path), + "config": { + "models": {"default": {"provider": "nvidia", "model": "nvidia/test-model"}} + }, + "runtime_context": {"runtime_id": "runtime-hermes-relay"}, + } + + with pytest.raises( + ValueError, + match="unsupported NeMo Relay observability config version 2", + ): + telemetry.write_hermes_relay_plugin_config(payload) + + def test_finalize_hermes_relay_session_uses_legacy_plugin_hook(monkeypatch): hermes_cli = ModuleType("hermes_cli") hermes_plugins = ModuleType("hermes_cli.plugins") @@ -300,6 +367,42 @@ def stop_after_staging(*_args, **kwargs): assert all(name not in os.environ for name in telemetry.HERMES_RELAY_ENV_NAMES) +async def test_runtime_rechecks_ambient_relay_config_before_each_turn( + monkeypatch, +): + mock_turn = MagicMock() + mock_finalize = MagicMock() + monkeypatch.setattr(adapter, "_invoke_hermes_turn", mock_turn) + monkeypatch.setattr(telemetry, "finalize_hermes_relay_session", mock_finalize) + monkeypatch.setattr( + common_utils, + "reject_ambient_relay_plugin_config", + MagicMock(side_effect=RuntimeError("ambient Relay config")), + ) + runtime = adapter.HermesRuntime() + runtime._started = True + runtime._runtime_id = "runtime-relay-guard" + runtime._agent_config = _agent_config( + {"models": {"default": {"provider": "test", "model": "test-model"}}} + ) + runtime._model_config = runtime._agent_config.models["default"] + runtime._agent = MagicMock() + runtime._relay_plugin_config = {"version": 1, "components": []} + + with pytest.raises(RuntimeError, match="ambient Relay config"): + await runtime.invoke( + { + "runtime_context": _runtime_context( + runtime_id=runtime._runtime_id + ).to_mapping(), + "request": {"input": "hello"}, + } + ) + + mock_turn.assert_not_called() + mock_finalize.assert_not_called() + + def test_build_hermes_config_maps_fabric_config_to_hermes_config(): os.environ["MCP_URL"] = "http://localhost:9000/mcp" agent_config = _agent_config( @@ -426,6 +529,7 @@ def test_hermes_config_variation_matrix_surfaces_supported_capabilities( { "relay": { "config": { + "version": 3, "atof": { "enabled": True, "sinks": [ diff --git a/tests/adapters/test_hermes_streaming.py b/tests/adapters/test_hermes_streaming.py index e15a91251..93fc098ca 100644 --- a/tests/adapters/test_hermes_streaming.py +++ b/tests/adapters/test_hermes_streaming.py @@ -3,6 +3,7 @@ """Dependency-free tests for Hermes Relay streaming integration.""" +import os import sys from pathlib import Path from types import SimpleNamespace @@ -25,6 +26,8 @@ async def test_relay_invocation_passes_fabric_request_id_to_hermes( monkeypatch, tmp_path: Path, ): + os.environ["XDG_CONFIG_HOME"] = str(tmp_path / "xdg-config") + monkeypatch.chdir(tmp_path) events: list[str] = [] task_ids: list[object] = [] runtime = adapter.HermesRuntime() diff --git a/tests/conftest.py b/tests/conftest.py index 67bf22e4c..c5966968c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -116,7 +116,10 @@ def api_server_fixture(unused_tcp_port: int) -> Iterator[str]: @pytest.fixture(name="nemo_relay") -def nemo_relay_fixture() -> types.ModuleType: +def nemo_relay_fixture( + tmp_path: Path, +) -> types.ModuleType: + os.environ["XDG_CONFIG_HOME"] = str(tmp_path / "xdg-config") return pytest.importorskip("nemo_relay", reason="nemo-relay extra is required") diff --git a/tests/e2e/test_claude.py b/tests/e2e/test_claude.py index 817dc55d8..5f2ed5432 100644 --- a/tests/e2e/test_claude.py +++ b/tests/e2e/test_claude.py @@ -53,7 +53,7 @@ def write_mock_relay_gateway(path: Path, log_path: Path) -> None: args = sys.argv[1:] if args == ["--version"]: - print("nemo-relay 0.6.0") + print("nemo-relay 0.7.2") raise SystemExit(0) Path({str(log_path)!r}).write_text(json.dumps(args), encoding="utf-8") bind = args[args.index("--bind") + 1] @@ -398,10 +398,15 @@ async def test_fabric_claude_relay_supervises_gateway_and_injects_plugin(tmp_pat reason="set FABRIC_NEMO_RELAY_COMMAND to test an installed NeMo Relay CLI", ) async def test_fabric_claude_accepts_real_relay_gateway_with_mock_claude(tmp_path): + project_plugin_config = tmp_path / ".nemo-relay" / "plugins.toml" + project_plugin_config.parent.mkdir() + project_plugin_config.write_text("version = [\n", encoding="utf-8") + os.environ["NEMO_RELAY_CONFIG_SCOPE"] = "project" config = fabric_config( tmp_path, cli_path=MOCK_CLAUDE_CLI, relay=True, + atif=False, nemo_relay_command=os.environ["FABRIC_NEMO_RELAY_COMMAND"], ) @@ -411,6 +416,7 @@ async def test_fabric_claude_accepts_real_relay_gateway_with_mock_claude(tmp_pat assert result.output["relay_runtime"]["enabled"] is True gateway_log_path = Path(result.output["relay_runtime"]["gateway_log_path"]) assert gateway_log_path.is_file() + assert os.environ["NEMO_RELAY_CONFIG_SCOPE"] == "project" @pytest.mark.skipif( diff --git a/tests/examples/langgraph_custom_agent/test_telemetry.py b/tests/examples/langgraph_custom_agent/test_telemetry.py index 9ce9a1dfb..1a35abe56 100644 --- a/tests/examples/langgraph_custom_agent/test_telemetry.py +++ b/tests/examples/langgraph_custom_agent/test_telemetry.py @@ -8,11 +8,14 @@ import asyncio import builtins import json +import os from pathlib import Path +import pytest from langchain_core.language_models.fake_chat_models import FakeListChatModel from nemo_fabric_adapter_contract.models import RuntimeContext +from examples.langgraph_custom_agent.adapter.telemetry import _load_plugin_config from examples.langgraph_custom_agent.adapter.telemetry import observe_invocation from examples.langgraph_custom_agent.agent.graph import build_email_phishing_graph @@ -72,7 +75,32 @@ async def run() -> None: assert imported == [] -def test_relay_observes_graph_and_model_backed_node(tmp_path): +def test_partial_relay_config_defaults_to_observability_version_3(tmp_path): + config_path = tmp_path / "relay-config.json" + config_path.write_text('{"relay":{"config":{}}}', encoding="utf-8") + + plugin_config = _load_plugin_config( + _context(config_path), + base_dir=tmp_path, + agent_name="email-phishing", + model_name="test-model", + ) + + assert plugin_config == { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": {"version": 3}, + } + ], + } + + +def test_relay_observes_graph_and_model_backed_node(tmp_path, monkeypatch): + os.environ["XDG_CONFIG_HOME"] = str(tmp_path / "xdg-config") + monkeypatch.chdir(tmp_path) config_path = tmp_path / "relay-config.json" config_path.write_text( json.dumps( @@ -85,7 +113,7 @@ def test_relay_observes_graph_and_model_backed_node(tmp_path): "kind": "observability", "enabled": True, "config": { - "version": 2, + "version": 3, "atof": { "enabled": True, "sinks": [ @@ -131,17 +159,16 @@ async def run(invocation_id: str) -> list[dict[str, str]]: model_name="test-model", ) as telemetry: result = await graph.ainvoke( - { - "email": ( - "Urgent: verify your password at https://example.invalid." - ) - }, + {"email": ("Urgent: verify your password at https://example.invalid.")}, config=telemetry.runnable_config, ) assert result["classification"] == "phishing" - assert telemetry.plugin_config["components"][0]["config"]["atif"][ - "model_name" - ] == "test-model" + assert ( + telemetry.plugin_config["components"][0]["config"]["atif"]["model_name"] + == "test-model" + ) + assert telemetry.plugin_config["version"] == 1 + assert telemetry.plugin_config["components"][0]["config"]["version"] == 3 return telemetry.artifacts() artifacts = asyncio.run(run("invocation-1")) @@ -158,7 +185,40 @@ async def run(invocation_id: str) -> list[dict[str, str]]: for event in events ) invocation_ids = { - event.get("metadata", {}).get("nemo_fabric_invocation_id") - for event in events + event.get("metadata", {}).get("nemo_fabric_invocation_id") for event in events } assert {"invocation-1", "invocation-2"}.issubset(invocation_ids) + + +def test_relay_rejects_observability_config_version_2(tmp_path): + config_path = tmp_path / "relay-config.json" + config_path.write_text( + json.dumps( + { + "relay": { + "config": { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": {"version": 2}, + } + ], + } + } + } + ), + encoding="utf-8", + ) + + with pytest.raises( + ValueError, + match="unsupported NeMo Relay observability config version 2; expected version 3", + ): + _load_plugin_config( + _context(config_path), + base_dir=tmp_path, + agent_name="email-phishing", + model_name="test-model", + ) diff --git a/tests/integrations/test_relay_scope_leak.py b/tests/integrations/test_relay_scope_leak.py index 5d3693de5..d7f1166c6 100644 --- a/tests/integrations/test_relay_scope_leak.py +++ b/tests/integrations/test_relay_scope_leak.py @@ -15,7 +15,9 @@ import asyncio import contextlib import logging +import os import uuid +from pathlib import Path import pytest from nemo_fabric_adapters.deepagents import adapter @@ -28,14 +30,16 @@ @pytest.fixture(autouse=True) -def isolated_scope_stack(): +def isolated_scope_stack(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): """Give each test its own Relay scope stack. These tests strand a scope on purpose, and the stack is process-global, so without isolation the damage would leak into every test that runs afterwards. ``ContextVar`` - assignment is the only way to install a stack for the current context on Relay 0.6. + assignment is the only way to install a stack for the current context. """ + os.environ["XDG_CONFIG_HOME"] = str(tmp_path / "xdg-config") + monkeypatch.chdir(tmp_path) token = nemo_relay._scope_stack_var.set(nemo_relay.create_scope_stack()) try: yield @@ -138,7 +142,7 @@ class _NoopPlugin: @contextlib.asynccontextmanager async def plugin(self, config: object): - yield + yield {"diagnostics": [], "runtime_diagnostics": []} async def test_a_poisoned_runtime_quarantines_its_next_turn(monkeypatch): diff --git a/tests/python/test_code_review_example.py b/tests/python/test_code_review_example.py index 991a13eac..7a6a67838 100644 --- a/tests/python/test_code_review_example.py +++ b/tests/python/test_code_review_example.py @@ -9,6 +9,8 @@ from unittest.mock import AsyncMock from unittest.mock import MagicMock +import pytest + from examples.code_review_agent import BASE_DIR from examples.code_review_agent import __main__ as main_module from examples.code_review_agent import base_config @@ -68,7 +70,7 @@ def test_capability_and_telemetry_variants_do_not_mutate_their_input(): base = hermes_config() variants = ( with_github_mcp(base), - with_native_otel(base), + with_native_otel(codex_config()), with_opensandbox(base), with_relay(base), with_relay_openinference(base), @@ -91,6 +93,72 @@ def test_capability_and_telemetry_variants_do_not_mutate_their_input(): assert "relay" in variants[3].telemetry.providers +@pytest.mark.usefixtures("nemo_relay") +def test_native_otel_variants_match_adapter_contracts(): + from nemo_relay import plugin + + codex = with_native_otel(codex_config()) + assert codex.telemetry is not None + codex_config_payload = codex.telemetry.providers["native"].config + codex_observability = codex_config_payload["components"][0]["config"] + assert codex_observability["version"] == 1 + assert codex_observability["opentelemetry"]["endpoint"] == ( + "http://localhost:4318/v1/traces" + ) + + deepagents = with_native_otel(deepagents_config()) + assert deepagents.telemetry is not None + deepagents_config_payload = deepagents.telemetry.providers["native"].config + assert plugin.validate(deepagents_config_payload)["diagnostics"] == [] + + with pytest.raises(ValueError, match="does not support native OpenTelemetry"): + with_native_otel(hermes_config()) + + +@pytest.mark.parametrize( + ("variant", "endpoint_type", "endpoint"), + [ + ( + with_relay_otel, + "full", + "http://localhost:4318/v1/traces", + ), + ( + with_relay_openinference, + "openinference", + "http://localhost:6006/v1/traces", + ), + ], +) +@pytest.mark.usefixtures("nemo_relay") +def test_relay_otel_variants_author_v3_endpoints( + variant, + endpoint_type: str, + endpoint: str, +): + from nemo_relay import plugin + + config = variant(hermes_config()) + + observability = config.to_mapping()["relay"]["observability"] + assert observability["version"] == 3 + assert "openinference" not in observability + assert observability["opentelemetry"]["enabled"] is True + assert observability["opentelemetry"]["endpoints"][0]["type"] == endpoint_type + assert observability["opentelemetry"]["endpoints"][0]["endpoint"] == endpoint + plugin_config = { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": observability, + } + ], + } + assert plugin.validate(plugin_config)["diagnostics"] == [] + + def test_variants_plan_from_complete_configs(): client = Fabric() diff --git a/tests/python/test_sdk_contract.py b/tests/python/test_sdk_contract.py index fe31a92dc..3354ca7de 100644 --- a/tests/python/test_sdk_contract.py +++ b/tests/python/test_sdk_contract.py @@ -38,8 +38,11 @@ from nemo_fabric import RelayAtofFileSinkConfig from nemo_fabric import RelayAtofStreamSinkConfig from nemo_fabric import RelayComponentConfig +from nemo_fabric import RelayConfig from nemo_fabric import RelayConfigPolicy from nemo_fabric import RelayObservabilityConfig +from nemo_fabric import RelayOpenTelemetryConfig +from nemo_fabric import RelayOpenTelemetryEndpointConfig from nemo_fabric import RunOutput from nemo_fabric import RunPlan from nemo_fabric import RunRequest @@ -839,6 +842,16 @@ def test_fabric_config_authors_first_class_relay_observability(): filename_template="trajectory-{session_id}.atif.json", agent_name="fabric-tests", ), + opentelemetry=RelayOpenTelemetryConfig( + enabled=True, + endpoints=[ + RelayOpenTelemetryEndpointConfig( + type="openinference", + endpoint="http://localhost:6006/v1/traces", + header_env={"authorization": "OTEL_AUTHORIZATION"}, + ) + ], + ), ), components=[ RelayComponentConfig(kind="switchyard", config={"route": "canary"}), @@ -852,7 +865,7 @@ def test_fabric_config_authors_first_class_relay_observability(): assert config.to_mapping()["relay"] == { "output_dir": "./artifacts/relay", "observability": { - "version": 2, + "version": 3, "atof": { "enabled": True, "sinks": [ @@ -880,6 +893,23 @@ def test_fabric_config_authors_first_class_relay_observability(): "output_directory": "./artifacts/relay", "filename_template": "trajectory-{session_id}.atif.json", }, + "opentelemetry": { + "enabled": True, + "endpoints": [ + { + "type": "openinference", + "endpoint": "http://localhost:6006/v1/traces", + "mark_projection": "inherit", + "mark_exclude_names": ["llm.chunk"], + "transport": "http_binary", + "header_env": {"authorization": "OTEL_AUTHORIZATION"}, + "service_name": "unknown_service", + "instrumentation_scope": "opentelemetry", + "timeout_millis": 3000, + } + ], + }, + "enable_full_payloads": False, }, "components": [ { @@ -896,6 +926,206 @@ def test_fabric_config_authors_first_class_relay_observability(): } +@pytest.mark.parametrize("version", [1, 2, 4, True, 3.0, "3"]) +def test_typed_relay_observability_requires_exact_v3(version): + with pytest.raises( + ValidationError, match="requires observability config version 3" + ): + RelayObservabilityConfig(version=version) + + +def test_relay_mapping_inputs_validate_typed_v3_observability(): + with pytest.raises( + ValidationError, match="requires observability config version 3" + ): + RelayConfig(observability={"version": 2}) + + with pytest.raises( + ValidationError, match="requires observability config version 3" + ): + FabricConfig( + metadata=MetadataConfig(name="demo"), + harness=HarnessConfig(adapter_id="test.fabric.shim"), + relay={"observability": {"version": 2}}, + ) + + config = _fabric_config() + with pytest.raises( + ValidationError, match="requires observability config version 3" + ): + config.enable_relay(observability={"version": 2}) + assert config.relay is None + assert config.telemetry is None + + valid = FabricConfig( + metadata=MetadataConfig(name="demo"), + harness=HarnessConfig(adapter_id="test.fabric.shim"), + relay={"observability": {"version": 3}}, + ) + assert isinstance(valid.relay, RelayConfig) + assert isinstance(valid.relay.observability, RelayObservabilityConfig) + + +@pytest.mark.parametrize("version", [1, 2, 4, True, 3.0, "3"]) +def test_relay_generic_observability_component_requires_explicit_v3(version): + with pytest.raises( + ValidationError, match="requires observability config version 3" + ): + RelayConfig( + components=[ + RelayComponentConfig( + kind="observability", + config={"version": version}, + ) + ] + ) + + +def test_relay_generic_observability_component_allows_implicit_or_v3_version(): + for config in ({}, {"version": 3}): + relay = RelayConfig( + components=[ + RelayComponentConfig(kind="observability", config=config), + ] + ) + assert relay.components[0].config == config + + +def test_relay_generic_observability_component_reports_version_before_v3_shape(): + with pytest.raises( + ValidationError, + match="requires observability config version 3", + ): + RelayConfig( + components=[ + RelayComponentConfig( + kind="observability", + config={ + "version": 2, + "opentelemetry": { + "enabled": True, + "endpoint": "http://localhost:4318/v1/traces", + }, + }, + ) + ] + ) + + +@pytest.mark.parametrize( + ("opentelemetry", "valid"), + [ + ({"enabled": True}, False), + ({"enabled": True, "endpoints": []}, False), + ({"enabled": False, "endpoints": []}, True), + ], +) +def test_relay_generic_observability_component_requires_enabled_endpoint( + opentelemetry: dict[str, object], + valid: bool, +): + config = { + "version": 3, + "opentelemetry": opentelemetry, + } + if valid: + relay = RelayConfig( + components=[ + RelayComponentConfig(kind="observability", config=config), + ] + ) + assert relay.components[0].config == config + else: + with pytest.raises(ValidationError, match="requires at least one endpoint"): + RelayConfig( + components=[ + RelayComponentConfig(kind="observability", config=config), + ] + ) + + +@pytest.mark.parametrize( + "config", + [ + { + "version": 3, + "openinference": { + "enabled": True, + "endpoint": "http://localhost:6006/v1/traces", + }, + }, + { + "version": 3, + "opentelemetry": { + "enabled": True, + "endpoint": "http://localhost:4318/v1/traces", + }, + }, + ], +) +def test_relay_generic_observability_component_rejects_legacy_exporters(config): + with pytest.raises(ValidationError, match="observability config version 3"): + RelayConfig( + components=[ + RelayComponentConfig(kind="observability", config=config), + ] + ) + + +@pytest.mark.parametrize("endpoint", ["", " "]) +def test_relay_opentelemetry_endpoint_must_be_nonblank(endpoint: str): + with pytest.raises(ValidationError): + RelayOpenTelemetryEndpointConfig(type="full", endpoint=endpoint) + + +def test_relay_opentelemetry_requires_an_endpoint_when_enabled(): + with pytest.raises(ValidationError, match="requires at least one endpoint"): + RelayOpenTelemetryConfig(enabled=True) + + assert RelayOpenTelemetryConfig().endpoints == [] + + +def test_relay_observability_rejects_legacy_openinference_section(): + with pytest.raises(ValidationError, match="as an opentelemetry endpoint"): + RelayObservabilityConfig( + openinference={ + "enabled": True, + "endpoint": "http://localhost:6006/v1/traces", + } + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("attribute_mappings", [{"key": "model", "alias": "llm.model"}]), + ("endpoint", "http://localhost:4318/v1/traces"), + ("transport", "http_binary"), + ("headers", {"authorization": "test"}), + ("header_env", {"authorization": "OTEL_AUTHORIZATION"}), + ("resource_attributes", {"deployment.environment": "test"}), + ("service_name", "fabric"), + ("service_namespace", "platform"), + ("service_version", "0.4.0"), + ("instrumentation_scope", "fabric.relay"), + ("timeout_millis", 1000), + ("mark_projection", "tool"), + ("mark_exclude_names", ["llm.chunk"]), + ("semantic_selector", "openinference"), + ("capture_content", True), + ], +) +def test_relay_opentelemetry_rejects_flat_v2_exporter_fields(field, value): + with pytest.raises(ValidationError, match=rf"endpoints: {field}"): + RelayOpenTelemetryConfig(**{field: value}) + + +def test_relay_opentelemetry_preserves_unknown_future_fields(): + config = RelayOpenTelemetryConfig(future_option={"enabled": True}) + + assert config.extra_fields == {"future_option": {"enabled": True}} + + @pytest.mark.parametrize( ("headers", "header_env"), [ diff --git a/uv.lock b/uv.lock index 52ec16a93..18e1bc8c4 100644 --- a/uv.lock +++ b/uv.lock @@ -31,14 +31,14 @@ name = "aiohttp" version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohappyeyeballs", marker = "python_full_version >= '3.12'" }, - { name = "aiosignal", marker = "python_full_version >= '3.12'" }, - { name = "attrs", marker = "python_full_version >= '3.12'" }, - { name = "frozenlist", marker = "python_full_version >= '3.12'" }, - { name = "multidict", marker = "python_full_version >= '3.12'" }, - { name = "propcache", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, - { name = "yarl", marker = "python_full_version >= '3.12'" }, + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } wheels = [ @@ -149,8 +149,8 @@ name = "aiosignal" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "frozenlist", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -478,7 +478,7 @@ name = "concurrent-log-handler" version = "0.9.29" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "portalocker", marker = "(python_full_version < '3.14' and sys_platform == 'win32') or (python_full_version < '3.12' and sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "portalocker" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/ba185acc438cff6b58cd8f8dec27e7f4fcabf6968a1facbb6d0cacbde7fe/concurrent_log_handler-0.9.29.tar.gz", hash = "sha256:bc37a76d3f384cbf4a98f693ebd770543edc0f4cd5c6ab6bc70e9e1d7d582265", size = 42114, upload-time = "2026-02-22T18:18:25.758Z" } wheels = [ @@ -579,8 +579,8 @@ name = "croniter" version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil", marker = "python_full_version < '3.14'" }, - { name = "pytz", marker = "python_full_version < '3.14'" }, + { name = "python-dateutil" }, + { name = "pytz" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ad/2f/44d1ae153a0e27be56be43465e5cb39b9650c781e001e7864389deb25090/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577", size = 64481, upload-time = "2024-12-17T17:17:47.32Z" } wheels = [ @@ -600,7 +600,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'linux'", ] dependencies = [ - { name = "cffi", marker = "python_full_version < '3.14' and platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ @@ -665,7 +665,7 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "cffi", marker = "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } wheels = [ @@ -747,7 +747,7 @@ name = "deprecation" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } wheels = [ @@ -759,7 +759,7 @@ name = "dirhash" version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "scantree", marker = "python_full_version >= '3.12'" }, + { name = "scantree" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/70/49f93897f3a4f7ab5f20a854ebc91aad47854e9fb2cd169e3a4452fa3f5e/dirhash-0.5.0.tar.gz", hash = "sha256:e60760f0ab2e935d8cb088923ea2c6492398dca42cec785df778985fd4cd5386", size = 21377, upload-time = "2024-08-03T22:14:13.322Z" } wheels = [ @@ -894,7 +894,7 @@ name = "fire" version = "0.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "termcolor", marker = "python_full_version < '3.14'" }, + { name = "termcolor" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/00/f8d10588d2019d6d6452653def1ee807353b21983db48550318424b5ff18/fire-0.7.1.tar.gz", hash = "sha256:3b208f05c736de98fb343310d090dcc4d8c78b2a89ea4f32b837c586270a9cbf", size = 88720, upload-time = "2025-08-16T20:20:24.175Z" } wheels = [ @@ -1069,8 +1069,8 @@ name = "h2" version = "4.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "hpack", marker = "python_full_version >= '3.12'" }, - { name = "hyperframe", marker = "python_full_version >= '3.12'" }, + { name = "hpack" }, + { name = "hyperframe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } wheels = [ @@ -1082,27 +1082,27 @@ name = "harbor" version = "0.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "dirhash", marker = "python_full_version >= '3.12'" }, - { name = "fastapi", marker = "python_full_version >= '3.12'" }, - { name = "filelock", marker = "python_full_version >= '3.12'" }, - { name = "httpx", marker = "python_full_version >= '3.12'" }, - { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "litellm", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pathspec", marker = "python_full_version >= '3.12'" }, - { name = "platformdirs", marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "pyjwt", marker = "python_full_version >= '3.12'" }, - { name = "python-dotenv", marker = "python_full_version >= '3.12'" }, - { name = "pyyaml", marker = "python_full_version >= '3.12'" }, - { name = "requests", marker = "python_full_version >= '3.12'" }, - { name = "rich", marker = "python_full_version >= '3.12'" }, - { name = "shortuuid", marker = "python_full_version >= '3.12'" }, - { name = "supabase", marker = "python_full_version >= '3.12'" }, - { name = "tenacity", marker = "python_full_version >= '3.12'" }, - { name = "toml", marker = "python_full_version >= '3.12'" }, - { name = "typer", marker = "python_full_version >= '3.12'" }, - { name = "uvicorn", marker = "python_full_version >= '3.12'" }, + { name = "dirhash" }, + { name = "fastapi" }, + { name = "filelock" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "litellm" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "shortuuid" }, + { name = "supabase" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "typer" }, + { name = "uvicorn" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/24/7222433d3b9f665db1759456c281a9c136b921300be5d7af269f183ee59e/harbor-0.18.0.tar.gz", hash = "sha256:9b918b99ec38b4e16db7e0b797dcebf92bfc8be9d9ba22a2d2ed6ebb8feb38df", size = 1535447, upload-time = "2026-07-07T20:29:46.878Z" } wheels = [ @@ -1114,36 +1114,36 @@ name = "hermes-agent" version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "python_full_version < '3.14'" }, - { name = "concurrent-log-handler", marker = "python_full_version < '3.14' and sys_platform == 'win32'" }, - { name = "croniter", marker = "python_full_version < '3.14'" }, - { name = "cryptography", version = "46.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "fastapi", marker = "python_full_version < '3.14'" }, - { name = "fire", marker = "python_full_version < '3.14'" }, - { name = "httpx", extra = ["socks"], marker = "python_full_version < '3.14'" }, - { name = "jinja2", marker = "python_full_version < '3.14'" }, - { name = "markdown", marker = "python_full_version < '3.14'" }, - { name = "openai", marker = "python_full_version < '3.14'" }, - { name = "packaging", marker = "python_full_version < '3.14'" }, - { name = "pathspec", marker = "python_full_version < '3.14'" }, - { name = "pillow", marker = "python_full_version < '3.14'" }, - { name = "prompt-toolkit", marker = "python_full_version < '3.14'" }, - { name = "psutil", marker = "python_full_version < '3.14'" }, - { name = "ptyprocess", marker = "python_full_version < '3.14' and sys_platform != 'win32'" }, - { name = "pydantic", marker = "python_full_version < '3.14'" }, - { name = "pyjwt", extra = ["crypto"], marker = "python_full_version < '3.14'" }, - { name = "python-dotenv", marker = "python_full_version < '3.14'" }, - { name = "python-multipart", marker = "python_full_version < '3.14'" }, - { name = "pywinpty", marker = "python_full_version < '3.14' and sys_platform == 'win32'" }, - { name = "pyyaml", marker = "python_full_version < '3.14'" }, - { name = "requests", marker = "python_full_version < '3.14'" }, - { name = "rich", marker = "python_full_version < '3.14'" }, - { name = "ruamel-yaml", marker = "python_full_version < '3.14'" }, - { name = "tenacity", marker = "python_full_version < '3.14'" }, - { name = "tzdata", marker = "python_full_version < '3.14' and sys_platform == 'win32'" }, - { name = "urllib3", marker = "python_full_version < '3.14'" }, - { name = "uvicorn", extra = ["standard"], marker = "python_full_version < '3.14'" }, - { name = "websockets", marker = "python_full_version < '3.14'" }, + { name = "certifi" }, + { name = "concurrent-log-handler", marker = "sys_platform == 'win32'" }, + { name = "croniter" }, + { name = "cryptography", version = "46.0.7", source = { registry = "https://pypi.org/simple" } }, + { name = "fastapi" }, + { name = "fire" }, + { name = "httpx", extra = ["socks"] }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "openai" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pillow" }, + { name = "prompt-toolkit" }, + { name = "psutil" }, + { name = "ptyprocess", marker = "sys_platform != 'win32'" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "pywinpty", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "ruamel-yaml" }, + { name = "tenacity" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "urllib3" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/2c/5eca471374c1b48555a8f0b988c06b19a93ae33a6c0e67845e47fc3a5628/hermes_agent-0.19.0.tar.gz", hash = "sha256:ac986bede64a2785436676c0ea084ec586574f8cb00a9d047e095b435d3e21c0", size = 14282599, upload-time = "2026-07-20T18:37:28.903Z" } wheels = [ @@ -1152,8 +1152,8 @@ wheels = [ [package.optional-dependencies] mcp = [ - { name = "mcp", version = "1.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "starlette", version = "1.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "mcp", version = "1.26.0", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette", version = "1.0.1", source = { registry = "https://pypi.org/simple" } }, ] [[package]] @@ -1270,10 +1270,10 @@ wheels = [ [package.optional-dependencies] http2 = [ - { name = "h2", marker = "python_full_version >= '3.12'" }, + { name = "h2" }, ] socks = [ - { name = "socksio", marker = "python_full_version < '3.14'" }, + { name = "socksio" }, ] [[package]] @@ -1290,16 +1290,16 @@ name = "huggingface-hub" version = "1.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "python_full_version >= '3.12'" }, - { name = "filelock", marker = "python_full_version >= '3.12'" }, - { name = "fsspec", marker = "python_full_version >= '3.12'" }, - { name = "hf-xet", marker = "(python_full_version >= '3.12' and platform_machine == 'AMD64') or (python_full_version >= '3.12' and platform_machine == 'aarch64') or (python_full_version >= '3.12' and platform_machine == 'amd64') or (python_full_version >= '3.12' and platform_machine == 'arm64') or (python_full_version >= '3.12' and platform_machine == 'x86_64')" }, - { name = "httpx", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pyyaml", marker = "python_full_version >= '3.12'" }, - { name = "tqdm", marker = "python_full_version >= '3.12'" }, - { name = "typer", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e6/7e/fad82ad491b226e832d2da90a1a59f36acd4526cda8c726f639834754aa4/huggingface_hub-1.20.1.tar.gz", hash = "sha256:9f6d63bfbeab2d2a8357200a9bc4f18cd2c8bfac9579f792f5922e77bf6471d0", size = 859910, upload-time = "2026-06-18T22:06:53.348Z" } wheels = [ @@ -1338,7 +1338,7 @@ name = "importlib-metadata" version = "8.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version >= '3.12'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e7/72/c600ae4f68c28fc19f9c31b9403053e5dbb8cace2e6842c7b7c3e4d42fe9/importlib_metadata-8.9.0.tar.gz", hash = "sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee", size = 56140, upload-time = "2026-03-20T16:56:26.362Z" } wheels = [ @@ -1756,18 +1756,18 @@ name = "litellm" version = "1.89.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "python_full_version >= '3.12'" }, - { name = "click", marker = "python_full_version >= '3.12'" }, - { name = "fastuuid", marker = "python_full_version >= '3.12'" }, - { name = "httpx", marker = "python_full_version >= '3.12'" }, - { name = "importlib-metadata", marker = "python_full_version >= '3.12'" }, - { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "jsonschema", marker = "python_full_version >= '3.12'" }, - { name = "openai", marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "python-dotenv", marker = "python_full_version >= '3.12'" }, - { name = "tiktoken", marker = "python_full_version >= '3.12'" }, - { name = "tokenizers", marker = "python_full_version >= '3.12'" }, + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, ] sdist = { url = "https://files.pythonhosted.org/packages/56/f1/f7cfead063f2ab1877c8fb465d0d7fe300b75f081bcb73525f6d550aeb1c/litellm-1.89.3.tar.gz", hash = "sha256:8fcdb2b7a0ef3381d41adf164443842e31ef9f0cd5bcda6fc3c0bd8bc2959510", size = 14080611, upload-time = "2026-06-20T22:42:26.997Z" } wheels = [ @@ -1904,7 +1904,7 @@ wheels = [ [package.optional-dependencies] zig = [ - { name = "ziglang", marker = "sys_platform == 'linux'" }, + { name = "ziglang" }, ] [[package]] @@ -1920,20 +1920,20 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'linux'", ] dependencies = [ - { name = "anyio", marker = "python_full_version < '3.14'" }, - { name = "httpx", marker = "python_full_version < '3.14'" }, - { name = "httpx-sse", marker = "python_full_version < '3.14'" }, - { name = "jsonschema", marker = "python_full_version < '3.14'" }, - { name = "pydantic", marker = "python_full_version < '3.14'" }, - { name = "pydantic-settings", marker = "python_full_version < '3.14'" }, - { name = "pyjwt", extra = ["crypto"], marker = "python_full_version < '3.14'" }, - { name = "python-multipart", marker = "python_full_version < '3.14'" }, - { name = "pywin32", marker = "python_full_version < '3.14' and sys_platform == 'win32'" }, - { name = "sse-starlette", marker = "python_full_version < '3.14'" }, - { name = "starlette", version = "1.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, - { name = "typing-inspection", marker = "python_full_version < '3.14'" }, - { name = "uvicorn", marker = "python_full_version < '3.14' and sys_platform != 'emscripten'" }, + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette", version = "1.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } wheels = [ @@ -1951,20 +1951,20 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.14'" }, - { name = "httpx", marker = "python_full_version >= '3.14'" }, - { name = "httpx-sse", marker = "python_full_version >= '3.14'" }, - { name = "jsonschema", marker = "python_full_version >= '3.14'" }, - { name = "pydantic", marker = "python_full_version >= '3.14'" }, - { name = "pydantic-settings", marker = "python_full_version >= '3.14'" }, - { name = "pyjwt", extra = ["crypto"], marker = "python_full_version >= '3.14'" }, - { name = "python-multipart", marker = "python_full_version >= '3.14'" }, - { name = "pywin32", marker = "python_full_version >= '3.14' and sys_platform == 'win32'" }, - { name = "sse-starlette", marker = "python_full_version >= '3.14'" }, - { name = "starlette", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, - { name = "typing-inspection", marker = "python_full_version >= '3.14'" }, - { name = "uvicorn", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten'" }, + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette", version = "1.3.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } wheels = [ @@ -2201,7 +2201,7 @@ requires-dist = [ { name = "nemo-fabric-adapters-deepagents", extras = ["harness"], marker = "extra == 'deepagents'", editable = "adapters/deepagents" }, { name = "nemo-fabric-adapters-hermes", extras = ["harness"], marker = "python_full_version < '3.14' and extra == 'hermes-agent'", editable = "adapters/hermes" }, { name = "nemo-fabric-runtime", editable = "python" }, - { name = "nemo-relay", marker = "extra == 'relay'", specifier = ">=0.6.0,<0.7" }, + { name = "nemo-relay", marker = "extra == 'relay'", specifier = ">=0.7.2,<0.8" }, { name = "pyyaml", marker = "extra == 'harbor'", specifier = ">=6.0" }, ] provides-extras = ["claude", "codex", "deepagents", "harbor", "hermes-agent", "relay"] @@ -2353,8 +2353,8 @@ requires-dist = [ { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0,<4.0" }, { name = "nemo-fabric-adapter-contract", editable = "adapter-contract" }, { name = "nemo-fabric-adapters-common", editable = "adapters/common" }, - { name = "nemo-relay", marker = "extra == 'relay'", specifier = ">=0.6.0,<0.7" }, - { name = "nemo-relay", extras = ["deepagents"], marker = "extra == 'full'", specifier = ">=0.6.0,<0.7" }, + { name = "nemo-relay", marker = "extra == 'relay'", specifier = ">=0.7.2,<0.8" }, + { name = "nemo-relay", extras = ["deepagents"], marker = "extra == 'full'", specifier = ">=0.7.2,<0.8" }, ] provides-extras = ["harness", "relay", "full"] @@ -2363,13 +2363,13 @@ name = "nemo-fabric-adapters-hermes" version = "0.2.0" source = { editable = "adapters/hermes" } dependencies = [ - { name = "nemo-fabric-adapter-contract", marker = "python_full_version < '3.14'" }, - { name = "nemo-fabric-adapters-common", marker = "python_full_version < '3.14'" }, + { name = "nemo-fabric-adapter-contract" }, + { name = "nemo-fabric-adapters-common" }, ] [package.optional-dependencies] harness = [ - { name = "hermes-agent", extra = ["mcp"], marker = "python_full_version < '3.14'" }, + { name = "hermes-agent", extra = ["mcp"] }, ] [package.metadata] @@ -2378,8 +2378,8 @@ requires-dist = [ { name = "hermes-agent", extras = ["mcp"], marker = "python_full_version < '3.14' and extra == 'harness'", specifier = ">=0.19.0" }, { name = "nemo-fabric-adapter-contract", editable = "adapter-contract" }, { name = "nemo-fabric-adapters-common", editable = "adapters/common" }, - { name = "nemo-relay", marker = "extra == 'full'", specifier = ">=0.6.0,<0.7" }, - { name = "nemo-relay", marker = "extra == 'relay'", specifier = ">=0.6.0,<0.7" }, + { name = "nemo-relay", marker = "extra == 'full'", specifier = ">=0.7.2,<0.8" }, + { name = "nemo-relay", marker = "extra == 'relay'", specifier = ">=0.7.2,<0.8" }, ] provides-extras = ["harness", "relay", "full"] @@ -2399,17 +2399,17 @@ requires-dist = [ [[package]] name = "nemo-relay" -version = "0.6.0" +version = "0.7.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/db/44d7258ee620c5cce6dc588983fcb11be3ee03ae71089a65686c14d9ee02/nemo_relay-0.6.0.tar.gz", hash = "sha256:f3d3088019609bc953357b5598a47481dc3e7dc8f11ecf27002ede251f37eb7b", size = 1071046, upload-time = "2026-08-03T14:55:49.702Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/81/a7a545ac3a2f8c670d261c89df599aa8fbf49d8be45fd1f52efb36b489eb/nemo_relay-0.7.2.tar.gz", hash = "sha256:828d9f6c7d7e4e42276bb7192bd44202c761e0c76fa4943d84e051b5a99028e5", size = 1295616, upload-time = "2026-08-08T01:54:00.953Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/65/d320016505457cc30971f575e8dadffb923b7cfc780ab8bb25a4ce9d305c/nemo_relay-0.6.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:ad5dae6febf6532d7b113abc2a404679c8feffc499df3034b93d9a078185d2bb", size = 9917779, upload-time = "2026-07-22T20:07:48.961Z" }, - { url = "https://files.pythonhosted.org/packages/ae/c0/f33250e71c4206da1b339072893f9a1e39295fe1aceb9a2fef4b8620a0f2/nemo_relay-0.6.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0cd9570f64c6956fe3bfb82af1cdb3ee70cb50b51098cdb0de831c3f9b4e904", size = 8888375, upload-time = "2026-07-22T20:07:51.049Z" }, - { url = "https://files.pythonhosted.org/packages/a3/f4/d1dfaed022da0f6f14765a122867f976a69cc520fe1faaf99757f5719d1f/nemo_relay-0.6.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:849daa9e45158ac581e54506e0fcc7a24f557d1ed06dbdc074f5de7a00393cbc", size = 9336372, upload-time = "2026-07-22T20:07:53.224Z" }, - { url = "https://files.pythonhosted.org/packages/60/9e/f8b80509eef5e05702b940a3d1e2f60c962548d87dec2d712fd3804e6cd4/nemo_relay-0.6.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8c80e534b76bb0455cfc222aaf5c10fa064c088b3c0d5e137eb3c97db46dbc47", size = 10578834, upload-time = "2026-07-30T15:44:44.726Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a0/84ee49d45a1a874457f2f9260d30fd573af30c9be360d9d97b8bb7835ad9/nemo_relay-0.6.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c8bc4a792a2f8c35ddef1b900cc43be2b2bbbfcd5e7cf65aa0829c04d25eb77f", size = 10849651, upload-time = "2026-07-30T15:44:40.48Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b0/908d77f75b9054e1e403da78f7d9430249a45939070d4298abbb41a04b5b/nemo_relay-0.6.0-cp311-abi3-win_amd64.whl", hash = "sha256:bfbbedfd130fa95c9b8c04643c30910df850e0ed3500beeab82b00ca2d94e7ea", size = 9613425, upload-time = "2026-07-22T20:07:55.175Z" }, - { url = "https://files.pythonhosted.org/packages/cd/71/c438b9d746303ff7f270d99f13b250bf947cdac3e52de2a83fd132cbca0b/nemo_relay-0.6.0-cp311-abi3-win_arm64.whl", hash = "sha256:82fe132943399d89e6ec34dc28df0be7bbe41b84f6698c545928b8816b6010f6", size = 9034810, upload-time = "2026-07-22T20:07:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/f50440257f01bc5ab3d668331c90e06cf4edcc84dc7dc582d322ad05b622/nemo_relay-0.7.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:e7c7977f0903793cc34c5542bf2b2e44d107def8a5ae9f1b28f06dd61ddec4ed", size = 9246341, upload-time = "2026-08-08T01:53:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9f/4041446dd134218799a34b5b5fad3a62d3e1d0a6c322ba2ca4b896ba1393/nemo_relay-0.7.2-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ae77c1f3d58eabda264e82ffaca54548df80caede7dd6af8cbd8f72b4a82ed", size = 8454070, upload-time = "2026-08-08T01:53:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/11/83/90230c2e9fae1aee39f768d4a9ef57e9f2716bcaed1a5923cce8b526c66b/nemo_relay-0.7.2-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ce7103aec546766649c182619d16aa6ad07439e4d0ebd16d95c5004afb3e56a", size = 8954377, upload-time = "2026-08-08T01:53:25.267Z" }, + { url = "https://files.pythonhosted.org/packages/71/e7/463fa461d0801146fec6a00cbc02e8961b30089d65ba170f9dfa9e6e3dcd/nemo_relay-0.7.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2e7d0c2629ade7313aaed71d2272dca96a2fafad0248d0f87cf40a7720b252a0", size = 10322132, upload-time = "2026-08-08T01:53:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/32/8c/e20ec9c52bd1edd953157aaf24d0d9f9ab8afcbf108fc2356f398e252da8/nemo_relay-0.7.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b841c92395d7686c7f233036008294b9d362af1ec5123ab0babbfd11cbb04054", size = 10704141, upload-time = "2026-08-08T01:53:30.453Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c1/92a73961ea759b433b1f897b225662d499123cb962b48dc8ece19f610a09/nemo_relay-0.7.2-cp311-abi3-win_amd64.whl", hash = "sha256:0cdcc5e09d6d62d5c1d385dc62c9233eb714a25f36a09da81e5b9731e3c67903", size = 8803938, upload-time = "2026-08-08T01:53:33.437Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ec/2de114dab437431173988b9b11f46e8d377e12d57e1b4903258f3e03c2df/nemo_relay-0.7.2-cp311-abi3-win_arm64.whl", hash = "sha256:ca5f66e617311f836a10d96f120f3f32a99b4267d65048453b31951de3419a9d", size = 8438997, upload-time = "2026-08-08T01:53:36.12Z" }, ] [[package]] @@ -2616,7 +2616,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.12' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -2752,7 +2752,7 @@ name = "portalocker" version = "3.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pywin32", marker = "python_full_version < '3.14' and sys_platform == 'win32'" }, + { name = "pywin32" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } wheels = [ @@ -2764,10 +2764,10 @@ name = "postgrest" version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "deprecation", marker = "python_full_version >= '3.12'" }, - { name = "httpx", extra = ["http2"], marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "yarl", marker = "python_full_version >= '3.12'" }, + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/22/88c470d8838d2678a44e0172d061630b8837cba3fb7fb492e28f6578c309/postgrest-2.31.0.tar.gz", hash = "sha256:2f395d84b2ee34fc57622ff2f711df603e2ede625f98e5015240741888f7bd0c", size = 14419, upload-time = "2026-06-04T13:37:20.474Z" } wheels = [ @@ -3204,7 +3204,7 @@ name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six", marker = "python_full_version < '3.14'" }, + { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ @@ -3345,9 +3345,9 @@ name = "realtime" version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, - { name = "websockets", marker = "python_full_version >= '3.12'" }, + { name = "pydantic" }, + { name = "typing-extensions" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/34/54a1eaaefa24db5cb12596fd74792e08efa53ed30dc5bce2c0a68ded6146/realtime-2.31.0.tar.gz", hash = "sha256:9e641cb4d77ca0fe768515f8cf9f83550c79f49ce1550a95afc2dc0e252be8c9", size = 18716, upload-time = "2026-06-04T13:37:22.089Z" } wheels = [ @@ -3654,7 +3654,7 @@ name = "ruamel-yaml" version = "0.18.17" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ruamel-yaml-clib", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, + { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3a/2b/7a1f1ebcd6b3f14febdc003e658778d81e76b40df2267904ee6b13f0c5c6/ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c", size = 149602, upload-time = "2025-12-17T20:02:55.757Z" } wheels = [ @@ -3714,8 +3714,8 @@ name = "scantree" version = "0.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs", marker = "python_full_version >= '3.12'" }, - { name = "pathspec", marker = "python_full_version >= '3.12'" }, + { name = "attrs" }, + { name = "pathspec" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/e4/40998faefc72ba1ddeb640a44fba92935353525dba110488806da8339c0b/scantree-0.0.4.tar.gz", hash = "sha256:15bd5cb24483b04db2c70653604e8ea3522e98087db7e38ab8482f053984c0ac", size = 24643, upload-time = "2024-08-03T20:08:59.413Z" } wheels = [ @@ -3829,8 +3829,8 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'linux'", ] dependencies = [ - { name = "anyio", marker = "python_full_version < '3.14'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version != '3.13.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } wheels = [ @@ -3848,7 +3848,7 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.14'" }, + { name = "anyio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ @@ -3860,10 +3860,10 @@ name = "storage3" version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "deprecation", marker = "python_full_version >= '3.12'" }, - { name = "httpx", extra = ["http2"], marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "yarl", marker = "python_full_version >= '3.12'" }, + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/30/fee43d523d3f680a833a4aae5bf8094de0b9031b0c2bddb3e0bc6e829e1b/storage3-2.31.0.tar.gz", hash = "sha256:d2161e2ea650dc115a1787c30e09b118365589ac772f4dd8643e3a503ecfc667", size = 20348, upload-time = "2026-06-04T13:37:23.703Z" } wheels = [ @@ -3884,13 +3884,13 @@ name = "supabase" version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx", marker = "python_full_version >= '3.12'" }, - { name = "postgrest", marker = "python_full_version >= '3.12'" }, - { name = "realtime", marker = "python_full_version >= '3.12'" }, - { name = "storage3", marker = "python_full_version >= '3.12'" }, - { name = "supabase-auth", marker = "python_full_version >= '3.12'" }, - { name = "supabase-functions", marker = "python_full_version >= '3.12'" }, - { name = "yarl", marker = "python_full_version >= '3.12'" }, + { name = "httpx" }, + { name = "postgrest" }, + { name = "realtime" }, + { name = "storage3" }, + { name = "supabase-auth" }, + { name = "supabase-functions" }, + { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fe/8e/54a2f950629689b1613434a61fc3bff5f92f84ba6b20213f5b2add05c1bb/supabase-2.31.0.tar.gz", hash = "sha256:3467b09d00482b9a0138235bdbde7a350426f93cf2a1342372eaddfc669f1206", size = 9805, upload-time = "2026-06-04T13:37:25.22Z" } wheels = [ @@ -3902,9 +3902,9 @@ name = "supabase-auth" version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx", extra = ["http2"], marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "pyjwt", extra = ["crypto"], marker = "python_full_version >= '3.12'" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/8a/408689cf39820f0d46d2731d6747ff94dbefc87ae977b4b5c4066da5b070/supabase_auth-2.31.0.tar.gz", hash = "sha256:0945b33fa96239c76dc8eaf96d7d2c94991950d24b4cfe4a5c2da9aa5e909663", size = 39151, upload-time = "2026-06-04T13:37:27.375Z" } wheels = [ @@ -3916,9 +3916,9 @@ name = "supabase-functions" version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx", extra = ["http2"], marker = "python_full_version >= '3.12'" }, - { name = "strenum", marker = "python_full_version >= '3.12'" }, - { name = "yarl", marker = "python_full_version >= '3.12'" }, + { name = "httpx", extra = ["http2"] }, + { name = "strenum" }, + { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/84/5d/61c2446ed26a57fa5543f9c270a731320911569202340d15341f72cdba7c/supabase_functions-2.31.0.tar.gz", hash = "sha256:4ad027b3ae3bd28b31233339f4db1da6965affd3546f655b421baf40cee2690f", size = 4683, upload-time = "2026-06-04T13:37:28.878Z" } wheels = [ @@ -4002,7 +4002,7 @@ name = "tokenizers" version = "0.23.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "python_full_version >= '3.12'" }, + { name = "huggingface-hub" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } wheels = [ @@ -4286,13 +4286,13 @@ wheels = [ [package.optional-dependencies] standard = [ - { name = "colorama", marker = "python_full_version < '3.14' and sys_platform == 'win32'" }, - { name = "httptools", marker = "python_full_version < '3.14'" }, - { name = "python-dotenv", marker = "python_full_version < '3.14'" }, - { name = "pyyaml", marker = "python_full_version < '3.14'" }, - { name = "uvloop", marker = "python_full_version < '3.14' and platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles", marker = "python_full_version < '3.14'" }, - { name = "websockets", marker = "python_full_version < '3.14'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, ] [[package]] @@ -4353,7 +4353,7 @@ name = "watchfiles" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "python_full_version < '3.14'" }, + { name = "anyio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ @@ -4658,9 +4658,9 @@ name = "yarl" version = "1.24.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna", marker = "python_full_version >= '3.12'" }, - { name = "multidict", marker = "python_full_version >= '3.12'" }, - { name = "propcache", marker = "python_full_version >= '3.12'" }, + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } wheels = [