diff --git a/agent/README.md b/agent/README.md index 5cd172de..b1e20100 100644 --- a/agent/README.md +++ b/agent/README.md @@ -19,3 +19,37 @@ Async agent loop with LiteLLM. | **`context_manager/`** | Manages conversation history, very rudimentary context engineering support | Implement intelligent context engineering to keep the agent on track | | **`config.py`** | Loads JSON config for the agent | Support different configs etc. | | **`main.py`** | Interactive CLI with async queue architecture (submission→agent, agent→events) (simple way to interact with the agent now)| Serve as reference implementation for other UIs (web, API, programmatic) | + +## Observability (optional) + +LLM calls can additionally be streamed to a [LangFuse](https://langfuse.com) +instance — useful for local development and for self-hosted deployments +that already run LangFuse / Phoenix / Langsmith. The primary +HF-Dataset-based telemetry pipeline (`agent/core/telemetry.py`) is unchanged. + +Set the LangFuse host plus both keys to opt in. Either env-var name for +the host works — Langfuse SDK v4 issues credentials as `LANGFUSE_BASE_URL`, +while litellm's callback reads `LANGFUSE_HOST`; this integration accepts +either and mirrors the value through to litellm: + +``` +LANGFUSE_BASE_URL=https://your-langfuse.example.com # or LANGFUSE_HOST=... +LANGFUSE_PUBLIC_KEY=pk-... +LANGFUSE_SECRET_KEY=sk-... +``` + +Both self-hosted LangFuse and the SaaS endpoint +(`https://cloud.langfuse.com`) are supported. The host is mandatory so the +destination is always an explicit choice — there is no silent fallback. +With any of the three vars unset the integration is a no-op. + +Install the optional dependency: + +``` +pip install ml-intern[observability] +``` + +**Privacy.** The callback ships the full prompt, tool calls, tool results, +and completions of every LLM turn to the configured host. Pick the +destination deliberately. See +https://github.com/huggingface/ml-intern/issues/196 for full details. diff --git a/agent/config.py b/agent/config.py index 5a6a8a45..d60012f7 100644 --- a/agent/config.py +++ b/agent/config.py @@ -6,6 +6,7 @@ from dotenv import load_dotenv +from agent.core.observability import setup_langfuse from agent.messaging.models import MessagingConfig # Project root: two levels up from this file (agent/config.py -> project root) @@ -207,4 +208,9 @@ def load_config( raw_config = apply_slack_user_defaults(raw_config) config_with_env = substitute_env_vars(raw_config) + + # Opt-in: register litellm's LangFuse callback if the operator set the + # three LANGFUSE_* env vars. No-op otherwise. See agent/core/observability.py. + setup_langfuse() + return Config.model_validate(config_with_env) diff --git a/agent/core/observability.py b/agent/core/observability.py new file mode 100644 index 00000000..b9495744 --- /dev/null +++ b/agent/core/observability.py @@ -0,0 +1,55 @@ +"""Opt-in third-party observability hooks for litellm calls. + +Today ml-intern's primary telemetry pipeline writes ``Event``s to a Hugging +Face Dataset (see ``agent/core/telemetry.py``). This module is a small, +opt-in side channel that lets operators also stream LLM traces to a +LangFuse instance (self-hosted or SaaS) via litellm's OTEL callback. + +Activation requires the LangFuse host plus both keys: either +``LANGFUSE_HOST`` or ``LANGFUSE_BASE_URL`` (the SDK v4 docs use the latter), +together with ``LANGFUSE_PUBLIC_KEY`` and ``LANGFUSE_SECRET_KEY``. With any +of them missing, this module is a no-op and behavior is identical to today. +The host is mandatory by design — see issue #196 for the privacy rationale. +""" + +from __future__ import annotations + +import logging +import os + +import litellm + +logger = logging.getLogger(__name__) + + +def setup_langfuse() -> None: + """Register litellm's LangFuse OTEL callback if host + keys are set. + + Accepts either ``LANGFUSE_HOST`` or ``LANGFUSE_BASE_URL`` for the host: + Langfuse SDK v4's docs issue credentials as ``LANGFUSE_BASE_URL``, but + litellm's callback only reads ``LANGFUSE_HOST`` — so we mirror the value + into ``LANGFUSE_HOST`` when only ``BASE_URL`` was set. + + Uses the ``langfuse_otel`` callback rather than the legacy ``langfuse`` + one because the legacy integration in current litellm releases breaks + against Langfuse SDK v4 (``module 'langfuse' has no attribute 'version'``). + The OTEL path works against both v3 and v4. + + Idempotent: ``load_config`` runs multiple times per process (CLI start + plus backend module-init), so guard against double-registration. + """ + host = os.getenv("LANGFUSE_HOST") or os.getenv("LANGFUSE_BASE_URL") + if not ( + host + and os.getenv("LANGFUSE_PUBLIC_KEY") + and os.getenv("LANGFUSE_SECRET_KEY") + ): + return + # litellm only reads LANGFUSE_HOST, so propagate the value if the + # operator set only LANGFUSE_BASE_URL. + os.environ.setdefault("LANGFUSE_HOST", host) + if "langfuse_otel" not in litellm.success_callback: + litellm.success_callback.append("langfuse_otel") + if "langfuse_otel" not in litellm.failure_callback: + litellm.failure_callback.append("langfuse_otel") + logger.info("LangFuse observability enabled (host=%s)", host) diff --git a/pyproject.toml b/pyproject.toml index c9773753..24d5aaf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,9 +46,14 @@ dev = [ "pytest-asyncio>=1.2.0", ] -# All dependencies (eval + dev) +# Optional third-party observability backends +observability = [ + "langfuse>=2.0.0", +] + +# All dependencies (eval + dev + observability) all = [ - "ml-intern[eval,dev]", + "ml-intern[eval,dev,observability]", ] [project.scripts] diff --git a/tests/unit/test_observability.py b/tests/unit/test_observability.py new file mode 100644 index 00000000..83a2fc53 --- /dev/null +++ b/tests/unit/test_observability.py @@ -0,0 +1,83 @@ +import os + +import litellm +import pytest + +from agent.core.observability import setup_langfuse + + +@pytest.fixture(autouse=True) +def _reset_litellm_callbacks(): + """Restore litellm callback lists around each test so they don't leak.""" + success_before = list(litellm.success_callback) + failure_before = list(litellm.failure_callback) + try: + yield + finally: + litellm.success_callback[:] = success_before + litellm.failure_callback[:] = failure_before + + +def _set_all_vars(monkeypatch): + monkeypatch.setenv("LANGFUSE_HOST", "https://langfuse.example.com") + monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test") + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test") + monkeypatch.delenv("LANGFUSE_BASE_URL", raising=False) + + +def test_setup_langfuse_registers_callbacks_when_all_vars_set(monkeypatch): + _set_all_vars(monkeypatch) + + setup_langfuse() + + assert "langfuse_otel" in litellm.success_callback + assert "langfuse_otel" in litellm.failure_callback + + +def test_setup_langfuse_accepts_base_url_alias(monkeypatch): + monkeypatch.delenv("LANGFUSE_HOST", raising=False) + monkeypatch.setenv("LANGFUSE_BASE_URL", "https://langfuse.example.com") + monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test") + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test") + + setup_langfuse() + + assert "langfuse_otel" in litellm.success_callback + # litellm only reads LANGFUSE_HOST; the alias must be mirrored into it. + assert os.environ["LANGFUSE_HOST"] == "https://langfuse.example.com" + + +def test_setup_langfuse_host_takes_precedence_over_base_url(monkeypatch): + monkeypatch.setenv("LANGFUSE_HOST", "https://from-host.example.com") + monkeypatch.setenv("LANGFUSE_BASE_URL", "https://from-base-url.example.com") + monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test") + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test") + + setup_langfuse() + + assert os.environ["LANGFUSE_HOST"] == "https://from-host.example.com" + + +@pytest.mark.parametrize( + "missing", ["LANGFUSE_HOST", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"] +) +def test_setup_langfuse_is_noop_when_any_var_missing(monkeypatch, missing): + _set_all_vars(monkeypatch) + monkeypatch.delenv(missing, raising=False) + success_before = list(litellm.success_callback) + failure_before = list(litellm.failure_callback) + + setup_langfuse() + + assert litellm.success_callback == success_before + assert litellm.failure_callback == failure_before + + +def test_setup_langfuse_is_idempotent(monkeypatch): + _set_all_vars(monkeypatch) + + setup_langfuse() + setup_langfuse() + + assert litellm.success_callback.count("langfuse_otel") == 1 + assert litellm.failure_callback.count("langfuse_otel") == 1 diff --git a/uv.lock b/uv.lock index 73df668c..272eaf56 100644 --- a/uv.lock +++ b/uv.lock @@ -261,6 +261,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" }, ] +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -995,6 +1004,18 @@ http = [ { name = "aiohttp" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1588,6 +1609,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] +[[package]] +name = "langfuse" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/bd/9b12c9dd3ae1883619b20daa6d60f20a780ce2d25564d9b2168db27cbeb0/langfuse-4.5.1.tar.gz", hash = "sha256:fe8f9219f4101c0921934b0aeb1b45834f8e7d248e5f830b2c89c5b40aea6d83", size = 279735, upload-time = "2026-04-24T15:21:43.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/63/77bd7220dfd60885a272a851f780b3f83e0f653ee3a852347552c3e24a28/langfuse-4.5.1-py3-none-any.whl", hash = "sha256:5923cafe8289c9e3c53cb6992f4b46ec3132473b9f9eb65eb33ad28e2682db81", size = 479527, upload-time = "2026-04-24T15:21:45.568Z" }, +] + [[package]] name = "linkify-it-py" version = "2.0.3" @@ -1800,6 +1840,7 @@ dependencies = [ all = [ { name = "datasets" }, { name = "inspect-ai" }, + { name = "langfuse" }, { name = "pandas" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1815,6 +1856,9 @@ eval = [ { name = "pandas" }, { name = "tenacity" }, ] +observability = [ + { name = "langfuse" }, +] [package.metadata] requires-dist = [ @@ -1827,8 +1871,9 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "huggingface-hub", specifier = ">=1.12.0" }, { name = "inspect-ai", marker = "extra == 'eval'", specifier = ">=0.3.149" }, + { name = "langfuse", marker = "extra == 'observability'", specifier = ">=2.0.0" }, { name = "litellm", specifier = ">=1.83.0" }, - { name = "ml-intern", extras = ["eval", "dev"], marker = "extra == 'all'" }, + { name = "ml-intern", extras = ["eval", "dev", "observability"], marker = "extra == 'all'" }, { name = "nbconvert", specifier = ">=7.16.6" }, { name = "nbformat", specifier = ">=5.10.4" }, { name = "pandas", marker = "extra == 'eval'", specifier = ">=2.3.3" }, @@ -1846,7 +1891,7 @@ requires-dist = [ { name = "websockets", specifier = ">=13.0" }, { name = "whoosh", specifier = ">=2.7.4" }, ] -provides-extras = ["eval", "dev", "all"] +provides-extras = ["eval", "dev", "observability", "all"] [[package]] name = "mmh3" @@ -2279,6 +2324,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, ] +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c", size = 17772, upload-time = "2026-03-04T14:17:25.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960, upload-time = "2026-03-04T14:17:07.153Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -2507,6 +2621,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 = "psutil" version = "7.1.3"