Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
64e6dc1
feat(extensions): package Laya for managed Portal decisions
gabsprogrammer Oct 6, 2026
9a77646
test(laya): mount temporary storage for readonly container checks
gabsprogrammer Oct 6, 2026
97de4d5
Merge branch 'feat/portal-laya-integration' into feat/portal-laya-ser…
gabsprogrammer Oct 6, 2026
061eae0
test(laya): verify complete context after increasing the token budget
gabsprogrammer Oct 6, 2026
0140dc4
Merge branch 'feat/portal-laya-integration' into feat/portal-laya-ser…
gabsprogrammer Oct 6, 2026
37e06cd
test(laya): include service in bounded catalog guidance
gabsprogrammer Oct 6, 2026
5ad7fcf
Merge branch 'feat/portal-laya-integration' into feat/portal-laya-ser…
gabsprogrammer Oct 6, 2026
4ca6870
Merge branch 'feat/portal-laya-integration' into feat/portal-laya-ser…
gabsprogrammer Oct 6, 2026
5899b0f
fix(laya): register the optional service port in env validation
gabsprogrammer Oct 6, 2026
5407e00
Merge branch 'feat/portal-laya-integration' into feat/portal-laya-ser…
gabsprogrammer Oct 6, 2026
77e1343
Merge branch 'feat/portal-laya-integration' into feat/portal-laya-ser…
gabsprogrammer Oct 6, 2026
c2e817a
fix(laya): configure the recipe from its actual installed directory
gabsprogrammer Oct 6, 2026
bf2e02c
Merge branch 'feat/portal-laya-integration' into feat/portal-laya-ser…
gabsprogrammer Oct 6, 2026
010957f
docs(laya): explain workspace batches and whole-task tradeoffs
gabsprogrammer Oct 6, 2026
256f00f
Merge branch 'feat/portal-laya-integration' into feat/portal-laya-ser…
gabsprogrammer Oct 6, 2026
98a429f
Merge branch 'feat/portal-laya-integration' into feat/portal-laya-ser…
gabsprogrammer Oct 7, 2026
99b5221
feat(laya): support bounded NVIDIA inference with CPU fallback
gabsprogrammer Oct 7, 2026
ca89e39
test(laya): include NVIDIA recipe in accelerator inventory
gabsprogrammer Oct 7, 2026
a5d9689
fix(laya): rebuild selected image when updating the extension
gabsprogrammer Oct 7, 2026
db5b156
test(laya): use the fixture recipe as Compose build context
gabsprogrammer Oct 7, 2026
b84e4f8
fix(laya): distinguish CPU and CUDA image identities on update
gabsprogrammer Oct 7, 2026
ed86de9
fix(laya): declare local build policy in the NVIDIA overlay
gabsprogrammer Oct 7, 2026
e56e3f2
Merge remote-tracking branch 'origin/feat/portal-laya-integration' in…
gabsprogrammer Oct 7, 2026
09cd72e
fix(ci): reuse installed tools and bound contract suite setup
gabsprogrammer Oct 7, 2026
7210182
Merge branch 'feat/portal-laya-integration' into feat/portal-laya-ser…
gabsprogrammer Oct 7, 2026
def3e06
Merge branch 'feat/portal-laya-integration' into feat/portal-laya-ser…
gabsprogrammer Oct 7, 2026
82fb49e
Merge branch 'feat/portal-laya-integration' into feat/portal-laya-ser…
gabsprogrammer Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions .github/scripts/test_ci_tool_setup.py
Original file line number Diff line number Diff line change
@@ -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()
43 changes: 43 additions & 0 deletions .github/workflows/laya-service.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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: 45
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: 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
20 changes: 19 additions & 1 deletion .github/workflows/test-ci-suite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
13 changes: 13 additions & 0 deletions ods/.env.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1382,6 +1382,19 @@
"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",
"minimum": 1,
"maximum": 65535,
"default": 8017
},
"LIBRECHAT_PORT": {
"type": "integer",
"description": "LibreChat external port",
Expand Down
63 changes: 62 additions & 1 deletion ods/config/extensions-catalog.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generated_at": "2026-10-06T14:59:46Z",
"generated_at": "2026-10-07T18:06:30Z",
"schema_version": "1.0.0",
"extensions": [
{
Expand Down Expand Up @@ -5153,6 +5153,67 @@
"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_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",
"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",
Expand Down
8 changes: 8 additions & 0 deletions ods/extensions/library/services/laya/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
*
!Dockerfile
!requirements.lock
!requirements-torch.lock
!requirements-cuda.lock
!server.py
!health.py
!runtime.py
29 changes: 29 additions & 0 deletions ods/extensions/library/services/laya/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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 /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"
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
Loading
Loading