diff --git a/ATTRIBUTIONS-Python.md b/ATTRIBUTIONS-Python.md index 2d4db8920..2c678c78b 100644 --- a/ATTRIBUTIONS-Python.md +++ b/ATTRIBUTIONS-Python.md @@ -3335,6 +3335,216 @@ SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` +## opentelemetry-proto (1.42.1) + +### Licenses +License: `Apache-2.0` + + - `LICENSE`: +``` +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +``` + ## orjson (3.11.9) ### Licenses @@ -4293,6 +4503,47 @@ Apache License limitations under the License. ``` +## protobuf (6.33.6) + +### Licenses +License: `BSD-3-Clause` + + - `LICENSE`: +``` +Copyright 2008 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Code generated by the Protocol Buffer compiler is owned by the owner +of the input file used when generating it. This code is not +standalone and requires a support library to be linked with it. This +support library is itself covered by the above license. +``` + ## ptyprocess (0.7.0) ### Licenses diff --git a/crates/python/src/py_types/codecs.rs b/crates/python/src/py_types/codecs.rs index 936f3afe5..d8b9d1a84 100644 --- a/crates/python/src/py_types/codecs.rs +++ b/crates/python/src/py_types/codecs.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use pyo3::prelude::*; +use serde::de::DeserializeOwned; use super::core::PyLLMRequest; use super::{ @@ -20,6 +21,7 @@ use super::{ FORCE_ANNOTATED_RESPONSE_TOOL_CALLS_SERIALIZATION_ERROR, FORCE_ANNOTATED_RESPONSE_USAGE_SERIALIZATION_ERROR, }; +use nemo_relay::codec::response::FinishReason; // --------------------------------------------------------------------------- // AnnotatedLLMRequest @@ -457,8 +459,92 @@ pub struct PyAnnotatedLLMResponse { pub inner: AnnotatedLLMResponse, } +fn optional_py_json( + value: Option<&Bound<'_, PyAny>>, + field_name: &'static str, +) -> PyResult> +where + T: DeserializeOwned, +{ + let Some(value) = value else { + return Ok(None); + }; + if value.is_none() { + return Ok(None); + } + + let json = py_to_json(value).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("invalid {field_name}: {e}")) + })?; + serde_json::from_value(json) + .map(Some) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("invalid {field_name}: {e}"))) +} + +fn optional_finish_reason(value: Option<&Bound<'_, PyAny>>) -> PyResult> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_none() { + return Ok(None); + } + + let json = py_to_json(value).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("invalid finish_reason: {e}")) + })?; + if let serde_json::Value::String(reason) = &json { + return Ok(Some(match reason.as_str() { + "complete" => FinishReason::Complete, + "length" => FinishReason::Length, + "tool_use" => FinishReason::ToolUse, + "content_filter" => FinishReason::ContentFilter, + other => FinishReason::Unknown(other.to_string()), + })); + } + + serde_json::from_value(json) + .map(Some) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("invalid finish_reason: {e}"))) +} + #[pymethods] impl PyAnnotatedLLMResponse { + #[new] + #[pyo3(signature = ( + id = None, + model = None, + message = None, + tool_calls = None, + finish_reason = None, + usage = None, + api_specific = None, + extra = None + ))] + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + id: Option, + model: Option, + message: Option<&Bound<'_, PyAny>>, + tool_calls: Option<&Bound<'_, PyAny>>, + finish_reason: Option<&Bound<'_, PyAny>>, + usage: Option<&Bound<'_, PyAny>>, + api_specific: Option<&Bound<'_, PyAny>>, + extra: Option<&Bound<'_, PyAny>>, + ) -> PyResult { + Ok(Self { + inner: AnnotatedLLMResponse { + id, + model, + message: optional_py_json(message, "message")?, + tool_calls: optional_py_json(tool_calls, "tool_calls")?, + finish_reason: optional_finish_reason(finish_reason)?, + usage: optional_py_json(usage, "usage")?, + api_specific: optional_py_json(api_specific, "api_specific")?, + extra: optional_py_json(extra, "extra")?.unwrap_or_default(), + }, + }) + } + #[getter] pub(crate) fn id(&self) -> Option { self.inner.id.clone() @@ -506,8 +592,13 @@ impl PyAnnotatedLLMResponse { self.inner .finish_reason .as_ref() - .and_then(|fr| serde_json::to_value(fr).ok()) - .and_then(|v| v.as_str().map(|s| s.to_string())) + .map(|reason| match reason { + FinishReason::Complete => "complete".to_string(), + FinishReason::Length => "length".to_string(), + FinishReason::ToolUse => "tool_use".to_string(), + FinishReason::ContentFilter => "content_filter".to_string(), + FinishReason::Unknown(value) => value.clone(), + }) } #[getter] diff --git a/pyproject.toml b/pyproject.toml index bb51101b7..eb8afe83e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ dev = [ ] test = [ + "opentelemetry-proto>=1.39,<2", "pydantic>=2", "pytest>=8", "pytest-asyncio>=0.26", diff --git a/python/nemo_relay/_native.pyi b/python/nemo_relay/_native.pyi index e3b66865f..c30145a86 100644 --- a/python/nemo_relay/_native.pyi +++ b/python/nemo_relay/_native.pyi @@ -492,6 +492,37 @@ class AnnotatedLLMResponse: provider-specific fields consistently. """ + def __init__( + self, + id: Optional[str] = None, + model: Optional[str] = None, + message: Optional[_JsonValue] = None, + tool_calls: Optional[Sequence[Mapping[str, _JsonValue]]] = None, + finish_reason: Optional[str | Mapping[str, _JsonValue]] = None, + usage: Optional[Mapping[str, _JsonValue]] = None, + api_specific: Optional[Mapping[str, _JsonValue]] = None, + extra: Optional[Mapping[str, _JsonValue]] = None, + ) -> None: + """Create a normalized LLM response view. + + Args: + id: Optional provider response identifier. + model: Optional model that served the response. + message: Optional normalized assistant response content. + tool_calls: Optional normalized response tool-call payloads. + finish_reason: Optional normalized finish reason. + usage: Optional normalized usage accounting. + api_specific: Optional API-specific response fields. + extra: Optional additional response fields. + + Returns: + ``None``. + + Exceptional flow: + Raises conversion errors when JSON-like inputs cannot be converted + to the native normalized representation. + """ + ... @property def id(self) -> Optional[str]: """Return the provider response identifier, if present.""" diff --git a/python/nemo_relay/integrations/langchain/_serialization.py b/python/nemo_relay/integrations/langchain/_serialization.py index 3e66d83f2..9618bee6f 100644 --- a/python/nemo_relay/integrations/langchain/_serialization.py +++ b/python/nemo_relay/integrations/langchain/_serialization.py @@ -20,7 +20,7 @@ ) from langgraph.types import Command, Send -from nemo_relay import AnnotatedLLMRequest, LLMRequest +from nemo_relay import AnnotatedLLMRequest, AnnotatedLLMResponse, LLMRequest from nemo_relay.codecs import LlmCodec if TYPE_CHECKING: @@ -32,6 +32,15 @@ "human": "user", "ai": "assistant", } +_FINISH_REASON_MAP = { + "stop": "complete", + "end_turn": "complete", + "tool_calls": "tool_use", + "tool_use": "tool_use", + "max_tokens": "length", + "length": "length", + "content_filter": "content_filter", +} def get_model_name(model: Any) -> str | None: @@ -194,6 +203,11 @@ def encode(self, annotated: AnnotatedLLMRequest, original: LLMRequest) -> LLMReq payload["tool_choice"] = annotated.tool_choice return LLMRequest(dict(original.headers), payload) + def decode_response(self, response: Any) -> AnnotatedLLMResponse: + """Decode a serialized LangChain ``ModelResponse`` for observability.""" + payload = _model_response_payload_from_json(response) + return _model_response_to_annotated(payload) + def split_system_message(messages: list[BaseMessage]) -> tuple[SystemMessage | None, list[BaseMessage]]: """Split a leading system message into LangChain agent ``ModelRequest`` shape.""" @@ -268,6 +282,17 @@ def _model_response_payload(response: ModelResponse[Any], codec: Any) -> dict[st return payload +def _model_response_payload_from_json(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict) or LANGCHAIN_MODEL_RESPONSE_KEY not in payload: + raise TypeError("expected serialized LangChain ModelResponse payload") + + response_payload = payload[LANGCHAIN_MODEL_RESPONSE_KEY] + if not isinstance(response_payload, dict): + raise TypeError("expected serialized LangChain ModelResponse object") + + return response_payload + + def _model_response_from_payload(payload: Any, codec: Any) -> ModelResponse[Any] | None: if not isinstance(payload, dict): return None @@ -292,6 +317,104 @@ def model_response_to_json(response: ModelResponse[Any], codec: Any) -> Any: } +def _message_content_text(message: BaseMessage) -> str | None: + content = message.content + if content is None: + return None + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + text = item.get("text", item.get("content")) + if isinstance(text, str): + parts.append(text) + return "\n".join(parts) if parts else None + return str(content) + + +def _message_finish_reason(message: BaseMessage) -> str | dict[str, str] | None: + metadata = getattr(message, "response_metadata", None) + if not isinstance(metadata, dict): + return None + for key in ("finish_reason", "stop_reason"): + value = metadata.get(key) + if isinstance(value, str) and value: + return _FINISH_REASON_MAP.get(value, {"unknown": value}) + return None + + +def _message_usage(message: BaseMessage) -> dict[str, Any] | None: + usage = getattr(message, "usage_metadata", None) + if not isinstance(usage, dict): + return None + + mapped: dict[str, Any] = {} + for source, target in ( + ("input_tokens", "prompt_tokens"), + ("output_tokens", "completion_tokens"), + ("total_tokens", "total_tokens"), + ): + value = usage.get(source) + if isinstance(value, int): + mapped[target] = value + + return mapped or None + + +def _message_model(message: BaseMessage) -> str | None: + metadata = getattr(message, "response_metadata", None) + if not isinstance(metadata, dict): + return None + for key in ("model_name", "model", "model_id"): + value = metadata.get(key) + if isinstance(value, str) and value: + return value + return None + + +def _message_response_tool_calls(message: BaseMessage) -> list[dict[str, Any]] | None: + if not isinstance(message, AIMessage): + return None + tool_calls = getattr(message, "tool_calls", []) + if not tool_calls: + return None + + return [ + { + "id": str(tool_call.get("id") or ""), + "name": str(tool_call["name"]), + "arguments": tool_call.get("args") or {}, + } + for tool_call in tool_calls + ] + + +def _model_response_to_annotated(payload: dict[str, Any]) -> AnnotatedLLMResponse: + raw_messages = payload.get("messages") + if not isinstance(raw_messages, list) or not raw_messages: + raise TypeError("expected serialized LangChain ModelResponse messages") + + messages = messages_from_dict(raw_messages) + message = next((item for item in reversed(messages) if isinstance(item, AIMessage)), messages[-1]) + extra = {} + if "structured_response" in payload: + extra["structured_response"] = payload["structured_response"] + + return AnnotatedLLMResponse( + id=getattr(message, "id", None), + model=_message_model(message), + message=_message_content_text(message), + tool_calls=_message_response_tool_calls(message), + finish_reason=_message_finish_reason(message), + usage=_message_usage(message), + extra=extra or None, + ) + + def model_response_from_json(payload: Any, codec: Any) -> ModelResponse[Any]: """Deserialize a ``ModelResponse`` serialized by ``best_effort_model_response_to_json``.""" if isinstance(payload, dict) and LANGCHAIN_MODEL_RESPONSE_KEY in payload: diff --git a/python/tests/integrations/deepagents_tests/test_deepagents_integration.py b/python/tests/integrations/deepagents_tests/test_deepagents_integration.py index ade20de8c..4bc13cecf 100644 --- a/python/tests/integrations/deepagents_tests/test_deepagents_integration.py +++ b/python/tests/integrations/deepagents_tests/test_deepagents_integration.py @@ -5,13 +5,20 @@ from __future__ import annotations +import asyncio +import http.server +import inspect +import json +import threading +import time import types from pathlib import Path -from typing import TYPE_CHECKING, Any, cast -from unittest.mock import MagicMock +from typing import TYPE_CHECKING, Any, TypedDict, cast +from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 import pytest +from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest import nemo_relay @@ -47,6 +54,124 @@ def bind_tools(self, _tools: Any, *_args: Any, **_kwargs: Any) -> _MockDeepAgent return _MockDeepAgentsChatModel(responses=responses) +class _CollectorRequest(TypedDict): + path: str + headers: dict[str, str] + body: bytes + + +class _OtelCollectorHandler(http.server.BaseHTTPRequestHandler): + def do_POST(self) -> None: + length = int(self.headers.get("content-length", "0")) + body = self.rfile.read(length) + server = cast("_OtelCollectorServer", self.server) + server.requests.append( + { + "path": self.path, + "headers": dict(self.headers.items()), + "body": body, + } + ) + server.request_event.set() + self.send_response(200) + self.end_headers() + + def log_message(self, format: str, *args: object) -> None: # noqa: ARG002 + return + + +class _OtelCollector: + server: "_OtelCollectorServer" + + def __enter__(self) -> "_OtelCollector": + self.server = _OtelCollectorServer(("127.0.0.1", 0), _OtelCollectorHandler) + self.server.requests = [] + self.server.request_event = threading.Event() + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=1) + + @property + def endpoint(self) -> str: + return f"http://127.0.0.1:{self.server.server_port}/v1/traces" + + @property + def body(self) -> bytes: + return b"".join(request["body"] for request in self.server.requests) + + def wait_for_request(self, timeout: float = 5.0) -> _CollectorRequest: + assert self.server.request_event.wait(timeout), "timed out waiting for OTLP request" + return self.server.requests[0] + + def wait_for_spans(self, timeout: float = 5.0) -> list[dict[str, Any]]: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + spans = _decode_otlp_spans(self.body) + if spans: + return spans + time.sleep(0.05) + raise AssertionError("timed out waiting for OTLP spans") + + +class _OtelCollectorServer(http.server.ThreadingHTTPServer): + requests: list[_CollectorRequest] + request_event: threading.Event + + +def _any_value_to_python(value: Any) -> Any: + value_kind = value.WhichOneof("value") + if value_kind is None: + return None + if value_kind == "array_value": + return [_any_value_to_python(item) for item in value.array_value.values] + if value_kind == "kvlist_value": + return {item.key: _any_value_to_python(item.value) for item in value.kvlist_value.values} + return getattr(value, value_kind) + + +def _decode_otlp_spans(body: bytes) -> list[dict[str, Any]]: + request = ExportTraceServiceRequest() + request.ParseFromString(body) + spans: list[dict[str, Any]] = [] + for resource_span in request.resource_spans: + for scope_span in resource_span.scope_spans: + spans.extend( + { + "name": span.name, + "attributes": { + attribute.key: _any_value_to_python(attribute.value) for attribute in span.attributes + }, + } + for span in scope_span.spans + ) + return spans + + +def _span_attrs_by_kind(spans: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]: + return [ + cast(dict[str, Any], span["attributes"]) + for span in spans + if span["attributes"].get("openinference.span.kind") == kind + ] + + +def _has_input_message(attrs: dict[str, Any], role: str, content: str) -> bool: + index = 0 + while f"llm.input_messages.{index}.message.role" in attrs: + if ( + attrs[f"llm.input_messages.{index}.message.role"] == role + and attrs.get(f"llm.input_messages.{index}.message.content") == content + ): + return True + index += 1 + return False + + def _filter_mark_events(events: list[nemo_relay.Event]) -> list[nemo_relay.MarkEvent]: return [event for event in events if isinstance(event, nemo_relay.MarkEvent)] @@ -84,6 +209,126 @@ def test_before_agent_emits_configuration_mark( assert _mark_data(marks[0])["backend"] == "StateBackend" +@pytest.mark.parametrize("use_async", [False, True]) +def test_model_call_routes_through_langchain_execution_middleware( + use_async: bool, + deepagents_integration_module: types.ModuleType, +): + from langchain.agents.middleware import ModelRequest, ModelResponse + from langchain_core.messages import AIMessage, HumanMessage + + from nemo_relay.integrations.langchain._serialization import LangChainCodec + + class _RecordingMiddleware(deepagents_integration_module.NemoRelayDeepAgentsMiddleware): + def __init__(self): + super().__init__() + self.calls: list[dict[str, Any]] = [] + + async def _llm_execute( + self, + model_name: str, + request: nemo_relay.LLMRequest, + codec: Any, + response_codec: Any, + func: Any, + ) -> Any: + self.calls.append( + { + "model_name": model_name, + "request": request, + "codec": codec, + "response_codec": response_codec, + } + ) + intercepted = nemo_relay.LLMRequest( + request.headers, + { + **request.content, + "model_settings": {"temperature": 0.25}, + }, + ) + return await func(intercepted) + + middleware = _RecordingMiddleware() + request = ModelRequest( + model=_mock_deepagents_chat_model([AIMessage(content="unused")]), + messages=[HumanMessage(content="hello")], + model_settings={"temperature": 1.0}, + ) + seen_request: dict[str, ModelRequest[Any]] = {} + + def handler(next_request: ModelRequest[Any]) -> ModelResponse[Any]: + seen_request["request"] = next_request + return ModelResponse(result=[AIMessage(content="done")]) + + async def async_handler(next_request: ModelRequest[Any]) -> ModelResponse[Any]: + return handler(next_request) + + if use_async: + response = asyncio.run(middleware.awrap_model_call(request, async_handler)) + else: + response = middleware.wrap_model_call(request, handler) + + assert response.result[0].content == "done" + assert seen_request["request"].model_settings == {"temperature": 0.25} + assert middleware.calls[0]["model_name"] == "mock-model" + assert isinstance(middleware.calls[0]["codec"], LangChainCodec) + assert middleware.calls[0]["response_codec"] is middleware.calls[0]["codec"] + + +@pytest.mark.parametrize("use_async", [False, True]) +def test_tool_call_routes_through_langchain_execution_middleware( + use_async: bool, + monkeypatch: pytest.MonkeyPatch, + deepagents_integration_module: types.ModuleType, +): + from langchain.agents.middleware import ToolCallRequest + from langchain_core.messages import ToolMessage + + parent_handle = MagicMock() + mock_tool_execute = AsyncMock() + + async def execute_side_effect(*, func: Any, **_kwargs: Any) -> ToolMessage: + result = func({"query": "intercepted"}) + if inspect.isawaitable(result): + return await result + return result + + mock_tool_execute.side_effect = execute_side_effect + monkeypatch.setattr(nemo_relay.scope, "get_handle", lambda: parent_handle) + monkeypatch.setattr(nemo_relay.typed, "tool_execute", mock_tool_execute) + + middleware = deepagents_integration_module.NemoRelayDeepAgentsMiddleware() + request = ToolCallRequest( + tool_call={"name": "lookup", "args": {"query": "original"}, "id": "call-1"}, + tool=None, + state={}, + runtime=MagicMock(), + ) + seen_request: dict[str, ToolCallRequest] = {} + + def handler(next_request: ToolCallRequest) -> ToolMessage: + seen_request["request"] = next_request + return ToolMessage(content="done", tool_call_id=next_request.tool_call["id"]) + + async def async_handler(next_request: ToolCallRequest) -> ToolMessage: + return handler(next_request) + + if use_async: + response = asyncio.run(middleware.awrap_tool_call(request, async_handler)) + else: + response = middleware.wrap_tool_call(request, handler) + + assert response.content == "done" + assert seen_request["request"].tool_call["args"] == {"query": "intercepted"} + mock_tool_execute.assert_awaited_once() + assert mock_tool_execute.await_args is not None + kwargs = mock_tool_execute.await_args.kwargs + assert kwargs["name"] == "lookup" + assert kwargs["args"] == {"query": "original"} + assert kwargs["handle"] is parent_handle + + def test_callback_handler_emits_human_in_the_loop_marks( subscribed_events: list[nemo_relay.Event], callback_handler: deepagents_integration.NemoRelayDeepAgentsCallbackHandler, @@ -194,7 +439,9 @@ def test_add_nemo_relay_integration_preserves_backend(deepagents_integration_mod assert kwargs["subagents"][1] is mock_compiled_subagent +@pytest.mark.parametrize("use_async", [False, True]) def test_e2e_agent( + use_async: bool, tmp_path: Path, subscribed_events: list[nemo_relay.Event], deepagents_integration_module: types.ModuleType, @@ -256,7 +503,11 @@ def test_e2e_agent( agent = create_deep_agent(**kwargs) with nemo_relay.scope.scope("deepagents-request", nemo_relay.ScopeType.Agent): - result = agent.invoke({"messages": [{"role": "user", "content": "Create a file named turtle."}]}) + input_payload = {"messages": [{"role": "user", "content": "Create a file named turtle."}]} + if use_async: + result = asyncio.run(agent.ainvoke(input_payload)) + else: + result = agent.invoke(input_payload) nemo_relay.subscribers.flush() assert (tmp_path / "turtle").read_text() == "shell" @@ -297,3 +548,103 @@ def test_e2e_agent( event_strings = [f"{event.kind}.{getattr(event, 'scope_category', '')}.{event.name}" for event in subscribed_events] assert event_strings == expected_events + + +def test_e2e_agent_exports_openinference_output_contract( + tmp_path: Path, + deepagents_integration_module: types.ModuleType, +): + from deepagents import create_deep_agent + from deepagents.backends import LocalShellBackend + from langchain_core.messages import AIMessage + + events: list[nemo_relay.Event] = [] + model = _mock_deepagents_chat_model( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "name": "write_file", + "args": {"file_path": "/turtle", "content": "shell"}, + "id": "call-1", + } + ], + ), + AIMessage(content="created turtle"), + ] + ) + kwargs = deepagents_integration_module.add_nemo_relay_integration( + model=model, + tools=[], + name="main-agent", + backend=LocalShellBackend(root_dir=tmp_path, virtual_mode=True), + ) + agent = create_deep_agent(**kwargs) + + with _OtelCollector() as collector: + config = nemo_relay.OpenInferenceConfig() + config.endpoint = collector.endpoint + config.service_name = "deepagents-test" + subscriber = nemo_relay.OpenInferenceSubscriber(config) + subscriber_name = f"deepagents_openinference_{uuid4().hex}" + event_recorder_name = f"deepagents_events_{uuid4().hex}" + subscriber.register(subscriber_name) + nemo_relay.subscribers.register(event_recorder_name, events.append) + try: + with nemo_relay.scope.scope("deepagents-request", nemo_relay.ScopeType.Agent): + agent.invoke({"messages": [{"role": "user", "content": "Create a file named turtle."}]}) + + nemo_relay.subscribers.flush() + subscriber.force_flush() + spans = collector.wait_for_spans() + finally: + nemo_relay.subscribers.deregister(event_recorder_name) + subscriber.deregister(subscriber_name) + subscriber.shutdown() + + llm_end_events = [ + event + for event in events + if isinstance(event, nemo_relay.ScopeEvent) and event.category == "llm" and event.scope_category == "end" + ] + assert len(llm_end_events) == 2 + assert all(event.annotated_response is not None for event in llm_end_events) + first_response = llm_end_events[0].annotated_response + final_response = llm_end_events[1].annotated_response + assert first_response is not None + assert final_response is not None + assert first_response.tool_calls == [ + { + "id": "call-1", + "name": "write_file", + "arguments": {"content": "shell", "file_path": "/turtle"}, + } + ] + assert final_response.response_text() == "created turtle" + + agent_spans = _span_attrs_by_kind(spans, "AGENT") + llm_spans = _span_attrs_by_kind(spans, "LLM") + tool_spans = _span_attrs_by_kind(spans, "TOOL") + assert len(agent_spans) == 1 + assert len(llm_spans) == 2 + assert len(tool_spans) == 1 + + first_llm_span = next( + span for span in llm_spans if "llm.output_messages.0.message.tool_calls.0.tool_call.function.name" in span + ) + final_llm_span = next(span for span in llm_spans if span.get("llm.output_messages.0.message.content")) + tool_span = tool_spans[0] + expected_tool_args = {"content": "shell", "file_path": "/turtle"} + assert first_llm_span["llm.input_messages.0.message.role"] == "system" + assert _has_input_message(first_llm_span, "user", "Create a file named turtle.") + assert first_llm_span["llm.output_messages.0.message.role"] == "assistant" + assert first_llm_span["llm.output_messages.0.message.tool_calls.0.tool_call.id"] == "call-1" + assert first_llm_span["llm.output_messages.0.message.tool_calls.0.tool_call.function.name"] == "write_file" + first_llm_args = json.loads( + first_llm_span["llm.output_messages.0.message.tool_calls.0.tool_call.function.arguments"] + ) + assert first_llm_args == expected_tool_args + assert final_llm_span["llm.output_messages.0.message.content"] == "created turtle" + assert json.loads(tool_span["tool.parameters"]) == expected_tool_args + assert json.loads(tool_span["tool_call.function.arguments"]) == expected_tool_args diff --git a/python/tests/integrations/langchain_tests/test_middleware.py b/python/tests/integrations/langchain_tests/test_middleware.py index 048b90694..7d2c7813c 100644 --- a/python/tests/integrations/langchain_tests/test_middleware.py +++ b/python/tests/integrations/langchain_tests/test_middleware.py @@ -244,6 +244,60 @@ def test_langchain_model_request_codec_round_trips_messages(model_request: Model assert round_tripped.messages[0].content == "hello from intercept" +def test_langchain_model_response_codec_decodes_text_and_tool_calls(): + from langchain.agents.middleware import ModelResponse + from langchain_core.messages import AIMessage + + from nemo_relay import AnnotatedLLMResponse + from nemo_relay.integrations.langchain._serialization import LangChainCodec, model_response_to_json + + codec = LangChainCodec() + response = ModelResponse( + result=[ + AIMessage( + content="I will search docs.", + tool_calls=[ + { + "name": "search_docs", + "args": {"query": "Deep Agents"}, + "id": "call-search-docs", + } + ], + response_metadata={"finish_reason": "tool_calls", "model_name": "mock-model"}, + usage_metadata={"input_tokens": 11, "output_tokens": 7, "total_tokens": 18}, + ) + ] + ) + + annotated = codec.decode_response(model_response_to_json(response, nemo_relay.typed.BestEffortAnyCodec())) + + assert isinstance(annotated, AnnotatedLLMResponse) + assert annotated.model == "mock-model" + assert annotated.response_text() == "I will search docs." + assert annotated.finish_reason == "tool_use" + assert annotated.usage == {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18} + assert annotated.tool_calls == [ + { + "id": "call-search-docs", + "name": "search_docs", + "arguments": {"query": "Deep Agents"}, + } + ] + + unknown_response = ModelResponse( + result=[ + AIMessage( + content="done", + response_metadata={"finish_reason": "provider_custom_stop"}, + ) + ] + ) + unknown_annotated = codec.decode_response( + model_response_to_json(unknown_response, nemo_relay.typed.BestEffortAnyCodec()) + ) + assert unknown_annotated.finish_reason == "provider_custom_stop" + + @pytest.mark.parametrize("use_async", [False, True]) def test_model_call_applies_annotated_llm_request_intercept( use_async: bool, diff --git a/python/tests/test_builtin_codecs.py b/python/tests/test_builtin_codecs.py index a88827244..96fc0b2ab 100644 --- a/python/tests/test_builtin_codecs.py +++ b/python/tests/test_builtin_codecs.py @@ -122,6 +122,54 @@ def test_openai_chat_encode(self): class TestBuiltinCodecDecodeResponse: + def test_annotated_response_constructable_for_custom_codecs(self): + """AnnotatedLLMResponse() lets Python response codecs return normalized responses.""" + annotated = AnnotatedLLMResponse( + id="langchain-response-1", + model="mock-model", + message="I will search docs.", + tool_calls=[ + { + "id": "call-search-docs", + "name": "search_docs", + "arguments": {"query": "Deep Agents"}, + } + ], + finish_reason="tool_use", + usage={"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18}, + api_specific={"api": "custom", "api_name": "provider", "data": {"id": "raw"}}, + extra={"framework": "langchain"}, + ) + + assert annotated.id == "langchain-response-1" + assert annotated.model == "mock-model" + assert annotated.response_text() == "I will search docs." + assert annotated.tool_calls == [ + { + "id": "call-search-docs", + "name": "search_docs", + "arguments": {"query": "Deep Agents"}, + } + ] + assert annotated.finish_reason == "tool_use" + assert annotated.usage == {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18} + assert annotated.api_specific == {"api": "custom", "api_name": "provider", "data": {"id": "raw"}} + assert annotated.extra == {"framework": "langchain"} + + def test_annotated_response_exposes_unknown_finish_reason(self): + """Unknown native finish reasons are still visible to Python callers.""" + annotated = AnnotatedLLMResponse( + message="done", + finish_reason="provider_custom_stop", + ) + annotated_from_native_shape = AnnotatedLLMResponse( + message="done", + finish_reason={"unknown": "provider_custom_stop"}, + ) + + assert annotated.finish_reason == "provider_custom_stop" + assert annotated_from_native_shape.finish_reason == "provider_custom_stop" + def test_openai_chat_decode_response(self): """OpenAIChatCodec.decode_response() returns AnnotatedLLMResponse.""" codec = OpenAIChatCodec() @@ -386,6 +434,7 @@ async def mock_llm(req): mock_llm, response_codec=codec, ) + subscribers.flush() # Find LLMEnd event end_events = [ @@ -418,6 +467,7 @@ async def mock_llm(req): return {"result": "ok"} await llm.execute("test-llm", request, mock_llm) + subscribers.flush() end_events = [ e for e in captured_events if e.kind == "scope" and e.category == "llm" and e.scope_category == "end" diff --git a/python/tests/test_llm.py b/python/tests/test_llm.py index cac8f0915..49aa8f070 100644 --- a/python/tests/test_llm.py +++ b/python/tests/test_llm.py @@ -641,7 +641,10 @@ async def gen(): chunks.append(chunk) assert chunks == [{"token": "hello"}] finally: - subscribers.deregister("py_llm_finalizer_fail_sub") + try: + subscribers.flush() + finally: + subscribers.deregister("py_llm_finalizer_fail_sub") end = _llm_event(events, "stream_finalizer_fail_llm", "end") assert end.data is None @@ -669,7 +672,10 @@ async def gen(): chunks.append(chunk) assert chunks == [{"token": "hello"}] finally: - subscribers.deregister("py_llm_finalizer_callable_fail_sub") + try: + subscribers.flush() + finally: + subscribers.deregister("py_llm_finalizer_callable_fail_sub") end = _llm_event(events, "stream_finalizer_callable_fail_llm", "end") assert end.data is None diff --git a/python/tests/test_scope_local.py b/python/tests/test_scope_local.py index 7e661b44c..0b4fcfe8d 100644 --- a/python/tests/test_scope_local.py +++ b/python/tests/test_scope_local.py @@ -96,6 +96,7 @@ def my_tool(args): scope_local.register_tool_sanitize_response(handle, "sl_resp_sanitizer", 1, response_sanitizer) scope_local.register_subscriber(handle, "sl_resp_sub", lambda e: events.append(e)) result = await tools.execute("resp_tool", {}, my_tool) + subscribers.flush() # Sanitize guardrails are observability-only: they do NOT modify the # result flowing through the execution pipeline. @@ -129,6 +130,7 @@ def my_tool(args): scope_local.register_tool_sanitize_request(handle, "sl_cleanup_guard", 1, sanitizer) scope_local.register_subscriber(handle, "sl_cleanup_sub", lambda e: events_inside.append(e)) await tools.execute("tool_inside", {"x": 1}, my_tool) + subscribers.flush() # Verify the sanitizer ran inside the scope (visible in event input). start_inside = _scope_event(events_inside, "tool_inside", "tool", "start") @@ -139,7 +141,10 @@ def my_tool(args): events_outside = [] subscribers.register("sl_cleanup_outer_sub", lambda e: events_outside.append(e)) await tools.execute("tool_outside", {"x": 2}, my_tool) - subscribers.deregister("sl_cleanup_outer_sub") + try: + subscribers.flush() + finally: + subscribers.deregister("sl_cleanup_outer_sub") start_outside = _scope_event(events_outside, "tool_outside", "tool", "start") assert "sanitized" not in _event_data_object(start_outside) @@ -301,6 +306,7 @@ def test_subscriber_deregister_within_scope(self): events_before = len(events) tool_handle = tools.call("dereg_tool", {}) tools.call_end(tool_handle, {}) + subscribers.flush() # No new events should have been appended after deregistration assert len(events) == events_before @@ -428,11 +434,13 @@ def my_tool(args): scope_local.register_tool_request(handle_a, "sl_iso_int", 1, False, intercept_fn) scope_local.register_subscriber(handle_a, "sl_iso_sub_a", lambda e: events_a.append(e)) result_a = await tools.execute("iso_tool_a", {"val": 1}, my_tool) + subscribers.flush() # Scope B: only a subscriber, no intercept with scope.scope("iso_scope_b", ScopeType.Agent) as handle_b: scope_local.register_subscriber(handle_b, "sl_iso_sub_b", lambda e: events_b.append(e)) result_b = await tools.execute("iso_tool_b", {"val": 2}, my_tool) + subscribers.flush() # Scope A should have had the intercept applied assert result_a["intercepted"] is True @@ -540,6 +548,7 @@ def my_tool(args): scope_local.register_tool_sanitize_request(handle, "sl_dereg_guard", 1, sanitizer) scope_local.register_subscriber(handle, "sl_dereg_sub", lambda e: events.append(e)) await tools.execute("dereg_tool_1", {"a": 1}, my_tool) + subscribers.flush() # Verify the sanitizer ran (visible in event input). start_before = _scope_event(events, "dereg_tool_1", "tool", "start") @@ -551,6 +560,7 @@ def my_tool(args): events.clear() await tools.execute("dereg_tool_2", {"a": 2}, my_tool) + subscribers.flush() # After deregistration, the sanitizer should no longer appear in events. start_after = _scope_event(events, "dereg_tool_2", "tool", "start") @@ -625,6 +635,7 @@ def sanitize_request(req): scope_local.register_subscriber(handle, "sl_llm_sanitize_sub", lambda event: events.append(event)) scope_local.register_llm_sanitize_request(handle, "sl_llm_sanitize", 1, sanitize_request) result = await llm.execute("sl_llm_sanitize_call", request, lambda req: {"model": req.content["model"]}) + subscribers.flush() assert result == {"model": "scope-local"} start = _scope_event(events, "sl_llm_sanitize_call", "llm", "start") diff --git a/python/tests/test_tools.py b/python/tests/test_tools.py index 87a63d2ca..4fe9f8f2c 100644 --- a/python/tests/test_tools.py +++ b/python/tests/test_tools.py @@ -133,7 +133,10 @@ def failing(args): with pytest.raises(RuntimeError, match="boom"): await tools.execute("failing_tool", {"x": 1}, failing) - subscribers.deregister("py_tool_exec_failure_sub") + try: + subscribers.flush() + finally: + subscribers.deregister("py_tool_exec_failure_sub") assert [e.kind for e in events] == ["scope", "scope"] assert all(isinstance(event, ScopeEvent) for event in events) diff --git a/uv.lock b/uv.lock index cf679b659..148595ba3 100644 --- a/uv.lock +++ b/uv.lock @@ -1357,6 +1357,7 @@ dev = [ { name = "uv" }, ] test = [ + { name = "opentelemetry-proto" }, { name = "pydantic" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1390,6 +1391,7 @@ dev = [ { name = "uv", specifier = "~=0.10.0" }, ] test = [ + { name = "opentelemetry-proto", specifier = ">=1.39,<2" }, { name = "pydantic", specifier = ">=2" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-asyncio", specifier = ">=0.26" }, @@ -1405,6 +1407,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "opentelemetry-proto" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, +] + [[package]] name = "orjson" version = "3.11.9" @@ -1720,6 +1734,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0"