diff --git a/agents/langgraph/templates/agentic_rag/.env.example b/agents/langgraph/templates/agentic_rag/.env.example index ccf2e39e..9e34612b 100644 --- a/agents/langgraph/templates/agentic_rag/.env.example +++ b/agents/langgraph/templates/agentic_rag/.env.example @@ -6,6 +6,11 @@ MODEL_ID=ollama/Llama3.1:8B # Deployment CONTAINER_IMAGE= +# Auth (local default off; comma-separate multiple allowlist entries) +# AUTH_ENABLED=false +# AUTH_AUDIENCE=langgraph-agentic-rag +# AUTH_ALLOWED_SERVICEACCOUNTS=ci-testing:langgraph-agentic-rag-caller + # RAG-specific Configuration EMBEDDING_MODEL= EMBEDDING_DIMENSION=768 diff --git a/agents/langgraph/templates/agentic_rag/Dockerfile b/agents/langgraph/templates/agentic_rag/Dockerfile index b4f1c471..8b599410 100644 --- a/agents/langgraph/templates/agentic_rag/Dockerfile +++ b/agents/langgraph/templates/agentic_rag/Dockerfile @@ -15,9 +15,12 @@ COPY --from=ghcr.io/astral-sh/uv@sha256:fc93e9ecd7218e9ec8fba117af89348eef8fd246 # Copy project files for dependency installation COPY pyproject.toml . COPY src/ ./src/ +COPY components/auth/ ./components/auth/ -# Install the project and its dependencies using uv -RUN uv pip install --no-cache ".[tracing]" +# Install auth component first, then the project (--no-sources skips [tool.uv.sources] +# which uses relative paths valid only for local dev) +RUN uv pip install --no-cache ./components/auth/ && \ + uv pip install --no-cache --no-sources ".[tracing]" # Copy the application entrypoint, playground UI, data, and images COPY main.py . diff --git a/agents/langgraph/templates/agentic_rag/Makefile b/agents/langgraph/templates/agentic_rag/Makefile index 8b52dbf9..1c812180 100644 --- a/agents/langgraph/templates/agentic_rag/Makefile +++ b/agents/langgraph/templates/agentic_rag/Makefile @@ -95,7 +95,10 @@ build: ## Build container image locally (podman/docker) @[ -n "$(CONTAINER_CLI)" ] || { echo "ERROR: neither podman nor docker found in PATH"; exit 1; } && \ source .env && \ [ -n "$${CONTAINER_IMAGE}" ] || { echo "ERROR: CONTAINER_IMAGE is not set in .env"; exit 1; } && \ - rm -rf ./images && cp -r ../../../../images ./images && trap 'rm -rf ./images' EXIT && \ + rm -rf ./images ./components/auth && \ + trap 'rm -rf ./images ./components/auth; rmdir ./components 2>/dev/null || true' EXIT && \ + cp -r ../../../../images ./images && \ + mkdir -p ./components && cp -r ../../../../components/auth ./components/auth && \ $(CONTAINER_CLI) build --platform linux/amd64 -t "$${CONTAINER_IMAGE}" -f Dockerfile . push: ## Push container image to registry @@ -105,7 +108,10 @@ push: ## Push container image to registry $(CONTAINER_CLI) push "$${CONTAINER_IMAGE}" build-openshift: ## Build image in-cluster via OpenShift BuildConfig (no podman/docker needed) - @rm -rf ./images && cp -r ../../../../images ./images && trap 'rm -rf ./images' EXIT && \ + @rm -rf ./images ./components/auth && \ + trap 'rm -rf ./images ./components/auth; rmdir ./components 2>/dev/null || true' EXIT && \ + cp -r ../../../../images ./images && \ + mkdir -p ./components && cp -r ../../../../components/auth ./components/auth && \ oc new-build --strategy=docker --binary --name=$(AGENT_NAME) --to=$(AGENT_NAME):latest 2>/dev/null || true && \ oc start-build $(AGENT_NAME) --from-dir=. --follow && \ NS=$$(oc project -q) && \ @@ -124,6 +130,17 @@ _check-env: deploy: _check-env ## Deploy to OpenShift/K8s via Helm @source .env && \ + AUTH_ALLOWLIST="$${AUTH_ALLOWED_SERVICEACCOUNTS:-$${AUTH_ALLOWED_SERVICEACCOUNT}}" && \ + AUTH_ALLOWLIST_ARGS="" && \ + if [ -n "$$AUTH_ALLOWLIST" ]; then \ + IFS=',' read -r -a AUTH_ALLOWLIST_ITEMS <<< "$$AUTH_ALLOWLIST"; \ + for idx in "$${!AUTH_ALLOWLIST_ITEMS[@]}"; do \ + account="$${AUTH_ALLOWLIST_ITEMS[$$idx]}"; \ + account="$$(echo -n "$$account" | tr -d '[:space:]')"; \ + [ -n "$$account" ] || continue; \ + AUTH_ALLOWLIST_ARGS="$$AUTH_ALLOWLIST_ARGS --set-string auth.allowedServiceAccounts[$$idx]=$$account"; \ + done; \ + fi && \ [ -n "$${CONTAINER_IMAGE}" ] || { echo "ERROR: CONTAINER_IMAGE is not set in .env"; exit 1; } && \ LAST_SEG="$${CONTAINER_IMAGE##*/}" && if [[ "$$LAST_SEG" == *:* ]]; then IMAGE_REPO="$${CONTAINER_IMAGE%:*}"; IMAGE_TAG="$${LAST_SEG##*:}"; else IMAGE_REPO="$${CONTAINER_IMAGE}"; IMAGE_TAG="latest"; fi && \ trap 'rm -f .helm-secrets.yaml' EXIT && \ @@ -138,6 +155,11 @@ deploy: _check-env ## Deploy to OpenShift/K8s via Helm --set image.tag="$${IMAGE_TAG}" \ --set env.BASE_URL="$${BASE_URL}" \ --set env.MODEL_ID="$${MODEL_ID}" \ + $${AUTH_ENABLED:+--set "auth.enabled=$${AUTH_ENABLED}"} \ + $${AUTH_ENABLED:+--set "serviceAccount.create=$${AUTH_ENABLED}"} \ + $${AUTH_ENABLED:+--set "auth.createAuthDelegatorBinding=$${AUTH_ENABLED}"} \ + $${AUTH_AUDIENCE:+--set-string "auth.audience=$${AUTH_AUDIENCE}"} \ + $${AUTH_ALLOWLIST_ARGS} \ $${EMBEDDING_MODEL:+--set env.EMBEDDING_MODEL="$${EMBEDDING_MODEL}"} \ $${EMBEDDING_DIMENSION:+--set env.EMBEDDING_DIMENSION="$${EMBEDDING_DIMENSION}"} \ $${VECTOR_STORE_ID:+--set env.VECTOR_STORE_ID="$${VECTOR_STORE_ID}"} \ @@ -160,6 +182,17 @@ deploy: _check-env ## Deploy to OpenShift/K8s via Helm dry-run: _check-env ## Render Helm templates without deploying @source .env && \ + AUTH_ALLOWLIST="$${AUTH_ALLOWED_SERVICEACCOUNTS:-$${AUTH_ALLOWED_SERVICEACCOUNT}}" && \ + AUTH_ALLOWLIST_ARGS="" && \ + if [ -n "$$AUTH_ALLOWLIST" ]; then \ + IFS=',' read -r -a AUTH_ALLOWLIST_ITEMS <<< "$$AUTH_ALLOWLIST"; \ + for idx in "$${!AUTH_ALLOWLIST_ITEMS[@]}"; do \ + account="$${AUTH_ALLOWLIST_ITEMS[$$idx]}"; \ + account="$$(echo -n "$$account" | tr -d '[:space:]')"; \ + [ -n "$$account" ] || continue; \ + AUTH_ALLOWLIST_ARGS="$$AUTH_ALLOWLIST_ARGS --set-string auth.allowedServiceAccounts[$$idx]=$$account"; \ + done; \ + fi && \ [ -n "$${CONTAINER_IMAGE}" ] || { echo "ERROR: CONTAINER_IMAGE is not set in .env"; exit 1; } && \ LAST_SEG="$${CONTAINER_IMAGE##*/}" && if [[ "$$LAST_SEG" == *:* ]]; then IMAGE_REPO="$${CONTAINER_IMAGE%:*}"; IMAGE_TAG="$${LAST_SEG##*:}"; else IMAGE_REPO="$${CONTAINER_IMAGE}"; IMAGE_TAG="latest"; fi && \ helm template $(AGENT_NAME) $(CHART_DIR) \ @@ -169,6 +202,11 @@ dry-run: _check-env ## Render Helm templates without deploying --set image.tag="$${IMAGE_TAG}" \ --set env.BASE_URL="$${BASE_URL}" \ --set env.MODEL_ID="$${MODEL_ID}" \ + $${AUTH_ENABLED:+--set "auth.enabled=$${AUTH_ENABLED}"} \ + $${AUTH_ENABLED:+--set "serviceAccount.create=$${AUTH_ENABLED}"} \ + $${AUTH_ENABLED:+--set "auth.createAuthDelegatorBinding=$${AUTH_ENABLED}"} \ + $${AUTH_AUDIENCE:+--set-string "auth.audience=$${AUTH_AUDIENCE}"} \ + $${AUTH_ALLOWLIST_ARGS} \ $${EMBEDDING_MODEL:+--set env.EMBEDDING_MODEL="$${EMBEDDING_MODEL}"} \ $${EMBEDDING_DIMENSION:+--set env.EMBEDDING_DIMENSION="$${EMBEDDING_DIMENSION}"} \ $${VECTOR_STORE_ID:+--set env.VECTOR_STORE_ID="$${VECTOR_STORE_ID}"} \ diff --git a/agents/langgraph/templates/agentic_rag/README.md b/agents/langgraph/templates/agentic_rag/README.md index 0fc9008e..719cb459 100644 --- a/agents/langgraph/templates/agentic_rag/README.md +++ b/agents/langgraph/templates/agentic_rag/README.md @@ -318,6 +318,130 @@ make undeploy See [OpenShift Deployment](../../../../docs/openshift-deployment.md) for more details. +## Authentication + +The agent supports optional native OpenShift authentication using Kubernetes +ServiceAccount tokens. When enabled, every request (except `/health`) must +carry a valid `Authorization: Bearer ` header. The token is verified +in-cluster via the Kubernetes `TokenReview` API. + +Authentication is **disabled by default**. + +### Prerequisites + +- The agent must be deployed on OpenShift (TokenReview requires in-cluster access) +- `helm` and `oc` CLI tools + +### Step 1 — Deploy the agent without auth + +Make sure the agent is running first: + +```bash +make build-openshift +# Set CONTAINER_IMAGE in .env to the value printed by the build +make deploy +``` + +Verify it works: + +```bash +ROUTE=$(oc get route langgraph-agentic-rag -o jsonpath='{.spec.host}') +curl -s https://$ROUTE/health +curl -s https://$ROUTE/chat/completions \ + -X POST -H "Content-Type: application/json" \ + -d '{"messages":[{"role":"user","content":"hello"}]}' +``` + +### Step 2 — Create a caller ServiceAccount + +Create a ServiceAccount that will act as the authorized client: + +```bash +oc create serviceaccount my-caller +``` + +### Step 3 — Enable auth + +Upgrade the Helm release with auth flags. Do **not** pass `-f values.yaml` here — +`--reuse-values` preserves the existing configuration from `make deploy`: + +```bash +helm upgrade langgraph-agentic-rag ../../deployment \ + --reuse-values \ + --set auth.enabled=true \ + --set auth.audience="langgraph-agentic-rag" \ + --set-string "auth.allowedServiceAccounts[0]=:my-caller" \ + --set serviceAccount.create=true \ + --set auth.createAuthDelegatorBinding=true +``` + +Replace `` with your OpenShift project name +(e.g. `myproject:my-caller`). + +Wait for the rollout: + +```bash +oc rollout status deployment/langgraph-agentic-rag +``` + +### Step 4 — Test auth enforcement + +```bash +ROUTE=$(oc get route langgraph-agentic-rag -o jsonpath='{.spec.host}') + +# 1. Health endpoint — always open (excluded from auth) +curl -s https://$ROUTE/health +# Expected: 200 {"status": "healthy", ...} + +# 2. Without token — should return 401 +curl -s https://$ROUTE/chat/completions \ + -X POST -H "Content-Type: application/json" \ + -d '{"messages":[{"role":"user","content":"hello"}]}' +# Expected: 401 {"detail": "Missing Bearer token"} + +# 3. With a valid SA token — should return 200 +TOKEN=$(oc create token my-caller --audience=langgraph-agentic-rag) +curl -s https://$ROUTE/chat/completions \ + -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"messages":[{"role":"user","content":"hello"}]}' +# Expected: 200 with assistant response +``` + +### Step 5 — Disable auth if needed + +Disable auth and confirm the agent works without tokens: + +```bash +helm upgrade langgraph-agentic-rag ../../deployment \ + --reuse-values \ + --set auth.enabled=false +``` + +### Auth configuration reference + +| Helm value | Description | +|---|---| +| `auth.enabled` | Enable/disable auth (`false` by default) | +| `auth.audience` | Token audience the agent expects (e.g. `langgraph-agentic-rag`) | +| `auth.allowedServiceAccounts` | Comma-separated list of `namespace:sa-name` pairs allowed to call the agent | +| `serviceAccount.create` | Create a ServiceAccount for the agent pod | +| `auth.createAuthDelegatorBinding` | Grant the agent's SA permission to call the TokenReview API | + +### Local development with auth + +To test the auth middleware locally (outside the cluster): + +```bash +uv sync --extra auth +python -c "from agent_auth.middleware import SATokenAuthMiddleware; print('OK')" +``` + +> **Note:** The middleware calls the Kubernetes TokenReview API, so +> `AUTH_ENABLED=true` only works when running inside an OpenShift cluster. +> For local development, leave `AUTH_ENABLED` unset or set to `false`. + ## Tests ```bash diff --git a/agents/langgraph/templates/agentic_rag/main.py b/agents/langgraph/templates/agentic_rag/main.py index a51ab8c9..5ccc85a6 100644 --- a/agents/langgraph/templates/agentic_rag/main.py +++ b/agents/langgraph/templates/agentic_rag/main.py @@ -156,6 +156,28 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: ) +def _auth_enabled() -> bool: + return getenv("AUTH_ENABLED", "false").strip().lower() == "true" + + +def _configure_auth_middleware() -> None: + if not _auth_enabled(): + return + + try: + from agent_auth.middleware import SATokenAuthMiddleware + except ModuleNotFoundError as exc: + raise RuntimeError( + "AUTH_ENABLED=true, but auth middleware dependencies are not installed. " + "Run `uv sync --extra auth` to enable ServiceAccount token auth locally." + ) from exc + + app.add_middleware(SATokenAuthMiddleware) + + +_configure_auth_middleware() + + def _build_langchain_messages(messages: list[ChatMessage]) -> list[HumanMessage]: """Extract the last user message from the OpenAI-format messages list.""" for msg in reversed(messages): @@ -442,12 +464,16 @@ async def health(): @app.get("/", response_class=HTMLResponse, include_in_schema=False) async def playground(): """Serve the playground chat UI.""" + if _auth_enabled(): + raise HTTPException(status_code=404, detail="Not found") return FileResponse(_PLAYGROUND_HTML) @app.get("/images/{filename:path}", include_in_schema=False) async def serve_image(filename: str): """Serve images from the project-level images directory.""" + if _auth_enabled(): + raise HTTPException(status_code=404, detail="Not found") base = _IMAGES_DIR.resolve() file_path = (base / filename).resolve() try: diff --git a/agents/langgraph/templates/agentic_rag/pyproject.toml b/agents/langgraph/templates/agentic_rag/pyproject.toml index 0cabeb4f..64f7c603 100644 --- a/agents/langgraph/templates/agentic_rag/pyproject.toml +++ b/agents/langgraph/templates/agentic_rag/pyproject.toml @@ -26,6 +26,9 @@ dependencies = [ ] [project.optional-dependencies] +auth = [ + "agent-auth", +] dev = [ "pytest>=9.0.2", ] @@ -36,6 +39,9 @@ tracing = [ [tool.setuptools.packages.find] where = ["src"] +[tool.uv.sources] +agent-auth = { path = "../../../../components/auth" } + [build-system] requires = ["setuptools>=80.9.0"] build-backend = "setuptools.build_meta" diff --git a/agents/langgraph/templates/agentic_rag/uv.lock b/agents/langgraph/templates/agentic_rag/uv.lock index fc8bba3c..185a5513 100644 --- a/agents/langgraph/templates/agentic_rag/uv.lock +++ b/agents/langgraph/templates/agentic_rag/uv.lock @@ -6,6 +6,21 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[[package]] +name = "agent-auth" +version = "0.1.0" +source = { directory = "../../../../components/auth" } +dependencies = [ + { name = "kubernetes" }, + { name = "starlette" }, +] + +[package.metadata] +requires-dist = [ + { name = "kubernetes", specifier = ">=29.0" }, + { name = "starlette", specifier = ">=0.27.0" }, +] + [[package]] name = "agentic-rag" version = "0.2.0" @@ -31,6 +46,9 @@ dependencies = [ ] [package.optional-dependencies] +auth = [ + { name = "agent-auth" }, +] dev = [ { name = "pytest" }, ] @@ -40,6 +58,7 @@ tracing = [ [package.metadata] requires-dist = [ + { name = "agent-auth", marker = "extra == 'auth'", directory = "../../../../components/auth" }, { name = "chardet", specifier = "==7.2.0" }, { name = "fastapi", specifier = ">=0.135.1" }, { name = "flask", specifier = ">=3.1.0" }, @@ -60,7 +79,7 @@ requires-dist = [ { name = "starlette", specifier = ">=1.0.1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.41.0" }, ] -provides-extras = ["dev", "tracing"] +provides-extras = ["auth", "dev", "tracing"] [[package]] name = "aiohappyeyeballs" @@ -483,6 +502,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, ] +[[package]] +name = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, +] + [[package]] name = "fastapi" version = "0.136.1" @@ -1022,6 +1050,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, ] +[[package]] +name = "kubernetes" +version = "36.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "certifi" }, + { name = "durationpy" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/57/b07b96353f902aa1bdbe00e878e3a12a137977d03a962479785576aa8ec9/kubernetes-36.0.3.tar.gz", hash = "sha256:36993ed25ce59b789c9341473a228fcf268504a2fec7c2b2b1531d73072e5ce7", size = 2337528, upload-time = "2026-07-13T20:38:12.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/30/a96d47df739689ac0001ade0afefc16e3b477fc2fb426b568515fdc8afce/kubernetes-36.0.3-py2.py3-none-any.whl", hash = "sha256:8fde9241c4b298e6374a069dcf728359b4e72c2fb29489a975ba4e1c047cf10f", size = 4618066, upload-time = "2026-07-13T20:38:10.172Z" }, +] + [[package]] name = "langchain" version = "1.2.18" @@ -1557,6 +1606,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "ogx-client" version = "1.1.3" @@ -2261,6 +2319,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + [[package]] name = "requests-toolbelt" version = "1.0.0" @@ -2749,6 +2820,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, ] +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + [[package]] name = "websockets" version = "16.0"