From 64e6dc18803a06fe37012c488972d798dc7b95b9 Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Tue, 6 Oct 2026 16:27:41 -0300 Subject: [PATCH 01/14] feat(extensions): package Laya for managed Portal decisions --- .github/workflows/laya-service.yml | 39 + ods/config/extensions-catalog.json | 55 +- .../library/services/laya/.dockerignore | 6 + .../library/services/laya/Dockerfile | 18 + .../library/services/laya/README.md | 49 ++ .../library/services/laya/compose.yaml | 55 ++ .../library/services/laya/health.py | 7 + .../library/services/laya/manifest.yaml | 44 ++ .../services/laya/requirements-torch.lock | 5 + .../library/services/laya/requirements.lock | 715 ++++++++++++++++++ .../library/services/laya/server.py | 80 ++ ods/extensions/library/services/laya/setup.py | 38 + ods/extensions/library/services/laya/setup.sh | 4 + .../laya/tests/container-qualification.sh | 43 ++ .../laya/tests/lifecycle.integration.mjs | 38 + .../laya/tests/portal_adapter.integration.mjs | 44 ++ .../services/laya/tests/test_recipe.py | 52 ++ .../services/laya/tests/test_service.py | 101 +++ 18 files changed, 1392 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/laya-service.yml create mode 100644 ods/extensions/library/services/laya/.dockerignore create mode 100644 ods/extensions/library/services/laya/Dockerfile create mode 100644 ods/extensions/library/services/laya/README.md create mode 100644 ods/extensions/library/services/laya/compose.yaml create mode 100644 ods/extensions/library/services/laya/health.py create mode 100644 ods/extensions/library/services/laya/manifest.yaml create mode 100644 ods/extensions/library/services/laya/requirements-torch.lock create mode 100644 ods/extensions/library/services/laya/requirements.lock create mode 100644 ods/extensions/library/services/laya/server.py create mode 100644 ods/extensions/library/services/laya/setup.py create mode 100644 ods/extensions/library/services/laya/setup.sh create mode 100644 ods/extensions/library/services/laya/tests/container-qualification.sh create mode 100644 ods/extensions/library/services/laya/tests/lifecycle.integration.mjs create mode 100644 ods/extensions/library/services/laya/tests/portal_adapter.integration.mjs create mode 100644 ods/extensions/library/services/laya/tests/test_recipe.py create mode 100644 ods/extensions/library/services/laya/tests/test_service.py diff --git a/.github/workflows/laya-service.yml b/.github/workflows/laya-service.yml new file mode 100644 index 0000000000..1b21fc0009 --- /dev/null +++ b/.github/workflows/laya-service.yml @@ -0,0 +1,39 @@ +name: Laya service qualification + +on: + pull_request: + paths: + - 'ods/extensions/library/services/laya/**' + - 'ods/extensions/services/pixel-agent/plugin/laya*.mjs' + - '.github/workflows/laya-service.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: laya-service-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + container: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: '24' + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 + with: + python-version: '3.11' + - name: Install recipe validation dependencies + run: python -m pip install fastapi==0.142.2 PyYAML==6.0.3 jsonschema==4.25.1 + - name: Validate manifest, actual extension policy and owner settings + run: python -m unittest discover -s ods/extensions/library/services/laya/tests -p test_recipe.py -v + - name: Build and verify actual inference, managed activation and restart + run: bash ods/extensions/library/services/laya/tests/container-qualification.sh diff --git a/ods/config/extensions-catalog.json b/ods/config/extensions-catalog.json index d5b3986cb8..04dd99b50d 100644 --- a/ods/config/extensions-catalog.json +++ b/ods/config/extensions-catalog.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-10-06T14:59:46Z", + "generated_at": "2026-10-06T19:20:40Z", "schema_version": "1.0.0", "extensions": [ { @@ -5153,6 +5153,59 @@ "startup_timeout": 180, "configuration_scope": "declared-environment-keys" }, + { + "id": "laya", + "name": "Laya", + "description": "Local classification and scoring used by the selected Portal model during a task.", + "category": "optional", + "gpu_backends": [ + "all" + ], + "compose_file": "compose.yaml", + "depends_on": [], + "port": 8000, + "external_port_default": 8017, + "health_endpoint": "/ready", + "env_vars": [ + { + "key": "LAYA_PORT", + "description": "Local connection port", + "default": "8017", + "format": "integer", + "required": false + } + ], + "tags": [ + "portal", + "classification", + "local", + "multilingual" + ], + "features": [ + { + "id": "laya-portal", + "name": "Laya for Portal", + "description": "Classify and score text directly within Portal conversations.", + "icon": "Workflow", + "category": "ai", + "requirements": { + "services": [ + "laya" + ], + "vram_gb": 0 + }, + "enabled_services_all": [ + "laya" + ], + "setup_time": "First setup downloads the decision models", + "priority": 50, + "launch": { + "type": "none" + } + } + ], + "configuration_scope": "declared-environment-keys" + }, { "id": "librechat", "name": "LibreChat", diff --git a/ods/extensions/library/services/laya/.dockerignore b/ods/extensions/library/services/laya/.dockerignore new file mode 100644 index 0000000000..1371c7edc6 --- /dev/null +++ b/ods/extensions/library/services/laya/.dockerignore @@ -0,0 +1,6 @@ +* +!Dockerfile +!requirements.lock +!requirements-torch.lock +!server.py +!health.py diff --git a/ods/extensions/library/services/laya/Dockerfile b/ods/extensions/library/services/laya/Dockerfile new file mode 100644 index 0000000000..80a961911e --- /dev/null +++ b/ods/extensions/library/services/laya/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim-trixie@sha256:0dd364ba7e10242f07755449e3a3d0e35f9efd987952737b90def6709ab0c5ce +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_CACHE_DIR=1 \ + PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \ + USE_TF=0 USE_TORCH=1 TOKENIZERS_PARALLELISM=false \ + TORCH_DISABLE_NATIVE_JIT=1 HOME=/cache +COPY requirements.lock requirements-torch.lock /opt/ods-laya/ +RUN python -m pip install --only-binary=:all: --no-deps --require-hashes -r /opt/ods-laya/requirements.lock \ + && python -m pip install --only-binary=:all: --no-deps --require-hashes -r /opt/ods-laya/requirements-torch.lock \ + && python -m pip check \ + && python -c "import torch; assert torch.__version__ == '2.14.0+cpu'; assert torch.ones(2).sum().item() == 2" +COPY server.py health.py /opt/ods-laya/ +RUN mkdir /cache && chmod 1777 /cache +LABEL org.opencontainers.image.source="https://github.com/NandhaKishorM/laya" \ + org.opencontainers.image.version="0.3.28" \ + org.opencontainers.image.revision="a4a8921afebfd852bba0000475cfb6ab737a124c" +USER 1000:1000 +EXPOSE 8000 +CMD ["python", "/opt/ods-laya/server.py"] diff --git a/ods/extensions/library/services/laya/README.md b/ods/extensions/library/services/laya/README.md new file mode 100644 index 0000000000..ed4f87b092 --- /dev/null +++ b/ods/extensions/library/services/laya/README.md @@ -0,0 +1,49 @@ +# Laya for Portal + +Enable Laya from ODS Extensions. The selected Portal model gains an optional +`pixel_ods_laya` tool for text classification, ordinal scores and yes/no +probabilities. It uses decisions to continue your task and answer normally. +There is no separate chat UI, provider key or model-selection step. + +Requires the ODS core Portal Laya integration, Docker Compose and the ODS owner's +Node.js runtime. Windows runs the extension through WSL; Linux and macOS use the +same Docker recipe. The service uses CPU on all platforms, including machines +with GPUs. GPU acceleration is not part of this recipe. + +## Installation and storage + +The image pins Laya 0.3.28, its dependency wheels and its Python base image. +The upstream source revision is `a4a8921afebfd852bba0000475cfb6ab737a124c`. +Laya's `reviewed` revision policy selects its reviewed English, multilingual +and typed-decisions checkpoints. First start downloads these models; allow +several minutes and Internet access to the model registry. Subsequent starts +reuse the Docker cache volume. Ordinary decisions run locally. + +The container is limited to two CPUs and 8 GiB RAM. It verifies inference with +all three checkpoints before becoming ready. Readiness is evidence that the +models loaded and answered valid test questions, not an accuracy guarantee. + +The dedicated key is under `config/laya/`; Portal's private connection is at +`~/.config/ods/laya-portal.json`. Neither contains a Dashboard or provider key. +Setup preserves the key across restart and re-enable. Only its individual file +is mounted into the container, read-only. The HTTP port binds to localhost; +`LAYA_PORT` defaults to 8017 and can be configured in ODS. + +Model downloads live in the Compose-managed `laya-cache` volume. The cache has +separate directories for different owner UIDs, so a recreated WSL user does not +inherit unwritable files. The volume is reusable and contains model files, not +chat history. Disabling the extension preserves this cache. Removing Docker +volumes is a separate destructive operation. + +## Behavior + +The model sends relevant text explicitly; Laya cannot read paths, browse, inspect +images or create projects. It does not run on every message. Long sources must +be divided into meaningful sections; truncated responses are rejected. A +service failure returns a tool error and leaves the Portal model able to +continue with its other capabilities. Decisions never authorize actions. + +Development checks are in `tests/`. `portal_adapter.integration.mjs` requires a +running service and a private `LAYA_TEST_KEY_FILE`; it tests actual inference, +authentication and context handling through the Portal adapter. Platform and +whole-task qualification remain necessary before release. diff --git a/ods/extensions/library/services/laya/compose.yaml b/ods/extensions/library/services/laya/compose.yaml new file mode 100644 index 0000000000..b909a2b506 --- /dev/null +++ b/ods/extensions/library/services/laya/compose.yaml @@ -0,0 +1,55 @@ +services: + laya: + build: + context: . + dockerfile: Dockerfile + container_name: ods-laya + user: "${ODS_UID:-1000}:${ODS_GID:-1000}" + restart: unless-stopped + init: true + read_only: true + security_opt: [no-new-privileges:true] + cap_drop: [ALL] + ports: + - "127.0.0.1:${LAYA_PORT:-8017}:8000" + volumes: + - laya-cache:/cache:rw + - ./config/laya/api-key:/run/ods-laya/api-key:ro + tmpfs: + - /tmp:size=256m,mode=1777 + environment: + HF_HOME: /cache/huggingface-${ODS_UID:-1000} + LAYA_DEVICE: cpu + LAYA_REVISION: reviewed + LAYA_MODELS: english,multilingual,typed-decisions + LAYA_PRELOAD: '1' + LAYA_MAX_LOADED: '3' + LAYA_MAX_CONCURRENT: '1' + LAYA_MAX_BATCH_TOKENS: '16384' + LAYA_MAX_TOKEN_BUDGET: '8192' + LAYA_THREADS: '2' + LAYA_IDLE_UNLOAD_SECONDS: '300' + OMP_NUM_THREADS: '2' + HF_HUB_DISABLE_TELEMETRY: '1' + healthcheck: + test: [CMD, python, /opt/ods-laya/health.py] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10m + deploy: + resources: + limits: + cpus: '2.0' + memory: 8G + logging: + driver: json-file + options: + max-size: 10m + max-file: '3' + networks: [ods-network] +networks: + ods-network: + external: true +volumes: + laya-cache: {} diff --git a/ods/extensions/library/services/laya/health.py b/ods/extensions/library/services/laya/health.py new file mode 100644 index 0000000000..7b6369898b --- /dev/null +++ b/ods/extensions/library/services/laya/health.py @@ -0,0 +1,7 @@ +import json +import urllib.request + +with urllib.request.urlopen('http://127.0.0.1:8000/ready', timeout=3) as response: + value = json.load(response) +if value != {'status': 'ok', 'startupInferenceVerified': True}: + raise SystemExit('Laya startup inference has not been verified') diff --git a/ods/extensions/library/services/laya/manifest.yaml b/ods/extensions/library/services/laya/manifest.yaml new file mode 100644 index 0000000000..e597ca296a --- /dev/null +++ b/ods/extensions/library/services/laya/manifest.yaml @@ -0,0 +1,44 @@ +schema_version: ods.services.v1 +service: + id: laya + name: Laya + aliases: [] + container_name: ods-laya + host_env: LAYA_HOST + default_host: laya + port: 8000 + external_port_env: LAYA_PORT + external_port_default: 8017 + health: /ready + health_timeout: 5 + external_link: false + type: docker + gpu_backends: [all] + category: optional + compose_file: compose.yaml + depends_on: [] + setup_hook: setup.sh + hooks: + pre_start: setup.sh + description: Local classification and scoring used by the selected Portal model during a task. + env_vars: + - key: LAYA_PORT + description: Local connection port + default: '8017' + format: integer + required: false +features: + - id: laya-portal + name: Laya for Portal + description: Classify and score text directly within Portal conversations. + icon: Workflow + category: ai + requirements: + services: [laya] + vram_gb: 0 + enabled_services_all: [laya] + setup_time: First setup downloads the decision models + priority: 50 + launch: + type: none +tags: [portal, classification, local, multilingual] diff --git a/ods/extensions/library/services/laya/requirements-torch.lock b/ods/extensions/library/services/laya/requirements-torch.lock new file mode 100644 index 0000000000..a572d9798c --- /dev/null +++ b/ods/extensions/library/services/laya/requirements-torch.lock @@ -0,0 +1,5 @@ +# Official CPU wheels for Linux AMD64/ARM64, Python 3.11. +--index-url https://download.pytorch.org/whl/cpu +torch==2.14.0+cpu \ + --hash=sha256:08894195f84541edcbd09072e6c53b791d4cdd09f650b05473f35718a848fd7b \ + --hash=sha256:673dbf5c9bbadfffab7a386b6dd7a0c219f1408a328b7b4e86d0ae551cdafa42 diff --git a/ods/extensions/library/services/laya/requirements.lock b/ods/extensions/library/services/laya/requirements.lock new file mode 100644 index 0000000000..628cf636f6 --- /dev/null +++ b/ods/extensions/library/services/laya/requirements.lock @@ -0,0 +1,715 @@ +# Laya 0.3.28 CPU/HTTP stack; exact releases and published wheel hashes. +# Install with --no-deps --require-hashes, then run pip check. Torch is locked separately. + +annotated-doc==0.0.5 \ + --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 + +annotated-types==0.8.0 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + +anyio==4.15.1 \ + --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 + +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 + +click==8.5.0 \ + --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 + +colorama==0.4.6 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + +fastapi==0.142.2 \ + --hash=sha256:bd5f4d81f1e93a88bcd77caf4dfe3c2dbffc3805407a0007e9a114c18b3a670b + +filelock==4.0.12 \ + --hash=sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8 + +fsspec==2026.9.0 \ + --hash=sha256:8dd6e646e99ea382bd85f97a45e6b526a442d79423a7dc673f1e2756d05fcb5f + +h11==0.16.0 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + +hf-xet==1.6.0 \ + --hash=sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7 \ + --hash=sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675 \ + --hash=sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9 \ + --hash=sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3 \ + --hash=sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb \ + --hash=sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e \ + --hash=sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338 \ + --hash=sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d \ + --hash=sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d \ + --hash=sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765 \ + --hash=sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c \ + --hash=sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522 \ + --hash=sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f \ + --hash=sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a \ + --hash=sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b \ + --hash=sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b + +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 + +httpx==0.28.1 \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + +huggingface_hub==1.33.0 \ + --hash=sha256:04e434b06e100eddbce9a6e817d72693a7884b10a79bd67ab48080d5c07eb899 + +idna==3.20 \ + --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c + +Jinja2==3.1.6 \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + +laya==0.3.28 \ + --hash=sha256:a1493cff474c0a5d84861db55c0c5c9b17f1db8561e458764da9a322dc542a88 + +markdown-it-py==4.2.0 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a + +MarkupSafe==3.0.4 \ + --hash=sha256:007e1ffd9bf65bb6ee96df7b258fc632a4868dd5566037986c64781f35a36e98 \ + --hash=sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002 \ + --hash=sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b \ + --hash=sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653 \ + --hash=sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c \ + --hash=sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e \ + --hash=sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc \ + --hash=sha256:0764a13d34cae40db7bbf3a09b7e9b491bf4603e20b263a7a9d6b8e324975d0a \ + --hash=sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92 \ + --hash=sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f \ + --hash=sha256:0cee7cb0f9a1b6892ea482237d9403b3d1b4603aee057d0ff01f0fac2d019a97 \ + --hash=sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4 \ + --hash=sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7 \ + --hash=sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691 \ + --hash=sha256:14bd2d845d62ab678eaf81da89d7b621b51756c72346745c1a594c09d49207a2 \ + --hash=sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc \ + --hash=sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde \ + --hash=sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99 \ + --hash=sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9 \ + --hash=sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df \ + --hash=sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5 \ + --hash=sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17 \ + --hash=sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8 \ + --hash=sha256:2a6ef68ae94aed8721934072b27a3b654ea2100b97e4ab864cf1489c90926fbc \ + --hash=sha256:2b2b1e18af909b448bb3cf9e3433366f7a8726271fc214e8b10e0f62a78c724b \ + --hash=sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea \ + --hash=sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248 \ + --hash=sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741 \ + --hash=sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5 \ + --hash=sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7 \ + --hash=sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1 \ + --hash=sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67 \ + --hash=sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f \ + --hash=sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9 \ + --hash=sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c \ + --hash=sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc \ + --hash=sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba \ + --hash=sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17 \ + --hash=sha256:3d23795802fc8bd72534836d64489bbf0f67c088959091bdb22e10735a5107bf \ + --hash=sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6 \ + --hash=sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2 \ + --hash=sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163 \ + --hash=sha256:4a540e2d3192792fc84eced57bef37851ccb2b41f73291bb17408eea77bcd278 \ + --hash=sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d \ + --hash=sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b \ + --hash=sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634 \ + --hash=sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38 \ + --hash=sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed \ + --hash=sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c \ + --hash=sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148 \ + --hash=sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a \ + --hash=sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7 \ + --hash=sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f \ + --hash=sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811 \ + --hash=sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e \ + --hash=sha256:57f9947a7e57a081c1e3e0a2dd0d2dcf290a4531450e6f611e30084c222a7295 \ + --hash=sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2 \ + --hash=sha256:5c22873ad1f0532ba40fa1727f3c0fc1bbbaab6d373d4cbe3f0dc74b2e2521c7 \ + --hash=sha256:5e8b3d0b18fd623afa12ecb2ce8d8becef69f9b5440c6330c7972200e0bb84b0 \ + --hash=sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6 \ + --hash=sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed \ + --hash=sha256:6669c1bf34080161ce49c589cc512ef24d4c704ac9d2b2d3667f519c60418378 \ + --hash=sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0 \ + --hash=sha256:6768d67d1bce64270e0fdc2e69309d68b9b18ae56ddf6c711d168e9d051c2cac \ + --hash=sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b \ + --hash=sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96 \ + --hash=sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59 \ + --hash=sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808 \ + --hash=sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2 \ + --hash=sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb \ + --hash=sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65 \ + --hash=sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72 \ + --hash=sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8 \ + --hash=sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e \ + --hash=sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91 \ + --hash=sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a \ + --hash=sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2 \ + --hash=sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e \ + --hash=sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707 \ + --hash=sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21 \ + --hash=sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef \ + --hash=sha256:8698d70a8081ee8c090dbb394768b5789a1da8b131b5499f89d071dd3cfaf6be \ + --hash=sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453 \ + --hash=sha256:88d59b473bfb03259722600839af9bbd7fa13a2eb514beefeedb95997882f69a \ + --hash=sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6 \ + --hash=sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977 \ + --hash=sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978 \ + --hash=sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581 \ + --hash=sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692 \ + --hash=sha256:9240187afb63d2f9ddc3e032c670356fe941f6e20662ea168a5dc3f1f317e1b3 \ + --hash=sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369 \ + --hash=sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a \ + --hash=sha256:9388003072b95f2f1e3fd908604194d653ba21330d811961a78b7da1a77e9e36 \ + --hash=sha256:9438a2648b2195980cb2dd8e53ed7b8df91319e2d0b70ae61a9e1d1bc8d3bec9 \ + --hash=sha256:94e4c421742086aeee4c32a506eec8859d7634aad943f7e6aacf70f813478768 \ + --hash=sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916 \ + --hash=sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b \ + --hash=sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f \ + --hash=sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346 \ + --hash=sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c \ + --hash=sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464 \ + --hash=sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9 \ + --hash=sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee \ + --hash=sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300 \ + --hash=sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6 \ + --hash=sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d \ + --hash=sha256:ac0c7c9f1609b0c4c114feb1d7a3409564c7fb77e360bed9e97e5d25dfeaf868 \ + --hash=sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46 \ + --hash=sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97 \ + --hash=sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733 \ + --hash=sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe \ + --hash=sha256:b61687d0828e72bf5cda24a2690188f37170bd31c9359ac97e4e66569f120a16 \ + --hash=sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429 \ + --hash=sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39 \ + --hash=sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894 \ + --hash=sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c \ + --hash=sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c \ + --hash=sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169 \ + --hash=sha256:bf053da3c97a4bc5ecfbb218cdd2983febd91c617be8367d139882aa11e490aa \ + --hash=sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77 \ + --hash=sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe \ + --hash=sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad \ + --hash=sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85 \ + --hash=sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e \ + --hash=sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34 \ + --hash=sha256:cf63c214fe879a65e69a386f915e36104fc84254ab141240f8854602d8e0be2a \ + --hash=sha256:d1aca03ede943eb80ab3d63bb082c84b7aab85ea83bd0fd0c200260945fb49d9 \ + --hash=sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c \ + --hash=sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749 \ + --hash=sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214 \ + --hash=sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932 \ + --hash=sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494 \ + --hash=sha256:dd8ea6ebee7aedbf7c749fa80521d9ccf1ba473e0d1e14805caafbaad281c889 \ + --hash=sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1 \ + --hash=sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0 \ + --hash=sha256:dff05cb7016dff1e9fd68f4122c127b65dfc59de5306cfb7ad92f956f230bee2 \ + --hash=sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786 \ + --hash=sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78 \ + --hash=sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e \ + --hash=sha256:e841068dc0be4cb6dfb5c890eb88cbdcff2f4a332393c7ec94e8e618bd32c1a8 \ + --hash=sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289 \ + --hash=sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c \ + --hash=sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe \ + --hash=sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237 \ + --hash=sha256:f291bcf42ae98eb5107edb162c3c998b4a89648fd8e99ed4cbd12705292788cd \ + --hash=sha256:f61efe1d2fe0de16158a5fe1d1cf3c14bdb6aecd54d8938fd26512c525c1f624 \ + --hash=sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19 \ + --hash=sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977 \ + --hash=sha256:fd9f8797427910198f95bced71ddfed61130d7e349213bfb8466c9c99e2c46a8 \ + --hash=sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3 + +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 + +mpmath==1.3.0 \ + --hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c + +networkx==3.6.1 \ + --hash=sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762 + +numpy==2.4.6 \ + --hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \ + --hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \ + --hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \ + --hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \ + --hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \ + --hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \ + --hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \ + --hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \ + --hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \ + --hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \ + --hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \ + --hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \ + --hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \ + --hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \ + --hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \ + --hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \ + --hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \ + --hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \ + --hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \ + --hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \ + --hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \ + --hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \ + --hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \ + --hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \ + --hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \ + --hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \ + --hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \ + --hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \ + --hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \ + --hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \ + --hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \ + --hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \ + --hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \ + --hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \ + --hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \ + --hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \ + --hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \ + --hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \ + --hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \ + --hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \ + --hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \ + --hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \ + --hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \ + --hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \ + --hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \ + --hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \ + --hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \ + --hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \ + --hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \ + --hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \ + --hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \ + --hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \ + --hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \ + --hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \ + --hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \ + --hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \ + --hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \ + --hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \ + --hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \ + --hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \ + --hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \ + --hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \ + --hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \ + --hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \ + --hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \ + --hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \ + --hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \ + --hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \ + --hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \ + --hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \ + --hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20 + +opentelemetry-api==1.45.1 \ + --hash=sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb + +packaging==26.3 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + +pydantic==2.13.5 \ + --hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 + +pydantic_core==2.46.5 \ + --hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \ + --hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \ + --hash=sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5 \ + --hash=sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c \ + --hash=sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184 \ + --hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 \ + --hash=sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5 \ + --hash=sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0 \ + --hash=sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931 \ + --hash=sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e \ + --hash=sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25 \ + --hash=sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d \ + --hash=sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6 \ + --hash=sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47 \ + --hash=sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038 \ + --hash=sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8 \ + --hash=sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b \ + --hash=sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a \ + --hash=sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e \ + --hash=sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074 \ + --hash=sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0 \ + --hash=sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433 \ + --hash=sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f \ + --hash=sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034 \ + --hash=sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21 \ + --hash=sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736 \ + --hash=sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0 \ + --hash=sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7 \ + --hash=sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c \ + --hash=sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4 \ + --hash=sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c \ + --hash=sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c \ + --hash=sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069 \ + --hash=sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb \ + --hash=sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061 \ + --hash=sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29 \ + --hash=sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3 \ + --hash=sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa \ + --hash=sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e \ + --hash=sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38 \ + --hash=sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f \ + --hash=sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b \ + --hash=sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 \ + --hash=sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e \ + --hash=sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c \ + --hash=sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be \ + --hash=sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6 \ + --hash=sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793 \ + --hash=sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 \ + --hash=sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b \ + --hash=sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64 \ + --hash=sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f \ + --hash=sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761 \ + --hash=sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d \ + --hash=sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168 \ + --hash=sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869 \ + --hash=sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111 \ + --hash=sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5 \ + --hash=sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b \ + --hash=sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7 \ + --hash=sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129 \ + --hash=sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e \ + --hash=sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655 \ + --hash=sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c \ + --hash=sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec \ + --hash=sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689 \ + --hash=sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f \ + --hash=sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf \ + --hash=sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea \ + --hash=sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9 \ + --hash=sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464 \ + --hash=sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519 \ + --hash=sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b \ + --hash=sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f \ + --hash=sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee \ + --hash=sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a \ + --hash=sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e \ + --hash=sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6 \ + --hash=sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2 \ + --hash=sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f \ + --hash=sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a \ + --hash=sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f \ + --hash=sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a \ + --hash=sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47 \ + --hash=sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda \ + --hash=sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0 \ + --hash=sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4 \ + --hash=sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d \ + --hash=sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3 \ + --hash=sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2 \ + --hash=sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355 \ + --hash=sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461 \ + --hash=sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed \ + --hash=sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f \ + --hash=sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5 \ + --hash=sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2 \ + --hash=sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829 \ + --hash=sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0 \ + --hash=sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266 \ + --hash=sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575 \ + --hash=sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290 \ + --hash=sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f \ + --hash=sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62 \ + --hash=sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f \ + --hash=sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a \ + --hash=sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7 \ + --hash=sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed \ + --hash=sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f \ + --hash=sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1 \ + --hash=sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615 \ + --hash=sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8 \ + --hash=sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3 \ + --hash=sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a \ + --hash=sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff \ + --hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \ + --hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \ + --hash=sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3 \ + --hash=sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13 \ + --hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9 + +Pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 + +python-multipart==0.0.32 \ + --hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23 + +PyYAML==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + +regex==2026.9.29 \ + --hash=sha256:01000ddf0e3ffef97f2413ceb514f6313040106b6d18a03ee00a4fe35c1eb1db \ + --hash=sha256:0166844493626c5015c6088ee15c9ca2fd060ca15b7641d1657da6a58432ae33 \ + --hash=sha256:044265d77d94f5e3cb2fd72c76723807c429cb8c533e9d4672d0334a6f14f588 \ + --hash=sha256:0476e5bcbe6e1ba3d1c4cc7bbb1c3ba78e3b979b5c8a88d0a6a8cdd4992b8c84 \ + --hash=sha256:066d0e3dbfdd739bce2bf8c2a41dd16f73e3d8adc2eb06dd803a36a307f56075 \ + --hash=sha256:0b65c72739f981377c9c22e0c5c3cd7f42da7bd8a3c9209330fac772c7d893ed \ + --hash=sha256:0c992c19cd45058a4b92f68f139c93db168b48fb1f322c9a7cd620806afb6b51 \ + --hash=sha256:0cc63b5e47c12a48d90c7e9d7de6a035dd14f62868aaedbb4e0ff8ba2b8bfe7b \ + --hash=sha256:0dd8af32e9f7b56b7f95cc1fd79b23054c3bdc172392ae560acc24d57b7ffe71 \ + --hash=sha256:0def9fb6abac55492d6d51cddb7225d07d6f279e774e0adc08569a54a5fc8d46 \ + --hash=sha256:0fd2c901cc307a745ad4bc87f20060d7a0825a3371d1e93488af22e7a387f78f \ + --hash=sha256:1043aedf5917caa861bcb25a9c11460049656bdf0017a90a309fa8f255467725 \ + --hash=sha256:121a76a0985db80ceae9e171c337f8c927868e37d01b54e3ce87bc87f9c6a208 \ + --hash=sha256:143533cc4b6fbc5b95aca0a5b8d541088d374831593def000ec89322c220221d \ + --hash=sha256:14e953ff3607c92d7675bf79c4d4509ef6782aa8c08509f179f9b3d6d0679e86 \ + --hash=sha256:18ae8eed4526e35bdb754d61562b90bf5c00a67fdcf3cc1380dd59597486631b \ + --hash=sha256:19959129885356df0e97556856f77eb2888380dac18bed075a7c05c5128c618d \ + --hash=sha256:1ba8c6a416569ce0d37e83e28a254a61dc99a419084dfb6476cea02d997f74fa \ + --hash=sha256:1c2a0026062abcc321a53db4a185ceba0b59a66b5d37b0808917a88b55a5257f \ + --hash=sha256:1d9fe8091b2e89d470df68a9331111ed008ae8aae6bf1e8e1fba4086a495c84e \ + --hash=sha256:2089fe39c406784d90101c726755ffa1497bb74638fd434300d2b88006186de8 \ + --hash=sha256:23ae6fdad9e63e54038f5ef78aba2933faca61e24d432786589e737bc5522ebb \ + --hash=sha256:26ec4ccce55aa533fbd603d08911b01101a8fcfec987845ac3ae2c7087b2bde3 \ + --hash=sha256:2f7f7aa47b229f2b39a2ae2596d2ad5625d77b5eb9856fac2dab3eb506cdd0a0 \ + --hash=sha256:31b003f9a070335e2a8233ee9b14a3ca8e6d792012ae011f741bf0aaf11744c5 \ + --hash=sha256:32ab11df9677ca80bcbb5fe4eb1da9109a5019239a054836efc6fa1c64e683cf \ + --hash=sha256:33026515aebc0e70d1c89978e53e8d695d35d9e472f8d5b34465ba3c74028650 \ + --hash=sha256:34b6925af9853bf461950e6508910f179fd6e9b1a7ec8548e069606b7e51a26b \ + --hash=sha256:352cf115a810b357caa35193ab656ecf5ef41056855e82f292c99e8514f8d954 \ + --hash=sha256:39ab5894d971f9ac68baa6eca5c50387db579cfcacf36ae8df3feceb1815e6d0 \ + --hash=sha256:3a21a9509d0ee88e7a70e1ad228cd2f0e0fd1e187458db132e8a8d18c97daf9d \ + --hash=sha256:3c5c2ef13797466aa64170cbb66ad98a32351dd4127694cea7199f80f213750d \ + --hash=sha256:3e778bfccd63075167709136afbc251c1f683758d5bf49c803c60ac3f894ce6b \ + --hash=sha256:3f1e6cb402a89457582cd696f982559217d13484a193202c394015297968c86d \ + --hash=sha256:42e82e578c904445d4c8a35b8f28052cf567593215fa5db06266fbc6f77aaa2e \ + --hash=sha256:4408b2b27a95ca8cc48b7411945753773353b5c93b307754781086c99d3a576f \ + --hash=sha256:446654b29bfaa30500d80947eda42cef1449dc8a87f4e3cf061cc8485d3a1f0b \ + --hash=sha256:45010bcfe66df41522d56c9b6114e87ecc597a08970ff6a2ced24415c141ae5f \ + --hash=sha256:49ee178ca31c94621294bf9b8b676a92a2e6bba8af0529591753719e57edb621 \ + --hash=sha256:4d7d93613b01b0199961330e49cfc52d479b3d5776c56c691db31130c0a07d91 \ + --hash=sha256:4fb41211d2333eb930a51e0546a65999761cf1f572a4da56ef9b8a62966c06f2 \ + --hash=sha256:4fe97894d1b306c919b4e50def1e6f6c522f4d03a7283811f4d108f1ce5d3ac2 \ + --hash=sha256:554bffadcbcb6d5f4e5fb10a61cc52084b9a63d1dab5f10bcd2c4343972e8e2c \ + --hash=sha256:59b49507f47479e299a9e1bc41b5cb83a7afda0540625f1dbae886615978acbf \ + --hash=sha256:5eeb8edc6110d9194a4d0d54610f64c37a31c605b5dbb7e407fc6ec7fa34a4a1 \ + --hash=sha256:612b709381c0355b70d89cdb51b7f670591ed5cbbc0e3b5337488019dc667b65 \ + --hash=sha256:61956f074ecd123f55adca68ee3eab46e6a07ad3f8e64e6db95dfacb444f55c4 \ + --hash=sha256:6398d5145689503412cc1748895242598d8846b8967b851133b20dc2ed1e21e8 \ + --hash=sha256:65b408d8fcb273e3499e7ef2ce796810da1becd208c7fb4373692a242d79d461 \ + --hash=sha256:686ac5350fceae63830bb98805fcb8039325bf4c06d9f6f048ff65229d5bffa5 \ + --hash=sha256:6a1a824fbed817e0a891103886b68f063b1e83cc51bc97192a90a60195a9291f \ + --hash=sha256:6abb75ab16bc3281714a5b99548a2225db70dba1f995f6d7f7419b76eb5a8fbe \ + --hash=sha256:6f7121a8914ed13fcfe2099f895341bfb789f004d4c5a0bdece8fa667da10849 \ + --hash=sha256:7020ed44df30b3aa492c00ee3b52d0548c1f30c2c6c5bb13ae897680900d3413 \ + --hash=sha256:720537c7ea6f80dc61913184edb0ce2497a306b39ef19f28505b322553d52bdb \ + --hash=sha256:724184b4aafed865e4f13ca313fdcb43024300c028ec67319cfa16847d84685e \ + --hash=sha256:7c03031610e3e6ed1768a2b7a8fc84637c1257b50c5eacaf094c6e17a84fc563 \ + --hash=sha256:80a5ea3b4fd9d6a5b9a44f7976a9acaaab35aa3c1f6b29e5bd857dfabaded223 \ + --hash=sha256:80c7cadd3fd2bfde5df8aa0787e315812cad0c313a753095d02f4c2b6c01677b \ + --hash=sha256:80ea96f5c1a30bf09007d48466521d9c294bebe197c708c3359096e3e3691632 \ + --hash=sha256:864e9b87ac33c3fb9fb4ad48166d4fdb579c351d5c77deb0d34bccb36a775cd9 \ + --hash=sha256:87fb80cbe3557e27e7b28b995c2b2eedf689b8886f941ab93e0e288f0976518a \ + --hash=sha256:8873c4a11c50b9989168881aeb3f08859f469d809941866aa1feefd8be5431f6 \ + --hash=sha256:888d60953908dcf761aa320c3e390ab8556efbdb551ace63921de90f6ae0848d \ + --hash=sha256:8f39588af4731c8923c26810eb3b33f76f17633985e40f59c3cd45a33805a895 \ + --hash=sha256:9173db3be74a35cb6731701094b98120f7ee4876a287882a59cdea1fa7da342f \ + --hash=sha256:92f05c9c42bde5785dc48770bc2194d9f7442544156f951e19cd31b096cec562 \ + --hash=sha256:951733b1bbdb71e377cec567b409f1a7881b47cfcad84121aa74cb575fa425ea \ + --hash=sha256:957bb708e8057ab1649ba566456429d691ec9b90d1c9ad1af1ba7ffbbeaf05f2 \ + --hash=sha256:9916fda742cd4eede63b286f58c06718324265d727ce0856eb1aac86d0d150d6 \ + --hash=sha256:9e1d3a4cb7993b708f0ada8d0c84590efd853f169e7147d2202c9da503180242 \ + --hash=sha256:9e4482589065c8ecd761cff522dcd85f2d39e62f551e37e025d1c7d54772def3 \ + --hash=sha256:a5300757f8a68f5b6cc33f57338d72a0e3589c5cc9ad5f8504ea06f028be582a \ + --hash=sha256:a540abfab208e1b7ef2df231c40ef3b6cbb30a0aad6204e9b6a81c10a6794628 \ + --hash=sha256:a5758353650079898dc1b2b0e95aa51fa23a30d020e06f62c430dd08ee56cdd8 \ + --hash=sha256:a64b85a4760337cfefdb27d42da6ed8b58e8cde3f2d57b6ef43e76ef6ea9ef47 \ + --hash=sha256:a655d34b2a6943af32401f3d94f72e9d731f6ad16285815550bf2b4ee69d420a \ + --hash=sha256:a714befaacbd10092ffe4cea0d3c5f008fb9efe9bc322c715bcdfdee414b9a3d \ + --hash=sha256:a760da040b47767b4b873adfb7c3b691e9ba2fc60f113f9d0b88f1a62f323e85 \ + --hash=sha256:addd736a0547d553283adaf4e05d7104e7f2c7b0b092e9b4d28756825f14531f \ + --hash=sha256:ae4613d7d9dda60fcba95f846cc6f808017f1843f392cf9daad14a6534493d71 \ + --hash=sha256:b11b589e00095ec69cf79841a76360f9b079e95b0368a25b5ebb951ab0c157ff \ + --hash=sha256:b3e445b66c80b4eb4234e855ce94d9adc183eedbd632816228d89930b91b2c5b \ + --hash=sha256:b7b893976e7fe42053da64f2aa27239c24252fd2ec6df471e1be197c0addc3b1 \ + --hash=sha256:b84f186a7f0536fe4ff9a9fa12d06d007b9b71d4b5352ddcc41f59ad6522a312 \ + --hash=sha256:b89efc38431793d28b7cd91227e2f952ad7c48df19132b17f43a5fec3c14143b \ + --hash=sha256:b97a38fb4c732b6832db6bf108963adbcd82ef1268ba2025dce390f45af75efa \ + --hash=sha256:b9d74e4eee9ddb64c2e92d5d61472c59c21684c059eb7b68767be9628e977859 \ + --hash=sha256:bb90e7177944b6684738c1fc36aabd2dd00d1de3be7dbe09f91e196f1bc0dc81 \ + --hash=sha256:bec37990e3d6121f29ecfb594bd8f1bf009e9f7926daba2e50e3b27d3892a783 \ + --hash=sha256:bf3c49863c23a1ad6da9c30351aed6cff8d5ddbeb63c5c8420ae54e98c7d0138 \ + --hash=sha256:bf48516e35cf848390ea68850aba53e7c333720d2945b4d2c25b69fc5171723f \ + --hash=sha256:bfc71e6d970419c1309b3640305298643e2a734cad3f7cfb6d2ddee4175ab53d \ + --hash=sha256:c0094897d7d01f184b2d7fe8c56c66d64efe01b31f4b7d34205b391387df1111 \ + --hash=sha256:c03c6eb6ece86dfdcbb34799efaa339b093132e1aceed491ba5e08fe06cdf699 \ + --hash=sha256:c1a9a6651197fbed6f0212591418b9def774fc3f8324f78d1bf0e6a63e5f8aa1 \ + --hash=sha256:c3589f40749acce747510bf5d589d54e376cb0930ea58b35effac97e5312b0c1 \ + --hash=sha256:c4e38dd8f39c43a91d2410ad2b85610701b0979342c3df1d69eaf8e838c757d8 \ + --hash=sha256:c6c8fabf1dafc1f1ddcbb67896d3f93efb092e8c4b6322d7389b944e76a484e5 \ + --hash=sha256:c90fcf7804ea0a54b896ce0f2b9565350220b8d4890fd0db461a476a4c687963 \ + --hash=sha256:c9b602fae1e00b7c035d661ce85575365719192a7b46784bd71cf64c68053aa0 \ + --hash=sha256:ccb64d887a9db1cd76dbc0f92051a1a478a2a67e7f56c62d915cb881d7734704 \ + --hash=sha256:d06fcdecc10fc7954d7c8f27a03c96055fe525274dc84a7b0dbdc3d6b9e03dab \ + --hash=sha256:d0c3082bf79bcd6a614d55916590ad4b8f93200e10b97f463ea5d9d07c9b5f23 \ + --hash=sha256:d49c18f1ea294cf4adde2e5ac256e98c82ea9d708462ce4bf799dffa7cfe8a2c \ + --hash=sha256:d60030baaa7bfbb02d650c126cdcddcb6e33dbff14d819434c8fa2fdcaeeeba5 \ + --hash=sha256:d7cab119d0df0b9413f106b4d7fc34f2872d3574ed3806fb48959c830b1537da \ + --hash=sha256:d9b77b25b4f395f92de6099ab08e8ae2bc7e51dfe157f22900902243a5cc90c7 \ + --hash=sha256:dabee8f4935e731fb46b2a3091bdda0d3d94b3bbfb907d2b4f12eefce4009619 \ + --hash=sha256:db5e82ba15c142425b8406690032df89e39cca4a2e8afbbb9a3d84edc2373ac3 \ + --hash=sha256:dc79d36d0618752265f0d575915bdc5c5130ecb9c9f6b3bcefeae32e4bdfafcf \ + --hash=sha256:ddfa987262763c3c22a8367d2a49c244b018a74c3a8e3ab1a864119ad45c5633 \ + --hash=sha256:e1172147d28d8fbcf8cb8d26c41506169f5ad8fe9ec969cb116835a19d4d8eca \ + --hash=sha256:e11edba5bc344a32b029a7af9d4b3173982dd79eeafa0b9dbd787364414b0509 \ + --hash=sha256:e2c89e9b762c57f59d5e99ee8b20202adb892e35f8d3485741340999ca55058e \ + --hash=sha256:e31f72490b7c12f7790e1e25c3afffd20503ee1bfb43461d7838b871ff244b19 \ + --hash=sha256:e8c65ef3862a8ad6e86492b6ed9327805dd66904c012bd3649dc67d822ed6c34 \ + --hash=sha256:ebb8912f565b8cdbbf27debfe00df04202c20e2f651b9e32767930c5eace3621 \ + --hash=sha256:ed511a0708e2297e1d6431e7fb217e3402791e491e02da800658ace4973df1bb \ + --hash=sha256:edf06545875f3efa31560d94121e95c7fd70d98b1dfedc0157097d79b13b52ea \ + --hash=sha256:f0fe9834e5aeccaf19a0d8feb296d66a24be1a7c9922002f842a682cd5abb787 \ + --hash=sha256:f1a0d5117230dd46b399a30a38afa44f79c99f3168988fdc4f425c3f928b39df \ + --hash=sha256:f37964e4a5e993d2fd45147741e9dff7f34a2d8c00ab94c4ea0514a4677f959e \ + --hash=sha256:f57dc6b8fef170f105d2cf5cdce254f47b137d7755086cf7050f47e16582abba \ + --hash=sha256:f93bc1c3486ef3747e07c9d7c1d0a147b8fbaab975f80e348aed6f71309dfaca \ + --hash=sha256:fb00027a09a8f9f08028b40dce4c933cf73e4833240ed356583fdc9cfa721566 \ + --hash=sha256:fb99cc9d45f48895d9d67f6a0b8a57f08d39c174d9f25ad97a313e0470267b1c \ + --hash=sha256:fdd88ed5e20b1bcdd234421e454962c971aa44b653bdb7f1ea9ef683e90fb649 \ + --hash=sha256:fe3fa1dd453ed5c7f5ea23a26218329790ed7197a99b90e94330e313959a7f52 + +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb + +safetensors==0.8.0 \ + --hash=sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358 \ + --hash=sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f \ + --hash=sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d \ + --hash=sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d \ + --hash=sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0 \ + --hash=sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc \ + --hash=sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235 \ + --hash=sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98 \ + --hash=sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4 \ + --hash=sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846 \ + --hash=sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca \ + --hash=sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0 \ + --hash=sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25 \ + --hash=sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452 \ + --hash=sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78 \ + --hash=sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774 + +setuptools==84.0.0 \ + --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 + +shellingham==1.5.4 \ + --hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 + +starlette==1.7.0 \ + --hash=sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e + +sympy==1.14.0 \ + --hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5 + +tokenizers==0.23.2 \ + --hash=sha256:12f0835dc2ee694746a76adf7b1567d4346a4a502ebe93fb1f5f80ea49799b78 \ + --hash=sha256:2e96f5699d5249c9c64aa8412e044f727aae3a4098cf830f9901ec1afc361cde \ + --hash=sha256:325fee2e0418a9dc6c9ecf736a5f5f0db7875183ace9549ae339da76f7a1fbb7 \ + --hash=sha256:41c2f84d172449b4dadb9cdc508e3e364076613c35b16e76ecfe47a60d1e3305 \ + --hash=sha256:43e4f2071e3cc8d5d86421c874aebc82659bb51a68bcdef5a0da75ee89511ccb \ + --hash=sha256:5c56bda1511921587789163e524d196ed8284174ac23abd7685d5ea8da6c4718 \ + --hash=sha256:7b7e37ba198f24150f523e1242e83c4970de4a525480586be5dcc24d9add32c5 \ + --hash=sha256:85a9a357a3764aecc904ee76bdaf8cf1ad8e5a67a1b929a487c4a39b49ed0e90 \ + --hash=sha256:950d7c9426fa72406a0ffeacdbc0bb9985f5db20eb8b263f29c79aaf83105703 \ + --hash=sha256:986670e43691469dcee610ea0f846f91a8f84e91fc6f7a48d4c064414c0ec2bf \ + --hash=sha256:a37039b5dfc4af84eb3ef0a92f4307e28936c8f9adccba2629d36f652e9bf7a2 \ + --hash=sha256:bef235815a067b2648caf6dcc7a71091b0b0fff9ee8057f6451eb9335fae52ef \ + --hash=sha256:debf978920d93ba9c219bd67cc4bbfaf912c9039e41e7a28b91ec15e3728c95a \ + --hash=sha256:e49c394456dd9985787fec76132438ba3fb8911f857b1bf3d40119f9292d41aa \ + --hash=sha256:eb2f9c8a24da020ea8c11a01a19c1c2547912d92121ae4a01cfbca46125dee40 \ + --hash=sha256:f486f402f6f9abee5bb032553736813af0c710a86b2e0ca592634c55cea1f835 + +tqdm==4.70.1 \ + --hash=sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73 + +transformers==5.19.0 \ + --hash=sha256:afcd2dd5f603ed28c1e1fcb00a338ccbb4ef5f878ed289635df8b58187afb518 + +typer==0.27.3 \ + --hash=sha256:e50022f28b82a86313e54501317a1db64bf8f8d036ff8cfe5ca7e47675454aff + +typing-inspection==0.4.4 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 + +typing_extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 + +uvicorn==0.54.0 \ + --hash=sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf diff --git a/ods/extensions/library/services/laya/server.py b/ods/extensions/library/services/laya/server.py new file mode 100644 index 0000000000..82019cb5ca --- /dev/null +++ b/ods/extensions/library/services/laya/server.py @@ -0,0 +1,80 @@ +"""Laya HTTP service with real, same-process inference qualification at startup.""" +import math +import os +from pathlib import Path +import re + +QUESTIONS = { + 'department': {'type': 'choice', 'instructions': 'Which department handles this request?', + 'criteria': {'billing': 'payments and refunds', 'technical': 'software bugs', 'other': 'other topics'}}, + 'urgency': {'type': 'score', 'instructions': 'How urgent is this request?', + 'criteria': ['low', 'medium', 'high']}, + 'refund': {'type': 'noul', 'instructions': 'Is the customer asking for a refund?'}, +} +STARTUP_CASES = [ + ('english', 'en', 'I was charged twice. Please refund the duplicate charge.'), + ('multilingual', 'pt', 'Fui cobrado duas vezes. Quero o reembolso da cobranca duplicada.'), + ('typed-decisions', 'en', 'A customer requests a refund for a duplicate charge. Route this ticket.'), +] + + +def probability(value): + return type(value) in (int, float) and math.isfinite(value) and 0 <= value <= 1 + + +def distribution(value, keys): + return (isinstance(value, dict) and set(value) == set(keys) + and all(probability(item) for item in value.values()) + and abs(sum(value.values()) - 1) <= 0.002) + + +def validate_startup_result(result, checkpoint): + if not isinstance(result, dict) or result.get('routing', {}).get('model') != checkpoint: + raise ValueError('Laya startup returned the wrong checkpoint') + usage = result.get('usage', {}) + if (usage.get('truncated') is not False or usage.get('state_tokens_dropped') != 0 + or usage.get('truncated_questions') != []): + raise ValueError('Laya startup did not prove complete context') + answers = result.get('answers', {}) + if set(answers) != set(QUESTIONS): + raise ValueError('Laya startup returned incomplete answers') + choice, score, yesno = (answers[key] for key in QUESTIONS) + if (choice.get('type') != 'choice' or choice.get('choice') not in QUESTIONS['department']['criteria'] + or not distribution(choice.get('probabilities'), QUESTIONS['department']['criteria']) + or score.get('type') != 'score' or type(score.get('score')) not in (int, float) + or not 0 <= score['score'] <= 2 + or score.get('legend') != {'0': 'low', '1': 'medium', '2': 'high'} + or not distribution(score.get('probabilities'), ['0', '1', '2']) + or yesno.get('type') != 'noul' or not probability(yesno.get('noul'))): + raise ValueError('Laya startup returned invalid typed decisions') + + +def read_key(path): + value = Path(path).read_text(encoding='ascii').strip() + if not re.fullmatch(r'[A-Za-z0-9_-]{32,256}', value): + raise ValueError('Invalid managed Laya API key') + return value + + +def create_portal_app(*, router=None, key_file='/run/ods-laya/api-key'): + from laya.serve import build_router, create_app + os.environ['LAYA_API_KEY'] = read_key(key_file) + if router is None: + router = build_router() + for checkpoint, language, text in STARTUP_CASES: + result = router.predict(text, QUESTIONS, model=checkpoint, lang=language) + validate_startup_result(result, checkpoint) + app = create_app(router) + + @app.get('/ready') + def ready(): + # No weights, device names, requests or credentials in the public probe. + # This is startup inference evidence, not a quality/accuracy guarantee. + return {'status': 'ok', 'startupInferenceVerified': True} + + return app + + +if __name__ == '__main__': + import uvicorn + uvicorn.run(create_portal_app(), host='0.0.0.0', port=8000, access_log=False) diff --git a/ods/extensions/library/services/laya/setup.py b/ods/extensions/library/services/laya/setup.py new file mode 100644 index 0000000000..97d6d2eed4 --- /dev/null +++ b/ods/extensions/library/services/laya/setup.py @@ -0,0 +1,38 @@ +"""Connect the managed Laya service to Portal without loading the shared .env.""" +from pathlib import Path +import shutil +import subprocess +import sys + + +def configured_port(root): + # Reuse the literal grammar used by ODS and Compose; never source .env. + sys.path.insert(0, str(root / 'extensions/services/dashboard-api')) + from env_values import parse_env_value + port = '8017' + env_file = root / '.env' + if env_file.exists(): + for line in env_file.read_text(encoding='utf-8').splitlines(): + key, separator, value = line.partition('=') + if separator and key.strip() == 'LAYA_PORT': + port = parse_env_value(value) or '8017' + if not port.isascii() or not port.isdecimal() or not 1 <= int(port) <= 65535: + raise ValueError('LAYA_PORT must be an integer from 1 to 65535') + return port + + +def main(): + if len(sys.argv) != 2: + raise SystemExit('Usage: setup.py INSTALL_DIR') + root = Path(sys.argv[1]).resolve(strict=True) + entry = root / 'extensions/services/pixel-agent/plugin/laya-setup-cli.mjs' + if not entry.is_file(): + raise SystemExit('Update ODS to a version with the native Portal Laya integration first.') + node = shutil.which('node') + if node is None: + raise SystemExit('The Portal Node.js runtime is missing from the ODS owner PATH.') + subprocess.run([node, str(entry), str(root), configured_port(root)], check=True) + + +if __name__ == '__main__': + main() diff --git a/ods/extensions/library/services/laya/setup.sh b/ods/extensions/library/services/laya/setup.sh new file mode 100644 index 0000000000..efcb63bda1 --- /dev/null +++ b/ods/extensions/library/services/laya/setup.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +extension_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +exec python3 "$extension_dir/setup.py" "${1:?ODS install directory is required}" diff --git a/ods/extensions/library/services/laya/tests/container-qualification.sh b/ods/extensions/library/services/laya/tests/container-qualification.sh new file mode 100644 index 0000000000..be9eb643d3 --- /dev/null +++ b/ods/extensions/library/services/laya/tests/container-qualification.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Run only on an isolated CI runner; creates its own ODS fixture and Compose project. +set -euo pipefail +: "${CI:?Use an isolated CI runner}" +recipe="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +ods="$(cd -- "$recipe/../../../.." && pwd -P)" +fixture="$(mktemp -d "$HOME/ods-laya-qa.XXXXXXXX")" +export ODS_UID ODS_GID LAYA_PORT +ODS_UID="$(id -u)" +ODS_GID="$(id -g)" +LAYA_PORT=18017 +mkdir -p "$fixture/extensions/user" "$fixture/extensions/services/pixel-agent" "$fixture/extensions/services/dashboard-api" +cp -R "$recipe" "$fixture/extensions/user/laya" +cp -R "$ods/extensions/services/pixel-agent/plugin" "$fixture/extensions/services/pixel-agent/plugin" +cp "$ods/extensions/services/dashboard-api/env_values.py" "$fixture/extensions/services/dashboard-api/" +printf 'LAYA_PORT=18017\n' > "$fixture/.env" +bash "$fixture/extensions/user/laya/setup.sh" "$fixture" +printf 'services:\n laya:\n image: ods-laya:qualification\n' > "$fixture/qualification.yaml" +compose=(docker compose --project-name ods-laya-qualification --project-directory "$fixture" + -f "$fixture/extensions/user/laya/compose.yaml" -f "$fixture/qualification.yaml") +"${compose[@]}" config --quiet +docker build --tag ods-laya:qualification "$recipe" +docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges:true \ + --mount "type=bind,source=$recipe,target=/recipe,readonly" \ + --entrypoint python ods-laya:qualification -m unittest discover -s /recipe/tests -p test_service.py -v +docker network create ods-network +cleanup() { + status=$? + trap - EXIT + if (( status != 0 )); then "${compose[@]}" logs --no-color --tail 100; fi + "${compose[@]}" down --volumes + docker network rm ods-network + exit "$status" +} +trap cleanup EXIT +"${compose[@]}" up --detach --no-build --wait --wait-timeout 900 +export LAYA_TEST_KEY_FILE="$fixture/config/laya/api-key" +export LAYA_TEST_PORT="$LAYA_PORT" LAYA_TEST_INSTALL_ROOT="$fixture" +node "$recipe/tests/portal_adapter.integration.mjs" +node "$recipe/tests/lifecycle.integration.mjs" +"${compose[@]}" restart laya +"${compose[@]}" up --detach --no-build --wait --wait-timeout 300 +node "$recipe/tests/portal_adapter.integration.mjs" diff --git a/ods/extensions/library/services/laya/tests/lifecycle.integration.mjs b/ods/extensions/library/services/laya/tests/lifecycle.integration.mjs new file mode 100644 index 0000000000..ae20180f3c --- /dev/null +++ b/ods/extensions/library/services/laya/tests/lifecycle.integration.mjs @@ -0,0 +1,38 @@ +// Exercise the real managed connection and tool against an already-ready service. +import assert from 'node:assert/strict'; +import {readFileSync, renameSync} from 'node:fs'; +import {join} from 'node:path'; +import {configureLayaPortal} from '../../../../services/pixel-agent/plugin/laya-setup.mjs'; +import {createLayaRuntime, readLayaConnection, LAYA_CONNECTION_FILE} from '../../../../services/pixel-agent/plugin/laya-runtime.mjs'; + +const root = process.env.LAYA_TEST_INSTALL_ROOT; +assert.ok(root, 'A dedicated test installation is required'); +const port = Number(process.env.LAYA_TEST_PORT ?? 8017); +const marker = join(root, 'extensions/user/laya/compose.yaml'); +const disabled = `${marker}.disabled`; +const original = readFileSync(LAYA_CONNECTION_FILE); +configureLayaPortal({installRoot: root, port}); +assert.deepEqual(readFileSync(LAYA_CONNECTION_FILE), original, 'setup preserves credentials and binding'); +assert.ok(readLayaConnection()); +const runtime = createLayaRuntime(); +const cachedTool = runtime.offered(); +assert.ok(cachedTool); +const args = {items: [{id: 'ticket', text: 'I was charged twice. I need a refund.'}], + questions: [{id: 'refund', type: 'noul', instructions: 'Does this customer request a refund?'}], + checkpoint: 'english'}; +let reply = await cachedTool.execute('enabled', args); +assert.equal(reply.isError, undefined); +assert.equal(JSON.parse(reply.content[0].text).completeContext, true); +renameSync(marker, disabled); +try { + assert.equal(runtime.offered(), null); + reply = await cachedTool.execute('disabled', args); + assert.equal(reply.details.status, 'disabled'); + assert.equal(reply.details.inferenceSubmitted, false); +} finally { renameSync(disabled, marker); } +configureLayaPortal({installRoot: root, port}); +reply = await cachedTool.execute('reenabled', args); +assert.equal(reply.isError, undefined); +assert.equal(reply.details.executionAuthorized, false); +assert.deepEqual(readFileSync(LAYA_CONNECTION_FILE), original); +console.log('Managed Laya setup, tool invocation, disable and re-enable passed.'); diff --git a/ods/extensions/library/services/laya/tests/portal_adapter.integration.mjs b/ods/extensions/library/services/laya/tests/portal_adapter.integration.mjs new file mode 100644 index 0000000000..7668302ea9 --- /dev/null +++ b/ods/extensions/library/services/laya/tests/portal_adapter.integration.mjs @@ -0,0 +1,44 @@ +// Real service qualification: no mocked engine, HTTP transport or adapter. +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {performance} from 'node:perf_hooks'; +import {createLayaClient} from '../../../../services/pixel-agent/plugin/laya-client.mjs'; + +assert.ok(process.env.LAYA_TEST_KEY_FILE, 'Set the private test key file'); +const token = readFileSync(process.env.LAYA_TEST_KEY_FILE, 'utf8').trim(); +const port = Number(process.env.LAYA_TEST_PORT ?? 8017); +const client = createLayaClient({token, port}); +const health = await client.status(); +assert.equal(health.status, 'loaded'); +assert.equal(health.inferenceVerified, false, 'upstream health alone never proves inference'); +const readiness = await fetch(`http://127.0.0.1:${port}/ready`, {signal: AbortSignal.timeout(5000)}); +assert.deepEqual(await readiness.json(), {status: 'ok', startupInferenceVerified: true}); + +const questions = [ + {id: 'department', type: 'choice', instructions: 'Which department handles this request?', + choices: [{id: 'billing', description: 'payments and refunds'}, + {id: 'technical', description: 'software bugs'}, {id: 'other', description: 'other topics'}]}, + {id: 'urgency', type: 'score', instructions: 'How urgent is this request?', levels: ['low', 'medium', 'high']}, + {id: 'refund', type: 'noul', instructions: 'Is the customer asking for a refund?'}, +]; +const cases = [ + {checkpoint: 'english', language: 'en', text: 'I was charged twice this month. Please refund the duplicate charge.'}, + {checkpoint: 'multilingual', language: 'pt', text: 'Fui cobrado duas vezes neste mes. Quero o reembolso da cobranca duplicada.'}, + {checkpoint: 'typed-decisions', language: 'en', text: 'A customer requests a refund for a duplicate charge. Route this support ticket.'}, +]; +const measurements = []; +for (const {checkpoint, language, text} of cases) { + const started = performance.now(); + const result = await client.decide({items: [{id: 'ticket', text}], questions, checkpoint, language}); + assert.equal(result.completeContext, true); + assert.equal(result.advisory, true); + assert.equal(result.items[0].id, 'ticket'); + assert.equal(result.items[0].answers.department.choice, 'billing'); + assert.doesNotMatch(JSON.stringify(result), /act_probability|Bearer/); + measurements.push({checkpoint, language, milliseconds: Math.round(performance.now() - started)}); +} +await assert.rejects(createLayaClient({token: 'invalid'.repeat(8), port}) + .decide({items: [{id: 'a', text: 'Hello'}], questions}), error => error.code === 'authentication_failed'); +await assert.rejects(client.decide({items: [{id: 'long', text: 'Background text. '.repeat(1100)}], + questions, checkpoint: 'english'}), error => error.code === 'truncated_context'); +console.log(JSON.stringify({qualified: true, device: health.device, measurements})); diff --git a/ods/extensions/library/services/laya/tests/test_recipe.py b/ods/extensions/library/services/laya/tests/test_recipe.py new file mode 100644 index 0000000000..08d6401c24 --- /dev/null +++ b/ods/extensions/library/services/laya/tests/test_recipe.py @@ -0,0 +1,52 @@ +"""Validate the recipe with ODS's real schema, policy and literal env reader.""" +import importlib.util +import json +import os +from pathlib import Path +import re +import tempfile +from typing import Optional +import unittest + +from fastapi import HTTPException +import jsonschema +import yaml + +SERVICE = Path(__file__).resolve().parents[1] +ODS = SERVICE.parents[3] +spec = importlib.util.spec_from_file_location('laya_setup', SERVICE / 'setup.py') +setup = importlib.util.module_from_spec(spec) +spec.loader.exec_module(setup) + + +class RecipeTest(unittest.TestCase): + def test_manifest_and_actual_extension_scanner(self): + manifest = yaml.safe_load((SERVICE / 'manifest.yaml').read_text()) + schema = json.loads((ODS / 'extensions/schema/service-manifest.v1.json').read_text()) + jsonschema.validate(manifest, schema) + source = (ODS / 'extensions/services/dashboard-api/routers/extensions.py').read_text(encoding='utf-8') + start = source.index('_LOOPBACK_VAR_DEFAULT_RE = re.compile(') + end = source.index('\ndef ', source.index('def _scan_compose_content(') + 5) + namespace = {'re': re, 'os': os, 'json': json, 'Path': Path, + 'Optional': Optional, 'HTTPException': HTTPException, 'yaml': yaml, + 'CORE_SERVICE_IDS': {'dashboard', 'dashboard-api', 'llama-server', 'open-webui'}} + exec(compile(source[start:end], 'ODS extension compose scanner', 'exec'), namespace) + namespace['_scan_compose_content'](SERVICE / 'compose.yaml', trusted=True, extension_id='laya') + + def test_port_uses_the_same_literal_grammar_as_ods(self): + # Import the existing ODS parser; the fixture contains only owner config. + setup.configured_port(ODS) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.assertEqual(setup.configured_port(root), '8017') + for value in ['18017', "'18017' # local", '"18017"', '18017 # local']: + (root / '.env').write_text('OTHER=ignored\nLAYA_PORT=' + value, encoding='utf-8') + self.assertEqual(setup.configured_port(root), '18017') + for value in ['0', '65536', '$(touch bad)', '${PORT}', '1234', '1.5', '-1']: + (root / '.env').write_text('LAYA_PORT=' + value, encoding='utf-8') + with self.subTest(value=value), self.assertRaises(ValueError): + setup.configured_port(root) + + +if __name__ == '__main__': + unittest.main() diff --git a/ods/extensions/library/services/laya/tests/test_service.py b/ods/extensions/library/services/laya/tests/test_service.py new file mode 100644 index 0000000000..2516cabf88 --- /dev/null +++ b/ods/extensions/library/services/laya/tests/test_service.py @@ -0,0 +1,101 @@ +import copy +import importlib.util +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from fastapi.testclient import TestClient + +SERVICE = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('ods_laya_service', SERVICE / 'server.py') +service = importlib.util.module_from_spec(spec) +spec.loader.exec_module(service) + + +def result(model): + return {'routing': {'model': model}, + 'usage': {'truncated': False, 'state_tokens_dropped': 0, 'truncated_questions': []}, + 'answers': { + 'department': {'type': 'choice', 'choice': 'billing', + 'probabilities': {'billing': 0.8, 'technical': 0.1, 'other': 0.1}}, + 'urgency': {'type': 'score', 'score': 1.0, + 'probabilities': {'0': 0.2, '1': 0.6, '2': 0.2}, + 'legend': {'0': 'low', '1': 'medium', '2': 'high'}}, + 'refund': {'type': 'noul', 'noul': 0.8}}} + + +class Router: + loaded = [] + + def __init__(self): + self.calls = [] + + def predict(self, text, questions, **controls): + self.calls.append((text, questions, controls)) + return result(controls['model']) + + +class ServiceTest(unittest.TestCase): + def test_all_checkpoint_startups_precede_readiness_and_auth_is_required(self): + router = Router() + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, clear=False): + key = Path(directory) / 'key' + key.write_text('a' * 64, encoding='ascii') + app = service.create_portal_app(router=router, key_file=key) + self.assertEqual([call[2]['model'] for call in router.calls], + ['english', 'multilingual', 'typed-decisions']) + with TestClient(app) as client: + ready = client.get('/ready') + self.assertEqual(ready.status_code, 200) + self.assertEqual(ready.json(), {'status': 'ok', 'startupInferenceVerified': True}) + for headers in [{}, {'Authorization': 'Bearer wrong'}]: + reply = client.post('/v1/systemone/batch', json={}, headers=headers) + self.assertEqual(reply.status_code, 401) + self.assertEqual(len(router.calls), 3) + self.assertNotIn('a' * 64, ready.text) + + def test_failed_prediction_never_creates_a_ready_app(self): + with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, clear=False): + key = Path(directory) / 'key' + key.write_text('b' * 64, encoding='ascii') + router = Router() + with patch.object(router, 'predict', side_effect=RuntimeError('inference unavailable')): + with self.assertRaisesRegex(RuntimeError, 'inference unavailable'): + service.create_portal_app(router=router, key_file=key) + + def test_invalid_key_is_not_echoed_and_no_inference_runs(self): + with tempfile.TemporaryDirectory() as directory: + key = Path(directory) / 'key' + key.write_text('private invalid value', encoding='ascii') + router = Router() + with self.assertRaisesRegex(ValueError, '^Invalid managed Laya API key$'): + service.create_portal_app(router=router, key_file=key) + self.assertEqual(router.calls, []) + + def test_missing_context_and_malformed_decisions_fail_startup(self): + baseline = result('english') + broken = [] + for field, value in [('truncated', True), ('state_tokens_dropped', 1), ('truncated_questions', ['refund'])]: + item = copy.deepcopy(baseline) + item['usage'][field] = value + broken.append(item) + item = copy.deepcopy(baseline) + del item['answers']['refund'] + broken.append(item) + for value in [float('nan'), float('inf'), -0.1, 1.1, True]: + item = copy.deepcopy(baseline) + item['answers']['refund']['noul'] = value + broken.append(item) + item = copy.deepcopy(baseline) + item['answers']['department']['probabilities']['billing'] = 0.1 + broken.append(item) + for item in broken: + with self.subTest(result=item), self.assertRaises(ValueError): + service.validate_startup_result(item, 'english') + service.validate_startup_result(baseline, 'english') + + +if __name__ == '__main__': + unittest.main() From 9a776460b067a84f1b50d6f5cfe8f6f380100b98 Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Tue, 6 Oct 2026 16:30:29 -0300 Subject: [PATCH 02/14] test(laya): mount temporary storage for readonly container checks --- .../library/services/laya/tests/container-qualification.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/ods/extensions/library/services/laya/tests/container-qualification.sh b/ods/extensions/library/services/laya/tests/container-qualification.sh index be9eb643d3..ce6f7fa523 100644 --- a/ods/extensions/library/services/laya/tests/container-qualification.sh +++ b/ods/extensions/library/services/laya/tests/container-qualification.sh @@ -21,6 +21,7 @@ compose=(docker compose --project-name ods-laya-qualification --project-director "${compose[@]}" config --quiet docker build --tag ods-laya:qualification "$recipe" docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges:true \ + --tmpfs /tmp:size=256m,mode=1777 \ --mount "type=bind,source=$recipe,target=/recipe,readonly" \ --entrypoint python ods-laya:qualification -m unittest discover -s /recipe/tests -p test_service.py -v docker network create ods-network From 061eae030ba9effdbb24f82fc396bca41c732ad3 Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Tue, 6 Oct 2026 16:34:48 -0300 Subject: [PATCH 03/14] test(laya): verify complete context after increasing the token budget --- .../services/laya/tests/portal_adapter.integration.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ods/extensions/library/services/laya/tests/portal_adapter.integration.mjs b/ods/extensions/library/services/laya/tests/portal_adapter.integration.mjs index 7668302ea9..02b3849fb7 100644 --- a/ods/extensions/library/services/laya/tests/portal_adapter.integration.mjs +++ b/ods/extensions/library/services/laya/tests/portal_adapter.integration.mjs @@ -41,4 +41,10 @@ await assert.rejects(createLayaClient({token: 'invalid'.repeat(8), port}) .decide({items: [{id: 'a', text: 'Hello'}], questions}), error => error.code === 'authentication_failed'); await assert.rejects(client.decide({items: [{id: 'long', text: 'Background text. '.repeat(1100)}], questions, checkpoint: 'english'}), error => error.code === 'truncated_context'); +const longer = {items: [{id: 'context', text: + 'This background sentence describes a resolved software issue. '.repeat(70) + ' Please refund the duplicate charge.'}], + questions: [{id: 'refund', type: 'noul', instructions: 'Does the customer ask for a refund?'}], checkpoint: 'english'}; +await assert.rejects(client.decide(longer), error => error.code === 'truncated_context'); +const complete = await client.decide({...longer, contextTokens: 2048}); +assert.equal(complete.completeContext, true, 'larger relevant evidence is accepted only when consumed in full'); console.log(JSON.stringify({qualified: true, device: health.device, measurements})); From 37e06cd3ad139ea6d95758e29d00478c17ef6d03 Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Tue, 6 Oct 2026 17:04:43 -0300 Subject: [PATCH 04/14] test(laya): include service in bounded catalog guidance --- .../services/dashboard-api/tests/test_extension_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ods/extensions/services/dashboard-api/tests/test_extension_integration.py b/ods/extensions/services/dashboard-api/tests/test_extension_integration.py index 5a180e7419..a7219a8be0 100644 --- a/ods/extensions/services/dashboard-api/tests/test_extension_integration.py +++ b/ods/extensions/services/dashboard-api/tests/test_extension_integration.py @@ -51,7 +51,7 @@ def test_all_catalog_definitions_project_only_bounded_declared_guidance(): ods = Path(__file__).resolve().parents[4] catalog = json.loads((ods / 'config/extensions-catalog.json').read_text(encoding='utf-8')) rows = catalog if isinstance(catalog, list) else catalog['extensions'] - assert len(rows) == len({row['id'] for row in rows}) == 198 + assert len(rows) == len({row['id'] for row in rows}) == 199 count = 0 for row in rows: result = integration_guidance(row['id'], [ods / 'extensions/services', ods / 'extensions/library/services']) From 5899b0fb98fd35379fabf4c35662c3d9532fc2f4 Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Tue, 6 Oct 2026 17:11:57 -0300 Subject: [PATCH 05/14] fix(laya): register the optional service port in env validation --- ods/.env.schema.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ods/.env.schema.json b/ods/.env.schema.json index 2427978c6a..4076678fc3 100644 --- a/ods/.env.schema.json +++ b/ods/.env.schema.json @@ -1382,6 +1382,13 @@ "maximum": 65535, "default": 7802 }, + "LAYA_PORT": { + "type": "integer", + "description": "Laya local decision service port", + "minimum": 1, + "maximum": 65535, + "default": 8017 + }, "LIBRECHAT_PORT": { "type": "integer", "description": "LibreChat external port", From c2e817ae81086890d0e04b454d735efa321beb9e Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Tue, 6 Oct 2026 18:01:14 -0300 Subject: [PATCH 06/14] fix(laya): configure the recipe from its actual installed directory --- ods/extensions/library/services/laya/setup.py | 5 ++++- .../laya/tests/container-qualification.sh | 9 +++++---- .../laya/tests/lifecycle.integration.mjs | 2 +- .../services/laya/tests/test_recipe.py | 19 +++++++++++++++++++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/ods/extensions/library/services/laya/setup.py b/ods/extensions/library/services/laya/setup.py index 97d6d2eed4..64ed9198ca 100644 --- a/ods/extensions/library/services/laya/setup.py +++ b/ods/extensions/library/services/laya/setup.py @@ -31,7 +31,10 @@ def main(): node = shutil.which('node') if node is None: raise SystemExit('The Portal Node.js runtime is missing from the ODS owner PATH.') - subprocess.run([node, str(entry), str(root), configured_port(root)], check=True) + # The host agent installs library recipes under its configured data root, + # not extensions/user. Bind the directory actually running this hook. + marker = Path(__file__).resolve().parent / 'compose.yaml' + subprocess.run([node, str(entry), str(root), configured_port(root), str(marker)], check=True) if __name__ == '__main__': diff --git a/ods/extensions/library/services/laya/tests/container-qualification.sh b/ods/extensions/library/services/laya/tests/container-qualification.sh index ce6f7fa523..e0e1589d1f 100644 --- a/ods/extensions/library/services/laya/tests/container-qualification.sh +++ b/ods/extensions/library/services/laya/tests/container-qualification.sh @@ -9,15 +9,16 @@ export ODS_UID ODS_GID LAYA_PORT ODS_UID="$(id -u)" ODS_GID="$(id -g)" LAYA_PORT=18017 -mkdir -p "$fixture/extensions/user" "$fixture/extensions/services/pixel-agent" "$fixture/extensions/services/dashboard-api" -cp -R "$recipe" "$fixture/extensions/user/laya" +mkdir -p "$fixture/data/user-extensions" "$fixture/extensions/services/pixel-agent" "$fixture/extensions/services/dashboard-api" +chmod 0775 "$fixture/data" +cp -R "$recipe" "$fixture/data/user-extensions/laya" cp -R "$ods/extensions/services/pixel-agent/plugin" "$fixture/extensions/services/pixel-agent/plugin" cp "$ods/extensions/services/dashboard-api/env_values.py" "$fixture/extensions/services/dashboard-api/" printf 'LAYA_PORT=18017\n' > "$fixture/.env" -bash "$fixture/extensions/user/laya/setup.sh" "$fixture" +bash "$fixture/data/user-extensions/laya/setup.sh" "$fixture" printf 'services:\n laya:\n image: ods-laya:qualification\n' > "$fixture/qualification.yaml" compose=(docker compose --project-name ods-laya-qualification --project-directory "$fixture" - -f "$fixture/extensions/user/laya/compose.yaml" -f "$fixture/qualification.yaml") + -f "$fixture/data/user-extensions/laya/compose.yaml" -f "$fixture/qualification.yaml") "${compose[@]}" config --quiet docker build --tag ods-laya:qualification "$recipe" docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges:true \ diff --git a/ods/extensions/library/services/laya/tests/lifecycle.integration.mjs b/ods/extensions/library/services/laya/tests/lifecycle.integration.mjs index ae20180f3c..547a308cee 100644 --- a/ods/extensions/library/services/laya/tests/lifecycle.integration.mjs +++ b/ods/extensions/library/services/laya/tests/lifecycle.integration.mjs @@ -8,7 +8,7 @@ import {createLayaRuntime, readLayaConnection, LAYA_CONNECTION_FILE} from '../.. const root = process.env.LAYA_TEST_INSTALL_ROOT; assert.ok(root, 'A dedicated test installation is required'); const port = Number(process.env.LAYA_TEST_PORT ?? 8017); -const marker = join(root, 'extensions/user/laya/compose.yaml'); +const marker = join(root, 'data/user-extensions/laya/compose.yaml'); const disabled = `${marker}.disabled`; const original = readFileSync(LAYA_CONNECTION_FILE); configureLayaPortal({installRoot: root, port}); diff --git a/ods/extensions/library/services/laya/tests/test_recipe.py b/ods/extensions/library/services/laya/tests/test_recipe.py index 08d6401c24..37daf368aa 100644 --- a/ods/extensions/library/services/laya/tests/test_recipe.py +++ b/ods/extensions/library/services/laya/tests/test_recipe.py @@ -7,6 +7,7 @@ import tempfile from typing import Optional import unittest +from unittest.mock import patch from fastapi import HTTPException import jsonschema @@ -20,6 +21,24 @@ class RecipeTest(unittest.TestCase): + def test_setup_binds_the_actual_installed_recipe(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / 'ods' + entry = root / 'extensions/services/pixel-agent/plugin/laya-setup-cli.mjs' + entry.parent.mkdir(parents=True) + entry.write_text('') + recipe = Path(directory) / 'custom-data/user-extensions/laya/setup.py' + recipe.parent.mkdir(parents=True) + recipe.write_text('') + with patch.object(setup, '__file__', str(recipe)), \ + patch.object(setup.sys, 'argv', ['setup.py', str(root)]), \ + patch.object(setup.shutil, 'which', return_value='/usr/bin/node'), \ + patch.object(setup, 'configured_port', return_value='18017'), \ + patch.object(setup.subprocess, 'run') as run: + setup.main() + run.assert_called_once_with(['/usr/bin/node', str(entry.resolve()), + str(root.resolve()), '18017', str(recipe.resolve().parent / 'compose.yaml')], check=True) + def test_manifest_and_actual_extension_scanner(self): manifest = yaml.safe_load((SERVICE / 'manifest.yaml').read_text()) schema = json.loads((ODS / 'extensions/schema/service-manifest.v1.json').read_text()) From 010957f15bbadfebbf85c34529540c820354666a Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Tue, 6 Oct 2026 19:22:35 -0300 Subject: [PATCH 07/14] docs(laya): explain workspace batches and whole-task tradeoffs --- .../library/services/laya/README.md | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/ods/extensions/library/services/laya/README.md b/ods/extensions/library/services/laya/README.md index ed4f87b092..887a964365 100644 --- a/ods/extensions/library/services/laya/README.md +++ b/ods/extensions/library/services/laya/README.md @@ -2,7 +2,8 @@ Enable Laya from ODS Extensions. The selected Portal model gains an optional `pixel_ods_laya` tool for text classification, ordinal scores and yes/no -probabilities. It uses decisions to continue your task and answer normally. +probabilities, plus `pixel_ods_laya_batch` for workspace datasets. It uses +decisions to continue your task and answer normally. There is no separate chat UI, provider key or model-selection step. Requires the ODS core Portal Laya integration, Docker Compose and the ODS owner's @@ -37,11 +38,25 @@ volumes is a separate destructive operation. ## Behavior -The model sends relevant text explicitly; Laya cannot read paths, browse, inspect -images or create projects. It does not run on every message. Long sources must -be divided into meaningful sections; truncated responses are rejected. A -service failure returns a tool error and leaves the Portal model able to -continue with its other capabilities. Decisions never authorize actions. +Ask Portal, for example: "Classify tickets.csv by topic, preserve the IDs and +save a CSV report." The batch tool reads an existing CSV/TSV/JSON/JSONL file +through Portal's normal workspace permissions, processes up to 128 rows/64 KiB, +and saves an editable `report.csv` plus original probabilities and source hash +in `decisions.json`. The model can review ambiguous predictions without copying +every row into tool arguments. New reports never overwrite existing files. + +Texts already in the conversation use the inline tool. Laya itself cannot read +paths, browse, inspect images or create projects. It does not run on every +message. Long sources must be divided into meaningful sections; truncated +responses are rejected. A service failure returns a tool error and leaves the +Portal model able to continue with its other capabilities. Decisions never +authorize actions. + +This fits repeated text classification and scoring, not general chat acceleration. +Compare total task time and label quality for your own workload: loading, +inference and model review can be slower than the chat model alone. Confidence +is an estimate, and saved file hashes do not establish label accuracy. Original +Laya probabilities remain in JSON when the model corrects a CSV label. Development checks are in `tests/`. `portal_adapter.integration.mjs` requires a running service and a private `LAYA_TEST_KEY_FILE`; it tests actual inference, From 99b52216d97d52fff35b55872a4e7dd3345109e5 Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Wed, 7 Oct 2026 15:16:00 -0300 Subject: [PATCH 08/14] feat(laya): support bounded NVIDIA inference with CPU fallback --- .github/workflows/laya-service.yml | 6 +- ods/.env.schema.json | 6 ++ ods/config/extensions-catalog.json | 10 +- .../library/services/laya/.dockerignore | 2 + .../library/services/laya/Dockerfile | 27 +++-- .../library/services/laya/README.md | 19 +++- .../library/services/laya/compose.nvidia.yaml | 15 +++ .../library/services/laya/compose.yaml | 2 +- .../library/services/laya/manifest.yaml | 6 ++ .../services/laya/requirements-cuda.lock | 84 +++++++++++++++ .../library/services/laya/runtime.py | 68 ++++++++++++ .../library/services/laya/server.py | 14 ++- .../laya/tests/container-qualification.sh | 9 ++ .../laya/tests/portal_adapter.integration.mjs | 4 + .../services/laya/tests/test_recipe.py | 24 +++++ .../services/laya/tests/test_runtime.py | 101 ++++++++++++++++++ ods/scripts/resolve-compose-stack.sh | 5 + 17 files changed, 385 insertions(+), 17 deletions(-) create mode 100644 ods/extensions/library/services/laya/compose.nvidia.yaml create mode 100644 ods/extensions/library/services/laya/requirements-cuda.lock create mode 100644 ods/extensions/library/services/laya/runtime.py create mode 100644 ods/extensions/library/services/laya/tests/test_runtime.py diff --git a/.github/workflows/laya-service.yml b/.github/workflows/laya-service.yml index 1b21fc0009..46009080b8 100644 --- a/.github/workflows/laya-service.yml +++ b/.github/workflows/laya-service.yml @@ -22,7 +22,7 @@ jobs: matrix: os: [ubuntu-24.04, ubuntu-24.04-arm] runs-on: ${{ matrix.os }} - timeout-minutes: 35 + timeout-minutes: 45 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e @@ -35,5 +35,9 @@ jobs: run: python -m pip install fastapi==0.142.2 PyYAML==6.0.3 jsonschema==4.25.1 - name: Validate manifest, actual extension policy and owner settings run: python -m unittest discover -s ods/extensions/library/services/laya/tests -p test_recipe.py -v + - name: Validate resource selection and CPU fallback + run: python -m unittest discover -s ods/extensions/library/services/laya/tests -p test_runtime.py -v - name: Build and verify actual inference, managed activation and restart + env: + LAYA_TEST_CUDA_IMAGE: '1' run: bash ods/extensions/library/services/laya/tests/container-qualification.sh diff --git a/ods/.env.schema.json b/ods/.env.schema.json index 4076678fc3..1beb7805b1 100644 --- a/ods/.env.schema.json +++ b/ods/.env.schema.json @@ -1382,6 +1382,12 @@ "maximum": 65535, "default": 7802 }, + "LAYA_ACCELERATION": { + "type": "string", + "description": "Optional Laya acceleration; CUDA requires the NVIDIA recipe and sufficient free memory", + "enum": ["auto", "cpu", "cuda"], + "default": "auto" + }, "LAYA_PORT": { "type": "integer", "description": "Laya local decision service port", diff --git a/ods/config/extensions-catalog.json b/ods/config/extensions-catalog.json index 04dd99b50d..02f752d2f5 100644 --- a/ods/config/extensions-catalog.json +++ b/ods/config/extensions-catalog.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-10-06T19:20:40Z", + "generated_at": "2026-10-07T18:06:30Z", "schema_version": "1.0.0", "extensions": [ { @@ -5167,6 +5167,14 @@ "external_port_default": 8017, "health_endpoint": "/ready", "env_vars": [ + { + "key": "LAYA_ACCELERATION", + "description": "Acceleration (auto uses NVIDIA when memory is available; cpu keeps it on CPU)", + "default": "auto", + "pattern": "^(auto|cpu|cuda)$", + "format_description": "auto, cpu or cuda", + "required": false + }, { "key": "LAYA_PORT", "description": "Local connection port", diff --git a/ods/extensions/library/services/laya/.dockerignore b/ods/extensions/library/services/laya/.dockerignore index 1371c7edc6..564e89492f 100644 --- a/ods/extensions/library/services/laya/.dockerignore +++ b/ods/extensions/library/services/laya/.dockerignore @@ -2,5 +2,7 @@ !Dockerfile !requirements.lock !requirements-torch.lock +!requirements-cuda.lock !server.py !health.py +!runtime.py diff --git a/ods/extensions/library/services/laya/Dockerfile b/ods/extensions/library/services/laya/Dockerfile index 80a961911e..ec658f7880 100644 --- a/ods/extensions/library/services/laya/Dockerfile +++ b/ods/extensions/library/services/laya/Dockerfile @@ -1,18 +1,29 @@ -FROM python:3.11-slim-trixie@sha256:0dd364ba7e10242f07755449e3a3d0e35f9efd987952737b90def6709ab0c5ce +FROM python:3.11-slim-trixie@sha256:0dd364ba7e10242f07755449e3a3d0e35f9efd987952737b90def6709ab0c5ce AS common ENV PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_CACHE_DIR=1 \ PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \ USE_TF=0 USE_TORCH=1 TOKENIZERS_PARALLELISM=false \ TORCH_DISABLE_NATIVE_JIT=1 HOME=/cache -COPY requirements.lock requirements-torch.lock /opt/ods-laya/ -RUN python -m pip install --only-binary=:all: --no-deps --require-hashes -r /opt/ods-laya/requirements.lock \ - && python -m pip install --only-binary=:all: --no-deps --require-hashes -r /opt/ods-laya/requirements-torch.lock \ - && python -m pip check \ - && python -c "import torch; assert torch.__version__ == '2.14.0+cpu'; assert torch.ones(2).sum().item() == 2" -COPY server.py health.py /opt/ods-laya/ +COPY requirements.lock /opt/ods-laya/ +RUN python -m pip install --only-binary=:all: --no-deps --require-hashes -r /opt/ods-laya/requirements.lock +COPY server.py health.py runtime.py /opt/ods-laya/ RUN mkdir /cache && chmod 1777 /cache LABEL org.opencontainers.image.source="https://github.com/NandhaKishorM/laya" \ org.opencontainers.image.version="0.3.28" \ org.opencontainers.image.revision="a4a8921afebfd852bba0000475cfb6ab737a124c" -USER 1000:1000 EXPOSE 8000 CMD ["python", "/opt/ods-laya/server.py"] + +FROM common AS nvidia +COPY requirements-cuda.lock /opt/ods-laya/ +RUN python -m pip install --only-binary=:all: --no-deps --require-hashes -r /opt/ods-laya/requirements-cuda.lock \ + && python -m pip check \ + && python -c "import torch; assert torch.__version__ == '2.14.0+cu130'; assert torch.ones(2).sum().item() == 2" +USER 1000:1000 + +# Keep the portable CPU image as the default Docker build target. +FROM common AS cpu +COPY requirements-torch.lock /opt/ods-laya/ +RUN python -m pip install --only-binary=:all: --no-deps --require-hashes -r /opt/ods-laya/requirements-torch.lock \ + && python -m pip check \ + && python -c "import torch; assert torch.__version__ == '2.14.0+cpu'; assert torch.ones(2).sum().item() == 2" +USER 1000:1000 diff --git a/ods/extensions/library/services/laya/README.md b/ods/extensions/library/services/laya/README.md index 887a964365..12728e3490 100644 --- a/ods/extensions/library/services/laya/README.md +++ b/ods/extensions/library/services/laya/README.md @@ -8,8 +8,11 @@ There is no separate chat UI, provider key or model-selection step. Requires the ODS core Portal Laya integration, Docker Compose and the ODS owner's Node.js runtime. Windows runs the extension through WSL; Linux and macOS use the -same Docker recipe. The service uses CPU on all platforms, including machines -with GPUs. GPU acceleration is not part of this recipe. +same Docker recipe. CPU works on all supported platforms. On NVIDIA hosts, +ODS selects the CUDA overlay; `LAYA_ACCELERATION=auto` uses the GPU only when +enough memory is available. Set it to `cpu` to keep the extension off the GPU, +or `cuda` to require GPU startup instead of allowing CPU fallback. AMD and +Apple hosts currently use CPU. ## Installation and storage @@ -24,6 +27,18 @@ The container is limited to two CPUs and 8 GiB RAM. It verifies inference with all three checkpoints before becoming ready. Readiness is evidence that the models loaded and answered valid test questions, not an accuracy guarantee. +The NVIDIA image uses hash-locked Torch 2.14.0 with CUDA 13.0 wheels and requires +a compatible host NVIDIA driver and Docker GPU integration. Before loading +models, automatic mode keeps 2 GiB of free GPU memory as headroom and limits +Torch's allocator to at most 4 GiB and 25% of the device's total memory. If a +2 GiB allocation budget cannot fit, it uses CPU. This allocator limit does not +cover driver/context memory or reserve memory against other processes. +Only one GPU checkpoint stays resident; it unloads after 60 idle seconds. +Automatic mode retries startup allocation exhaustion once on CPU. Pinned Laya +also supports scoped CPU fallback during inference. Authenticated `/health` +reports actual resident devices and inference fallback counts; a GPU request +alone is not evidence that inference ran there. No main-model settings change. + The dedicated key is under `config/laya/`; Portal's private connection is at `~/.config/ods/laya-portal.json`. Neither contains a Dashboard or provider key. Setup preserves the key across restart and re-enable. Only its individual file diff --git a/ods/extensions/library/services/laya/compose.nvidia.yaml b/ods/extensions/library/services/laya/compose.nvidia.yaml new file mode 100644 index 0000000000..20e09d686b --- /dev/null +++ b/ods/extensions/library/services/laya/compose.nvidia.yaml @@ -0,0 +1,15 @@ +services: + laya: + build: + context: . + dockerfile: Dockerfile + target: nvidia + # One visible GPU; the process enforces its own bounded allocator and + # checks free memory before choosing CUDA. CPU remains an owner option. + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] diff --git a/ods/extensions/library/services/laya/compose.yaml b/ods/extensions/library/services/laya/compose.yaml index b909a2b506..db1e76ba01 100644 --- a/ods/extensions/library/services/laya/compose.yaml +++ b/ods/extensions/library/services/laya/compose.yaml @@ -19,7 +19,7 @@ services: - /tmp:size=256m,mode=1777 environment: HF_HOME: /cache/huggingface-${ODS_UID:-1000} - LAYA_DEVICE: cpu + LAYA_ACCELERATION: ${LAYA_ACCELERATION:-auto} LAYA_REVISION: reviewed LAYA_MODELS: english,multilingual,typed-decisions LAYA_PRELOAD: '1' diff --git a/ods/extensions/library/services/laya/manifest.yaml b/ods/extensions/library/services/laya/manifest.yaml index e597ca296a..f67a3d1753 100644 --- a/ods/extensions/library/services/laya/manifest.yaml +++ b/ods/extensions/library/services/laya/manifest.yaml @@ -22,6 +22,12 @@ service: pre_start: setup.sh description: Local classification and scoring used by the selected Portal model during a task. env_vars: + - key: LAYA_ACCELERATION + description: Acceleration (auto uses NVIDIA when memory is available; cpu keeps it on CPU) + default: auto + pattern: '^(auto|cpu|cuda)$' + format_description: auto, cpu or cuda + required: false - key: LAYA_PORT description: Local connection port default: '8017' diff --git a/ods/extensions/library/services/laya/requirements-cuda.lock b/ods/extensions/library/services/laya/requirements-cuda.lock new file mode 100644 index 0000000000..5898e6bb93 --- /dev/null +++ b/ods/extensions/library/services/laya/requirements-cuda.lock @@ -0,0 +1,84 @@ +# Official CUDA 13.0 wheels for Linux AMD64/ARM64, Python 3.11. +# Generated from the tested pip report and official wheel-index SHA-256 metadata. +--index-url https://download.pytorch.org/whl/cu130 +--extra-index-url https://pypi.nvidia.com +--extra-index-url https://pypi.org/simple + +cuda-bindings==13.3.1 \ + --hash=sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708 \ + --hash=sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474 + +cuda-pathfinder==1.6.0 \ + --hash=sha256:1503af579d8379c24bdd65528379bc57039b0455be9f5f9686cf8e473a1fce51 + +cuda-toolkit==13.0.3.0 \ + --hash=sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f + +nvidia-cublas==13.1.1.3 \ + --hash=sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436 \ + --hash=sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5 + +nvidia-cuda-cupti==13.0.85 \ + --hash=sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8 \ + --hash=sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151 + +nvidia-cuda-nvrtc==13.0.88 \ + --hash=sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575 \ + --hash=sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b + +nvidia-cuda-runtime==13.0.96 \ + --hash=sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548 \ + --hash=sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55 + +nvidia-cudnn-cu13==9.24.0.43 \ + --hash=sha256:71f181cd810e90f9b6023b01186fe82d13d65f0ec098581ee201d39fad769e4b \ + --hash=sha256:a6812a554a1ff0413e9c52b84c26c050380649ab9615f9c16bded368ce9f421f + +nvidia-cufft==12.0.0.61 \ + --hash=sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5 \ + --hash=sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3 + +nvidia-cufile==1.15.1.6 \ + --hash=sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44 \ + --hash=sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1 + +nvidia-curand==10.4.0.35 \ + --hash=sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a \ + --hash=sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc + +nvidia-cusolver==12.0.4.66 \ + --hash=sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2 \ + --hash=sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112 + +nvidia-cusparse==12.6.3.3 \ + --hash=sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b \ + --hash=sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c + +nvidia-cusparselt-cu13==0.8.1 \ + --hash=sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f \ + --hash=sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0 + +nvidia-nccl-cu13==2.30.7 \ + --hash=sha256:ca786ffa5a647c75d4d1f5cc72a6c4f537947e2ba8823d7c8aaf768e7a7b9f77 \ + --hash=sha256:cefa7fdb9710efd0f39c5f1be1d61ff6fc9a996c451265bd7fbdcf9455ed4b50 + +nvidia-nvjitlink==13.3.33 \ + --hash=sha256:26a6de7fb4c8fdaa7703d3dad720d6d427ddfea5c48a528fd97c11733ad830e5 \ + --hash=sha256:ce48b37dfeb3cb1eae4cf85adacb47d7a6539ea2272870c9a3628ce275c2037e + +nvidia-nvshmem-cu13==3.4.5 \ + --hash=sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80 \ + --hash=sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9 + +nvidia-nvtx==13.0.85 \ + --hash=sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4 \ + --hash=sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6 + +torch==2.14.0+cu130 \ + --hash=sha256:07686af6e00801b04fe0feb9e9aa22ff7a58112fb60e0b29a4146f5ac553bf6b \ + --hash=sha256:5fc2edbf7d38aa0491e517035ada66dfc5852e7fd10e8f50d062c4553d9cea16 + +triton==3.8.0 \ + --hash=sha256:372285307d4c44ee74cee32de0b4f04bd157e071427e38c6f4ee3e3beb2194f4 \ + --hash=sha256:68988ac85d5e7086baeda0ddc175af9667db7529b3c5e11a5c0601b8bef2200a \ + --hash=sha256:d88352123b2b05b46eff74464e12420c45e0d6dc5ebd279046c994c88f01abca diff --git a/ods/extensions/library/services/laya/runtime.py b/ods/extensions/library/services/laya/runtime.py new file mode 100644 index 0000000000..6a6e2b4052 --- /dev/null +++ b/ods/extensions/library/services/laya/runtime.py @@ -0,0 +1,68 @@ +"""Choose bounded optional CUDA without changing the Portal model's settings.""" +import gc +import logging +import os + +LOG = logging.getLogger('ods.laya.runtime') +MIB = 1024 * 1024 +MAX_CUDA_BYTES = 4096 * MIB +MIN_CUDA_BYTES = 2048 * MIB +HEADROOM_BYTES = 2048 * MIB + + +def select_device(torch, env): + mode = env.get('LAYA_ACCELERATION', 'auto').strip() + if mode not in {'auto', 'cpu', 'cuda'}: + raise ValueError('LAYA_ACCELERATION must be auto, cpu or cuda') + reason, budget = 'owner-selected-cpu', 0 + if mode != 'cpu': + reason = 'cuda-unavailable' + if torch.cuda.is_available(): + try: + free, total = torch.cuda.mem_get_info(0) + budget = min(MAX_CUDA_BYTES, int(total * 0.25), max(0, free - HEADROOM_BYTES)) + if budget >= MIN_CUDA_BYTES: + torch.cuda.set_per_process_memory_fraction(budget / total, 0) + return {'device': 'cuda:0', 'allocatorBytes': budget, 'reason': 'cuda-with-headroom'} + reason = 'insufficient-cuda-headroom' + except RuntimeError: + reason = 'cuda-initialization-failed' + if mode == 'cuda': + raise RuntimeError('Requested CUDA cannot satisfy the Laya memory budget: ' + reason) + return {'device': 'cpu', 'allocatorBytes': 0, 'reason': reason} + + +def build_qualified_router(build_router, qualify, *, torch=None, env=None): + if torch is None: + import torch + if env is None: + env = os.environ + selected = select_device(torch, env) + env['LAYA_DEVICE'] = selected['device'] + if selected['device'].startswith('cuda'): + # Avoid preloading all checkpoints before the memory budget takes effect. + # One resident checkpoint leaves room for the main model; idle unload + # returns allocations when the auxiliary extension is not being used. + env.update(LAYA_PRELOAD='0', LAYA_MAX_LOADED='1', LAYA_IDLE_UNLOAD_SECONDS='60') + LOG.warning('Laya runtime: device=%s allocator_bytes=%s reason=%s', + selected['device'], selected['allocatorBytes'], selected['reason']) + router = None + try: + router = build_router() + qualify(router) + return router + except torch.cuda.OutOfMemoryError: + # Retry only startup allocation exhaustion, not arbitrary model/runtime + # bugs. Inference-time scoped CPU fallback is provided by pinned Laya. + if not selected['device'].startswith('cuda') or env.get('LAYA_ACCELERATION', 'auto').strip() == 'cuda': + raise + if router is not None: + router.unload() + router = None + gc.collect() + torch.cuda.empty_cache() + env['LAYA_DEVICE'] = 'cpu' + LOG.warning('Laya startup allocation exhausted; qualifying CPU fallback') + router = build_router() + qualify(router) + return router diff --git a/ods/extensions/library/services/laya/server.py b/ods/extensions/library/services/laya/server.py index 82019cb5ca..c8cca5e9cc 100644 --- a/ods/extensions/library/services/laya/server.py +++ b/ods/extensions/library/services/laya/server.py @@ -59,11 +59,17 @@ def read_key(path): def create_portal_app(*, router=None, key_file='/run/ods-laya/api-key'): from laya.serve import build_router, create_app os.environ['LAYA_API_KEY'] = read_key(key_file) + + def qualify(candidate): + for checkpoint, language, text in STARTUP_CASES: + result = candidate.predict(text, QUESTIONS, model=checkpoint, lang=language) + validate_startup_result(result, checkpoint) + if router is None: - router = build_router() - for checkpoint, language, text in STARTUP_CASES: - result = router.predict(text, QUESTIONS, model=checkpoint, lang=language) - validate_startup_result(result, checkpoint) + from runtime import build_qualified_router + router = build_qualified_router(build_router, qualify) + else: + qualify(router) app = create_app(router) @app.get('/ready') diff --git a/ods/extensions/library/services/laya/tests/container-qualification.sh b/ods/extensions/library/services/laya/tests/container-qualification.sh index e0e1589d1f..d8b0e5b12c 100644 --- a/ods/extensions/library/services/laya/tests/container-qualification.sh +++ b/ods/extensions/library/services/laya/tests/container-qualification.sh @@ -43,3 +43,12 @@ node "$recipe/tests/lifecycle.integration.mjs" "${compose[@]}" restart laya "${compose[@]}" up --detach --no-build --wait --wait-timeout 300 node "$recipe/tests/portal_adapter.integration.mjs" +if [[ "${LAYA_TEST_CUDA_IMAGE:-0}" == 1 ]]; then + # Hosted runners have no NVIDIA GPU. Verify the actual CUDA dependency lock + # on each architecture, then run real inference through the CPU fallback. + docker build --target nvidia --tag ods-laya:qualification-cuda "$recipe" + printf 'services:\n laya:\n image: ods-laya:qualification-cuda\n' > "$fixture/qualification.yaml" + "${compose[@]}" up --detach --no-build --wait --wait-timeout 300 + export LAYA_TEST_EXPECT_DEVICE=cpu + node "$recipe/tests/portal_adapter.integration.mjs" +fi diff --git a/ods/extensions/library/services/laya/tests/portal_adapter.integration.mjs b/ods/extensions/library/services/laya/tests/portal_adapter.integration.mjs index 02b3849fb7..88f82daff5 100644 --- a/ods/extensions/library/services/laya/tests/portal_adapter.integration.mjs +++ b/ods/extensions/library/services/laya/tests/portal_adapter.integration.mjs @@ -11,6 +11,10 @@ const client = createLayaClient({token, port}); const health = await client.status(); assert.equal(health.status, 'loaded'); assert.equal(health.inferenceVerified, false, 'upstream health alone never proves inference'); +if (process.env.LAYA_TEST_EXPECT_DEVICE) { + assert.ok(health.device.startsWith(process.env.LAYA_TEST_EXPECT_DEVICE), + `Expected ${process.env.LAYA_TEST_EXPECT_DEVICE}, actual resident device is ${health.device}`); +} const readiness = await fetch(`http://127.0.0.1:${port}/ready`, {signal: AbortSignal.timeout(5000)}); assert.deepEqual(await readiness.json(), {status: 'ok', startupInferenceVerified: true}); diff --git a/ods/extensions/library/services/laya/tests/test_recipe.py b/ods/extensions/library/services/laya/tests/test_recipe.py index 37daf368aa..54720b568e 100644 --- a/ods/extensions/library/services/laya/tests/test_recipe.py +++ b/ods/extensions/library/services/laya/tests/test_recipe.py @@ -4,6 +4,8 @@ import os from pathlib import Path import re +import shutil +import subprocess import tempfile from typing import Optional import unittest @@ -21,6 +23,26 @@ class RecipeTest(unittest.TestCase): + @unittest.skipIf(os.name == 'nt', 'Actual ODS resolver runs in WSL on Windows') + def test_actual_resolver_honors_cpu_opt_out_and_nvidia_overlay(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + recipe = root / 'data/user-extensions/laya' + recipe.parent.mkdir(parents=True) + shutil.copytree(SERVICE, recipe, ignore=shutil.ignore_patterns('__pycache__')) + (root / 'docker-compose.base.yml').write_text('services: {}\n') + (root / 'docker-compose.nvidia.yml').write_text('services: {}\n') + for mode, gpu in [('auto', True), ('cpu', False), ('cuda', True)]: + (root / '.env').write_text('LAYA_ACCELERATION=' + mode + '\n') + environment = {key: value for key, value in os.environ.items() + if key not in {'LAYA_ACCELERATION', 'ODS_SKIP_GPU_OVERLAYS', + 'ODS_SKIP_GPU_OVERLAYS_FOR', 'ODS_MODE'}} + run = subprocess.run(['bash', str(ODS / 'scripts/resolve-compose-stack.sh'), + '--script-dir', str(root), '--gpu-backend', 'nvidia'], + env=environment, capture_output=True, text=True, check=True) + self.assertIn('compose.yaml', run.stdout) + self.assertEqual('compose.nvidia.yaml' in run.stdout, gpu, run.stdout + run.stderr) + def test_setup_binds_the_actual_installed_recipe(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) / 'ods' @@ -51,6 +73,8 @@ def test_manifest_and_actual_extension_scanner(self): 'CORE_SERVICE_IDS': {'dashboard', 'dashboard-api', 'llama-server', 'open-webui'}} exec(compile(source[start:end], 'ODS extension compose scanner', 'exec'), namespace) namespace['_scan_compose_content'](SERVICE / 'compose.yaml', trusted=True, extension_id='laya') + namespace['_scan_compose_content'](SERVICE / 'compose.nvidia.yaml', trusted=True, + extension_id='laya', accelerator='nvidia') def test_port_uses_the_same_literal_grammar_as_ods(self): # Import the existing ODS parser; the fixture contains only owner config. diff --git a/ods/extensions/library/services/laya/tests/test_runtime.py b/ods/extensions/library/services/laya/tests/test_runtime.py new file mode 100644 index 0000000000..7a9eef6ab0 --- /dev/null +++ b/ods/extensions/library/services/laya/tests/test_runtime.py @@ -0,0 +1,101 @@ +import importlib.util +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import Mock + +spec = importlib.util.spec_from_file_location('laya_runtime', Path(__file__).resolve().parents[1] / 'runtime.py') +runtime = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runtime) + + +class AllocationError(RuntimeError): + pass + + +def torch_fixture(free_gib=7, total_gib=16, available=True): + return SimpleNamespace(cuda=SimpleNamespace( + is_available=Mock(return_value=available), + mem_get_info=Mock(return_value=(free_gib * 1024**3, total_gib * 1024**3)), + set_per_process_memory_fraction=Mock(), empty_cache=Mock(), OutOfMemoryError=AllocationError)) + + +class RuntimeTest(unittest.TestCase): + def test_cpu_selection_never_initializes_cuda_and_invalid_modes_fail(self): + torch = torch_fixture() + self.assertEqual(runtime.select_device(torch, {'LAYA_ACCELERATION': 'cpu'})['device'], 'cpu') + torch.cuda.is_available.assert_not_called() + for value in ['nvidia', '', 'AUTO', '${GPU}', 'cuda:0']: + with self.subTest(value=value), self.assertRaises(ValueError): + runtime.select_device(torch, {'LAYA_ACCELERATION': value}) + + def test_auto_uses_cpu_when_cuda_is_absent_or_shared_memory_is_low(self): + for torch, reason in [(torch_fixture(available=False), 'cuda-unavailable'), + (torch_fixture(free_gib=3), 'insufficient-cuda-headroom'), + (torch_fixture(total_gib=4), 'insufficient-cuda-headroom')]: + with self.subTest(reason=reason): + self.assertEqual(runtime.select_device(torch, {}), + {'device': 'cpu', 'allocatorBytes': 0, 'reason': reason}) + torch.cuda.set_per_process_memory_fraction.assert_not_called() + with self.assertRaisesRegex(RuntimeError, 'Requested CUDA'): + runtime.select_device(torch, {'LAYA_ACCELERATION': 'cuda'}) + + def test_allocator_budget_respects_fraction_absolute_limit_and_headroom(self): + for free, total, expected in [(7, 16, 4), (7, 24, 4), (4, 16, 2), (6, 8, 2)]: + with self.subTest(free=free, total=total): + torch = torch_fixture(free, total) + result = runtime.select_device(torch, {}) + self.assertEqual(result['device'], 'cuda:0') + self.assertEqual(result['allocatorBytes'], expected * 1024**3) + torch.cuda.set_per_process_memory_fraction.assert_called_once_with(expected / total, 0) + + def test_driver_initialization_failure_falls_back_only_in_auto(self): + torch = torch_fixture() + torch.cuda.mem_get_info.side_effect = RuntimeError('driver initialization') + self.assertEqual(runtime.select_device(torch, {})['reason'], 'cuda-initialization-failed') + with self.assertRaisesRegex(RuntimeError, 'Requested CUDA'): + runtime.select_device(torch, {'LAYA_ACCELERATION': 'cuda'}) + + def test_budget_precedes_model_loading_and_all_startup_inference(self): + torch = torch_fixture() + env = {} + router = Mock() + + def build(): + torch.cuda.set_per_process_memory_fraction.assert_called_once() + self.assertEqual(env['LAYA_DEVICE'], 'cuda:0') + self.assertEqual(env['LAYA_PRELOAD'], '0') + self.assertEqual(env['LAYA_MAX_LOADED'], '1') + self.assertEqual(env['LAYA_IDLE_UNLOAD_SECONDS'], '60') + return router + + qualify = Mock() + self.assertIs(runtime.build_qualified_router(build, qualify, torch=torch, env=env), router) + qualify.assert_called_once_with(router) + + def test_startup_oom_releases_gpu_and_qualifies_cpu_once(self): + torch, env = torch_fixture(), {} + first, second = Mock(), Mock() + build = Mock(side_effect=[first, second]) + qualify = Mock(side_effect=[AllocationError(), None]) + self.assertIs(runtime.build_qualified_router(build, qualify, torch=torch, env=env), second) + first.unload.assert_called_once() + torch.cuda.empty_cache.assert_called_once() + self.assertEqual(env['LAYA_DEVICE'], 'cpu') + self.assertEqual(build.call_count, 2) + self.assertEqual(qualify.call_count, 2) + + def test_model_defects_and_explicit_cuda_oom_are_not_retried(self): + for mode, error in [('auto', ValueError('invalid typed result')), + ('auto', RuntimeError('kernel defect')), + ('cuda', AllocationError())]: + with self.subTest(mode=mode, error=error): + build, qualify = Mock(return_value=Mock()), Mock(side_effect=error) + with self.assertRaises(type(error)): + runtime.build_qualified_router(build, qualify, torch=torch_fixture(), + env={'LAYA_ACCELERATION': mode}) + self.assertEqual(build.call_count, 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/ods/scripts/resolve-compose-stack.sh b/ods/scripts/resolve-compose-stack.sh index c13c8301e3..47e443e61b 100755 --- a/ods/scripts/resolve-compose-stack.sh +++ b/ods/scripts/resolve-compose-stack.sh @@ -139,6 +139,11 @@ def _install_env_value(name): return value +# An owner opting out of auxiliary GPU work must also avoid the CUDA image +# and Docker device reservation, not merely run a GPU-enabled image on CPU. +if _install_env_value("LAYA_ACCELERATION") == "cpu": + skip_gpu_overlays.add("laya") + # An ODS-managed llama-server outside this stack (the Windows Portal runs # llama-server.exe while this stack runs in WSL). Its origin, not the # credential, selects the overlay. From ca89e396b0be36953f1dd294372e33e4fbc59c76 Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Wed, 7 Oct 2026 15:25:00 -0300 Subject: [PATCH 09/14] test(laya): include NVIDIA recipe in accelerator inventory --- .../dashboard-api/tests/test_curated_library_recipes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ods/extensions/services/dashboard-api/tests/test_curated_library_recipes.py b/ods/extensions/services/dashboard-api/tests/test_curated_library_recipes.py index 744dfecdcf..bf370d4a64 100644 --- a/ods/extensions/services/dashboard-api/tests/test_curated_library_recipes.py +++ b/ods/extensions/services/dashboard-api/tests/test_curated_library_recipes.py @@ -168,7 +168,7 @@ def _accelerator(compose_name): # Curated recipes whose backend overlay reserves the accelerator. The resolver # used to drop exactly these overlays, so the services started without a GPU. -NVIDIA_GPU_RECIPES = ["audiocraft", "bark", "forge", "frigate", "invokeai", "ollama", +NVIDIA_GPU_RECIPES = ["audiocraft", "bark", "forge", "frigate", "invokeai", "laya", "ollama", "rvc", "text-generation-webui", "xtts"] AMD_GPU_RECIPES = ["invokeai", "ollama", "rvc", "text-generation-webui", "xtts"] From a5d9689296be2dd624f2e09319b9e76500de288d Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Wed, 7 Oct 2026 15:30:25 -0300 Subject: [PATCH 10/14] fix(laya): rebuild selected image when updating the extension --- ods/extensions/library/services/laya/compose.yaml | 3 +++ .../services/laya/tests/container-qualification.sh | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ods/extensions/library/services/laya/compose.yaml b/ods/extensions/library/services/laya/compose.yaml index db1e76ba01..2bf1572dd2 100644 --- a/ods/extensions/library/services/laya/compose.yaml +++ b/ods/extensions/library/services/laya/compose.yaml @@ -1,5 +1,8 @@ services: laya: + # Rebuild changed sources/targets on update; an existing CPU image must + # not be reused when the selected overlay switches to CUDA. + pull_policy: build build: context: . dockerfile: Dockerfile diff --git a/ods/extensions/library/services/laya/tests/container-qualification.sh b/ods/extensions/library/services/laya/tests/container-qualification.sh index d8b0e5b12c..bcfa435b4f 100644 --- a/ods/extensions/library/services/laya/tests/container-qualification.sh +++ b/ods/extensions/library/services/laya/tests/container-qualification.sh @@ -47,8 +47,12 @@ if [[ "${LAYA_TEST_CUDA_IMAGE:-0}" == 1 ]]; then # Hosted runners have no NVIDIA GPU. Verify the actual CUDA dependency lock # on each architecture, then run real inference through the CPU fallback. docker build --target nvidia --tag ods-laya:qualification-cuda "$recipe" - printf 'services:\n laya:\n image: ods-laya:qualification-cuda\n' > "$fixture/qualification.yaml" - "${compose[@]}" up --detach --no-build --wait --wait-timeout 300 + # Keep the existing CPU image tag and use the normal update/start command. + # The recipe must rebuild for the selected target instead of silently + # recreating the old CPU image with new environment/device settings. + printf 'services:\n laya:\n image: ods-laya:qualification\n build:\n target: nvidia\n' > "$fixture/qualification.yaml" + "${compose[@]}" up --detach --wait --wait-timeout 300 + "${compose[@]}" exec -T laya python -c 'import torch; assert torch.version.cuda, torch.__version__' export LAYA_TEST_EXPECT_DEVICE=cpu node "$recipe/tests/portal_adapter.integration.mjs" fi From db5b156eb88d5559a3e72c1c7a0184c0f489ed7e Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Wed, 7 Oct 2026 15:37:59 -0300 Subject: [PATCH 11/14] test(laya): use the fixture recipe as Compose build context --- .../library/services/laya/tests/container-qualification.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ods/extensions/library/services/laya/tests/container-qualification.sh b/ods/extensions/library/services/laya/tests/container-qualification.sh index bcfa435b4f..983aeba650 100644 --- a/ods/extensions/library/services/laya/tests/container-qualification.sh +++ b/ods/extensions/library/services/laya/tests/container-qualification.sh @@ -16,7 +16,8 @@ cp -R "$ods/extensions/services/pixel-agent/plugin" "$fixture/extensions/service cp "$ods/extensions/services/dashboard-api/env_values.py" "$fixture/extensions/services/dashboard-api/" printf 'LAYA_PORT=18017\n' > "$fixture/.env" bash "$fixture/data/user-extensions/laya/setup.sh" "$fixture" -printf 'services:\n laya:\n image: ods-laya:qualification\n' > "$fixture/qualification.yaml" +printf 'services:\n laya:\n image: ods-laya:qualification\n build:\n context: "%s"\n' \ + "$fixture/data/user-extensions/laya" > "$fixture/qualification.yaml" compose=(docker compose --project-name ods-laya-qualification --project-directory "$fixture" -f "$fixture/data/user-extensions/laya/compose.yaml" -f "$fixture/qualification.yaml") "${compose[@]}" config --quiet @@ -50,7 +51,8 @@ if [[ "${LAYA_TEST_CUDA_IMAGE:-0}" == 1 ]]; then # Keep the existing CPU image tag and use the normal update/start command. # The recipe must rebuild for the selected target instead of silently # recreating the old CPU image with new environment/device settings. - printf 'services:\n laya:\n image: ods-laya:qualification\n build:\n target: nvidia\n' > "$fixture/qualification.yaml" + printf 'services:\n laya:\n image: ods-laya:qualification\n build:\n context: "%s"\n target: nvidia\n' \ + "$fixture/data/user-extensions/laya" > "$fixture/qualification.yaml" "${compose[@]}" up --detach --wait --wait-timeout 300 "${compose[@]}" exec -T laya python -c 'import torch; assert torch.version.cuda, torch.__version__' export LAYA_TEST_EXPECT_DEVICE=cpu From b84e4f8ed658bef6c4fe7b1d5690feb36adf7be4 Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Wed, 7 Oct 2026 15:50:22 -0300 Subject: [PATCH 12/14] fix(laya): distinguish CPU and CUDA image identities on update --- .../library/services/laya/compose.nvidia.yaml | 1 + .../library/services/laya/compose.yaml | 3 +++ .../laya/tests/container-qualification.sh | 23 ++++++++++++------- .../services/laya/tests/test_recipe.py | 7 ++++++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/ods/extensions/library/services/laya/compose.nvidia.yaml b/ods/extensions/library/services/laya/compose.nvidia.yaml index 20e09d686b..094fd8d0eb 100644 --- a/ods/extensions/library/services/laya/compose.nvidia.yaml +++ b/ods/extensions/library/services/laya/compose.nvidia.yaml @@ -1,5 +1,6 @@ services: laya: + image: ods/laya:0.3.28-cuda130-v1 build: context: . dockerfile: Dockerfile diff --git a/ods/extensions/library/services/laya/compose.yaml b/ods/extensions/library/services/laya/compose.yaml index 2bf1572dd2..733b970628 100644 --- a/ods/extensions/library/services/laya/compose.yaml +++ b/ods/extensions/library/services/laya/compose.yaml @@ -1,5 +1,8 @@ services: laya: + # Separate tags make backend changes visible to Compose even when an + # older engine does not recreate a container after rebuilding one tag. + image: ods/laya:0.3.28-cpu-v1 # Rebuild changed sources/targets on update; an existing CPU image must # not be reused when the selected overlay switches to CUDA. pull_policy: build diff --git a/ods/extensions/library/services/laya/tests/container-qualification.sh b/ods/extensions/library/services/laya/tests/container-qualification.sh index 983aeba650..60912f5d5b 100644 --- a/ods/extensions/library/services/laya/tests/container-qualification.sh +++ b/ods/extensions/library/services/laya/tests/container-qualification.sh @@ -16,16 +16,17 @@ cp -R "$ods/extensions/services/pixel-agent/plugin" "$fixture/extensions/service cp "$ods/extensions/services/dashboard-api/env_values.py" "$fixture/extensions/services/dashboard-api/" printf 'LAYA_PORT=18017\n' > "$fixture/.env" bash "$fixture/data/user-extensions/laya/setup.sh" "$fixture" -printf 'services:\n laya:\n image: ods-laya:qualification\n build:\n context: "%s"\n' \ +printf 'services:\n laya:\n build:\n context: "%s"\n' \ "$fixture/data/user-extensions/laya" > "$fixture/qualification.yaml" compose=(docker compose --project-name ods-laya-qualification --project-directory "$fixture" -f "$fixture/data/user-extensions/laya/compose.yaml" -f "$fixture/qualification.yaml") "${compose[@]}" config --quiet -docker build --tag ods-laya:qualification "$recipe" +cpu_image="$("${compose[@]}" config --images)" +docker build --tag "$cpu_image" "$recipe" docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges:true \ --tmpfs /tmp:size=256m,mode=1777 \ --mount "type=bind,source=$recipe,target=/recipe,readonly" \ - --entrypoint python ods-laya:qualification -m unittest discover -s /recipe/tests -p test_service.py -v + --entrypoint python "$cpu_image" -m unittest discover -s /recipe/tests -p test_service.py -v docker network create ods-network cleanup() { status=$? @@ -47,12 +48,18 @@ node "$recipe/tests/portal_adapter.integration.mjs" if [[ "${LAYA_TEST_CUDA_IMAGE:-0}" == 1 ]]; then # Hosted runners have no NVIDIA GPU. Verify the actual CUDA dependency lock # on each architecture, then run real inference through the CPU fallback. - docker build --target nvidia --tag ods-laya:qualification-cuda "$recipe" - # Keep the existing CPU image tag and use the normal update/start command. - # The recipe must rebuild for the selected target instead of silently - # recreating the old CPU image with new environment/device settings. - printf 'services:\n laya:\n image: ods-laya:qualification\n build:\n context: "%s"\n target: nvidia\n' \ + # Use the shipped overlay and its distinct backend image identity. Remove + # only the device reservation: these runners have no NVIDIA device. + printf 'services:\n laya:\n build:\n context: "%s"\n deploy:\n resources:\n reservations:\n devices: !reset []\n' \ "$fixture/data/user-extensions/laya" > "$fixture/qualification.yaml" + compose=(docker compose --project-name ods-laya-qualification --project-directory "$fixture" + -f "$fixture/data/user-extensions/laya/compose.yaml" + -f "$fixture/data/user-extensions/laya/compose.nvidia.yaml" -f "$fixture/qualification.yaml") + cuda_image="$("${compose[@]}" config --images)" + [[ "$cuda_image" != "$cpu_image" ]] + # Cache the selected tag with the old CPU bytes. A normal start/update must + # build the CUDA target and replace the CPU container, without force flags. + docker tag "$cpu_image" "$cuda_image" "${compose[@]}" up --detach --wait --wait-timeout 300 "${compose[@]}" exec -T laya python -c 'import torch; assert torch.version.cuda, torch.__version__' export LAYA_TEST_EXPECT_DEVICE=cpu diff --git a/ods/extensions/library/services/laya/tests/test_recipe.py b/ods/extensions/library/services/laya/tests/test_recipe.py index 54720b568e..1fcead795d 100644 --- a/ods/extensions/library/services/laya/tests/test_recipe.py +++ b/ods/extensions/library/services/laya/tests/test_recipe.py @@ -23,6 +23,13 @@ class RecipeTest(unittest.TestCase): + def test_backend_transition_has_a_distinct_compose_image_identity(self): + cpu = yaml.safe_load((SERVICE / 'compose.yaml').read_text())['services']['laya'] + gpu = yaml.safe_load((SERVICE / 'compose.nvidia.yaml').read_text())['services']['laya'] + self.assertNotEqual(cpu['image'], gpu['image']) + self.assertEqual(cpu['pull_policy'], 'build') + self.assertEqual(gpu['build']['target'], 'nvidia') + @unittest.skipIf(os.name == 'nt', 'Actual ODS resolver runs in WSL on Windows') def test_actual_resolver_honors_cpu_opt_out_and_nvidia_overlay(self): with tempfile.TemporaryDirectory() as directory: From ed86de990cbbf58135c4981f368929a0fb6fffa2 Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Wed, 7 Oct 2026 15:53:12 -0300 Subject: [PATCH 13/14] fix(laya): declare local build policy in the NVIDIA overlay --- ods/extensions/library/services/laya/compose.nvidia.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/ods/extensions/library/services/laya/compose.nvidia.yaml b/ods/extensions/library/services/laya/compose.nvidia.yaml index 094fd8d0eb..887d5396b0 100644 --- a/ods/extensions/library/services/laya/compose.nvidia.yaml +++ b/ods/extensions/library/services/laya/compose.nvidia.yaml @@ -1,6 +1,7 @@ services: laya: image: ods/laya:0.3.28-cuda130-v1 + pull_policy: build build: context: . dockerfile: Dockerfile From 09cd72ea5791e96e8b2d288f9ac30d1f5d6910b9 Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Wed, 7 Oct 2026 17:11:10 -0300 Subject: [PATCH 14/14] fix(ci): reuse installed tools and bound contract suite setup --- .github/scripts/test_ci_tool_setup.py | 122 ++++++++++++++++++++++++++ .github/workflows/test-ci-suite.yml | 20 ++++- 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/test_ci_tool_setup.py diff --git a/.github/scripts/test_ci_tool_setup.py b/.github/scripts/test_ci_tool_setup.py new file mode 100644 index 0000000000..2b5152725b --- /dev/null +++ b/.github/scripts/test_ci_tool_setup.py @@ -0,0 +1,122 @@ +"""Exercise the workflow's tool preparation without network or root access.""" +import os +from pathlib import Path +import shutil +import subprocess +import unittest + +import yaml + + +WORKFLOW = Path(__file__).resolve().parents[1] / "workflows/test-ci-suite.yml" +SHELL = shutil.which("bash") + + +@unittest.skipUnless(SHELL and os.name == "posix", "POSIX bash is required") +class ToolSetupTests(unittest.TestCase): + def run_case(self, mode): + workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + step = next(step for step in workflow["jobs"]["suite"]["steps"] + if step.get("name") == "Install test tools") + self.assertEqual(step["timeout-minutes"], 10) + # Simulate tool presence and APT results; the workflow's actual shell + # selects which packages to install and propagates all failures. + fake = r''' +HAS_JQ=1 +HAS_SHELLCHECK=1 +case "$MODE" in + missing_jq) HAS_JQ=0 ;; + missing_shellcheck) HAS_SHELLCHECK=0 ;; + missing_both|update_fail|install_fail) HAS_JQ=0; HAS_SHELLCHECK=0 ;; +esac +python() { :; } +command() { + if [ "$1" = -v ]; then + case "$2" in + jq) test "$HAS_JQ" = 1; return ;; + shellcheck) test "$HAS_SHELLCHECK" = 1; return ;; + esac + fi + builtin command "$@" +} +sudo() { + printf 'APT %s\n' "$*" + case " $* " in + *" update "*) test "$MODE" != update_fail || return 100 ;; + *" install "*) + test "$MODE" != install_fail || return 124 + for arg in "$@"; do + case "$arg" in + jq) HAS_JQ=1 ;; + shellcheck) HAS_SHELLCHECK=1 ;; + esac + done ;; + *) return 99 ;; + esac +} +jq() { test "$HAS_JQ" = 1 || return 127; printf 'JQ-VERIFIED\n'; } +shellcheck() { + test "$HAS_SHELLCHECK" = 1 || return 127 + test "$MODE" != broken_tool || return 37 + printf 'SHELLCHECK-VERIFIED\n' +} +''' + result = subprocess.run( + [SHELL, "-e", "-c", fake + step["run"]], + env={**os.environ, "MODE": mode}, capture_output=True, text=True, timeout=10, + ) + calls = [line for line in result.stdout.splitlines() if line.startswith("APT ")] + for call in calls: + self.assertIn("timeout 180 apt-get", call) + self.assertIn("Acquire::Retries=2", call) + self.assertIn("Acquire::http::Timeout=30", call) + self.assertIn("Acquire::https::Timeout=30", call) + self.assertNotIn("-qq", call) + self.assertNotIn("allow-unauthenticated", call) + if "update" in call.split(): + self.assertIn("APT::Update::Error-Mode=any", call) + else: + self.assertIn("DEBIAN_FRONTEND=noninteractive", call) + return result, calls + + def test_present_tools_are_verified_without_package_network(self): + result, calls = self.run_case("present") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(calls, []) + self.assertIn("JQ-VERIFIED", result.stdout) + self.assertIn("SHELLCHECK-VERIFIED", result.stdout) + + def test_only_missing_tools_are_installed_and_then_verified(self): + for mode, packages in (("missing_jq", ["jq"]), ("missing_shellcheck", ["shellcheck"]), + ("missing_both", ["jq", "shellcheck"])): + with self.subTest(mode=mode): + result, calls = self.run_case(mode) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(calls), 2) + self.assertTrue(calls[0].endswith(" update")) + self.assertTrue(calls[1].endswith(" install -y " + " ".join(packages))) + self.assertIn("JQ-VERIFIED", result.stdout) + self.assertIn("SHELLCHECK-VERIFIED", result.stdout) + + def test_failed_refresh_never_installs_from_stale_indexes(self): + result, calls = self.run_case("update_fail") + self.assertEqual(result.returncode, 100) + self.assertEqual(len(calls), 1) + self.assertTrue(calls[0].endswith(" update")) + self.assertNotIn("JQ-VERIFIED", result.stdout) + + def test_install_timeout_stops_before_running_tests(self): + result, calls = self.run_case("install_fail") + self.assertEqual(result.returncode, 124) + self.assertEqual(len(calls), 2) + self.assertNotIn("JQ-VERIFIED", result.stdout) + + def test_broken_existing_tool_still_fails(self): + result, calls = self.run_case("broken_tool") + self.assertEqual(result.returncode, 37) + self.assertEqual(calls, []) + self.assertNotIn("SHELLCHECK-VERIFIED", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/test-ci-suite.yml b/.github/workflows/test-ci-suite.yml index b393aa8863..ea97268354 100644 --- a/.github/workflows/test-ci-suite.yml +++ b/.github/workflows/test-ci-suite.yml @@ -25,6 +25,8 @@ jobs: run: python -m pip install 'PyYAML>=6,<7' - name: Self-test the inventory run: python -m unittest discover -s .github/scripts -p test_list_unwired_tests.py + - name: Test tool preparation contracts + run: python -m unittest discover -s .github/scripts -p test_ci_tool_setup.py - name: Every test runs in CI or is recorded as not run run: python .github/scripts/list-unwired-tests.py --check @@ -43,8 +45,24 @@ jobs: with: node-version: '22' - name: Install test tools + timeout-minutes: 10 run: | python -m pip install 'pytest>=8,<9' 'PyYAML>=6,<7' 'jsonschema>=4,<5' 'httpx>=0.27,<1' 'requests>=2,<3' - sudo apt-get update -qq && sudo apt-get install -y -qq jq shellcheck > /dev/null + missing=() + for tool in jq shellcheck; do + if ! command -v "$tool" >/dev/null 2>&1; then + missing+=("$tool") + fi + done + if (( ${#missing[@]} )); then + sudo timeout 180 apt-get -o Acquire::Retries=2 \ + -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 \ + -o APT::Update::Error-Mode=any update + sudo env DEBIAN_FRONTEND=noninteractive timeout 180 apt-get \ + -o Acquire::Retries=2 -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 install -y "${missing[@]}" + fi + jq --version + shellcheck --version - name: Run tests/ci-suite.txt run: bash tests/run-ci-suite.sh