@plumbus/ai-decision-laya 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ import { type DecisionHttpConfig, type DecisionProviderAdapter } from '@plumbus/ai-decision';
2
+ export interface LayaDecisionAdapterConfig extends DecisionHttpConfig {
3
+ /** auto routes by language; otherwise selects a configured checkpoint. */
4
+ model?: string;
5
+ language?: string;
6
+ /** Operator-supplied infrastructure estimate. Omit to report unknown cost. */
7
+ costPerRequestUsd?: number;
8
+ }
9
+ /** Calls the persistent Python service shipped in this package's service/ directory. */
10
+ export declare function createLayaDecisionAdapter(config: LayaDecisionAdapterConfig): DecisionProviderAdapter;
11
+ //# sourceMappingURL=laya-adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"laya-adapter.d.ts","sourceRoot":"","sources":["../src/laya-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,kBAAkB,EACvB,KAAK,uBAAuB,EAC7B,MAAM,sBAAsB,CAAC;AAG9B,MAAM,WAAW,yBAA0B,SAAQ,kBAAkB;IACnE,0EAA0E;IAC1E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8EAA8E;IAC9E,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,wFAAwF;AACxF,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,yBAAyB,GAChC,uBAAuB,CAgDzB"}
@@ -0,0 +1,41 @@
1
+ import { createDecisionHttpTransport, DecisionProviderError, parseDecisionResponse, toSystemOneQuestions, validateDecisionRequest, } from '@plumbus/ai-decision';
2
+ import { z } from '@plumbus/core/zod';
3
+ /** Calls the persistent Python service shipped in this package's service/ directory. */
4
+ export function createLayaDecisionAdapter(config) {
5
+ const settings = z
6
+ .object({
7
+ model: z.string().trim().min(1).max(256).default('auto'),
8
+ language: z.string().trim().min(1).max(64).optional(),
9
+ costPerRequestUsd: z.number().finite().nonnegative().optional(),
10
+ })
11
+ .safeParse(config);
12
+ if (!settings.success)
13
+ throw new DecisionProviderError('laya', 'configuration', 'Invalid Laya model, language, or cost configuration');
14
+ const post = createDecisionHttpTransport('laya', config);
15
+ return {
16
+ name: 'laya',
17
+ async decide(request) {
18
+ const input = validateDecisionRequest(request, 'laya');
19
+ const start = performance.now();
20
+ const model = input.model ?? settings.data.model;
21
+ const wire = await post({
22
+ state: input.state,
23
+ questions: toSystemOneQuestions(input.questions),
24
+ ...(model === 'auto' ? {} : { model }),
25
+ ...(settings.data.language ? { lang: settings.data.language } : {}),
26
+ }, input);
27
+ const result = parseDecisionResponse(wire, input.questions, 'laya');
28
+ if (!z.object({ routing: z.object({}) }).safeParse(result).success) {
29
+ throw new DecisionProviderError('laya', 'invalid_response', 'Laya response is missing checkpoint routing identity', { model: result.model, usage: result.usage });
30
+ }
31
+ const cost = settings.data.costPerRequestUsd ?? null;
32
+ return {
33
+ ...result,
34
+ cost,
35
+ costAvailable: cost !== null,
36
+ latencyMs: performance.now() - start,
37
+ };
38
+ },
39
+ };
40
+ }
41
+ //# sourceMappingURL=laya-adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"laya-adapter.js","sourceRoot":"","sources":["../src/laya-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,2BAA2B,EAC3B,qBAAqB,EACrB,qBAAqB,EACrB,oBAAoB,EACpB,uBAAuB,GAGxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,CAAC,EAAE,MAAM,mBAAmB,CAAC;AAUtC,wFAAwF;AACxF,MAAM,UAAU,yBAAyB,CACvC,MAAiC;IAEjC,MAAM,QAAQ,GAAG,CAAC;SACf,MAAM,CAAC;QACN,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC;QACxD,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE;QACrD,iBAAiB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,CAAC,WAAW,EAAE,CAAC,QAAQ,EAAE;KAChE,CAAC;SACD,SAAS,CAAC,MAAM,CAAC,CAAC;IACrB,IAAI,CAAC,QAAQ,CAAC,OAAO;QACnB,MAAM,IAAI,qBAAqB,CAC7B,MAAM,EACN,eAAe,EACf,qDAAqD,CACtD,CAAC;IACJ,MAAM,IAAI,GAAG,2BAA2B,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzD,OAAO;QACL,IAAI,EAAE,MAAM;QACZ,KAAK,CAAC,MAAM,CAAC,OAAO;YAClB,MAAM,KAAK,GAAG,uBAAuB,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YACvD,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC;YAChC,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,IAAI,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC;YACjD,MAAM,IAAI,GAAG,MAAM,IAAI,CACrB;gBACE,KAAK,EAAE,KAAK,CAAC,KAAK;gBAClB,SAAS,EAAE,oBAAoB,CAAC,KAAK,CAAC,SAAS,CAAC;gBAChD,GAAG,CAAC,KAAK,KAAK,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC;gBACtC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aACpE,EACD,KAAK,CACN,CAAC;YACF,MAAM,MAAM,GAAG,qBAAqB,CAAC,IAAI,EAAE,KAAK,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;YACpE,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,CAAC;gBACnE,MAAM,IAAI,qBAAqB,CAC7B,MAAM,EACN,kBAAkB,EAClB,sDAAsD,EACtD,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAC7C,CAAC;YACJ,CAAC;YACD,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,iBAAiB,IAAI,IAAI,CAAC;YACrD,OAAO;gBACL,GAAG,MAAM;gBACT,IAAI;gBACJ,aAAa,EAAE,IAAI,KAAK,IAAI;gBAC5B,SAAS,EAAE,WAAW,CAAC,GAAG,EAAE,GAAG,KAAK;aACrC,CAAC;QACJ,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,12 @@
1
+ # @plumbus/ai-decision-laya — agent instructions
2
+
3
+ | File | When to read |
4
+ | --- | --- |
5
+ | [framework.md](framework.md) | Installation, public exports, adapter boundary, usage rules |
6
+ | [testing.md](testing.md) | Offline validation and live environment setup |
7
+
8
+ Keep app business logic in Plumbus primitives and `ctx.*`. These packages do not
9
+ add `ctx.ai.decide()` or automatic provider discovery yet. Never route model
10
+ predictions directly around capability access checks or confirmation policy.
11
+
12
+ [Package README](../README.md) · [Monorepo guide](https://github.com/plumbus-framework/plumbus/blob/main/docs/ai/decision-providers.md)
@@ -0,0 +1,46 @@
1
+ # Framework boundary
2
+
3
+ Laya provider for self-hosted typed decision inference, with a persistent Python reference service.
4
+
5
+ ## Install and ownership
6
+
7
+ Install `@plumbus/ai-decision-laya` explicitly. Its core peer is exactly `"0.7.x"`.
8
+ Consumers reuse Zod/testing utilities from core; do not install them separately.
9
+ Use this package for bounded typed decisions. Use core generation and embeddings
10
+ for text responses and vector inference.
11
+
12
+ Public exports: `createLayaDecisionAdapter` — construct an explicitly configured adapter; `DecisionProviderError` — structured failures; `DecisionProviderAdapter`, `DecisionQuestions`, `DecisionRequest`, `DecisionResult` — protocol types.
13
+
14
+ File map: `src/index.ts` is the public barrel, `src/__tests__/` exercises the public
15
+ API, `instructions/` contains these recipes. Laya alone also ships `service/`.
16
+
17
+ ## Minimal usage
18
+
19
+ ```ts
20
+ import { createLayaDecisionAdapter } from '@plumbus/ai-decision-laya';
21
+
22
+ const adapter = createLayaDecisionAdapter({ baseUrl: 'http://127.0.0.1:8080/v1', apiKey: process.env.LAYA_API_KEY });
23
+ const result = await adapter.decide({
24
+ state: 'Please refund the duplicate charge.',
25
+ questions: { refund: { type: 'probability', instructions: 'Is a refund requested?' } },
26
+ });
27
+ console.log(result.answers.refund.probability);
28
+ ```
29
+
30
+
31
+ ## Required rules
32
+
33
+ 1. Keep capabilities, flows, access policy and `ctx.*` as the application implementation path.
34
+ 2. Construct adapters at a server-owned infrastructure boundary and inject them. Keep keys on the server.
35
+ 3. Treat direct adapter usage as protocol integration, not a replacement for core's security/budget/audit lifecycle.
36
+ 4. Do not monkeypatch `ctx.ai`, register these as completion providers, or advertise `ctx.ai.decide()` as available.
37
+ 5. Preserve distributions and provider confidence separately. A high confidence value is not authorization or proof of correctness.
38
+ 6. Validate thresholds on the actual task/model/language. Unknown cost is `null`, not free.
39
+ 7. Pass cancellation/deadlines explicitly during this package-only stage.
40
+ 8. Keep Python inference outside Node.js. Use the packaged Laya service or implement its documented HTTP protocol.
41
+ 9. There are no core CLI commands, environment discovery, or `plumbus init` instruction wiring for these packages yet.
42
+
43
+ Chat, voice and MCP still consume Plumbus capabilities; this package does not
44
+ introduce a parallel runtime or transport for those surfaces.
45
+
46
+ See `docs/ai/decision-providers.md` in the monorepo for the full API and configuration.
@@ -0,0 +1,19 @@
1
+ # Testing
2
+
3
+ Use injected `fetch` for offline adapter tests. In consumer applications, test
4
+ business behavior through core's `runCapability()` / `simulateFlow()` and run
5
+ `plumbus test`; do not add separate Vitest or Zod dependencies.
6
+
7
+ In the framework repository, run `pnpm lint`, `pnpm format:check`,
8
+ `pnpm typecheck`, and `pnpm test`. Laya's offline service contract test needs
9
+ `python3` but imports no model libraries and downloads nothing.
10
+
11
+ For live tests, follow [Providing a live test environment](https://github.com/plumbus-framework/plumbus/blob/main/docs/ai/decision-providers.md#providing-a-live-test-environment).
12
+ Provide a private env-file path, not pasted secrets. Set
13
+ `PLUMBUS_LIVE_DECISION_TESTS=1`, the provider key, and the reachable Laya base URL
14
+ when applicable. Run the provider's `scripts/smoke.mjs` from its monorepo directory
15
+ after building. Scripts are repository tools, not installed core CLI commands.
16
+
17
+ Validate choices, scores, probabilities, actual model/routing, usage and unknown
18
+ cost handling. Measure live accuracy and calibration separately from connection
19
+ success. Do not assert a universal confidence cutoff or guaranteed live label.
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@plumbus/ai-decision-laya",
3
+ "version": "0.2.0",
4
+ "description": "Self-hosted Laya typed decision adapter and Python reference service for Plumbus.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "instructions",
17
+ "README.md",
18
+ "CHANGELOG.md",
19
+ "service"
20
+ ],
21
+ "keywords": [
22
+ "plumbus",
23
+ "ai",
24
+ "decision"
25
+ ],
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/plumbus-framework/plumbus.git",
30
+ "directory": "packages/ai-decision-laya"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "engines": {
36
+ "node": ">=20.6.0"
37
+ },
38
+ "peerDependencies": {
39
+ "@plumbus/core": "0.7.x"
40
+ },
41
+ "devDependencies": {
42
+ "@biomejs/biome": "^2.4.6",
43
+ "@types/node": "^25.4.0",
44
+ "typescript": "^5.7.0",
45
+ "vitest": "^3.2.4",
46
+ "@plumbus/core": "0.7.2"
47
+ },
48
+ "dependencies": {
49
+ "@plumbus/ai-decision": "~0.2.0"
50
+ },
51
+ "scripts": {
52
+ "build": "tsc -b",
53
+ "dev": "tsc -b --watch",
54
+ "test": "vitest run",
55
+ "lint": "biome lint ./src",
56
+ "format": "biome format --write ./src",
57
+ "format:check": "biome format ./src",
58
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
59
+ "smoke": "pnpm build && node scripts/smoke.mjs"
60
+ }
61
+ }
@@ -0,0 +1,14 @@
1
+ FROM python:3.11-slim
2
+ WORKDIR /service
3
+ COPY requirements.txt .
4
+ # CPU reference image. GPU environments should provision their CUDA-compatible torch separately.
5
+ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \
6
+ && pip install --no-cache-dir -r requirements.txt
7
+ COPY server.py .
8
+ RUN useradd --create-home laya \
9
+ && mkdir -p /home/laya/.cache/huggingface \
10
+ && chown -R laya:laya /home/laya/.cache
11
+ USER laya
12
+ ENV LAYA_HOST=0.0.0.0 LAYA_PORT=8080 LAYA_DEVICE=cpu HF_HOME=/home/laya/.cache/huggingface
13
+ EXPOSE 8080
14
+ CMD ["python", "server.py"]
@@ -0,0 +1,28 @@
1
+ """E2E-only inference fixture. This module is never used by the production entrypoint."""
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
7
+ from server import create_server
8
+
9
+
10
+ class Backend:
11
+ names = ("english", "multilingual")
12
+
13
+ def predict(self, body):
14
+ answers = {}
15
+ for key, question in body["questions"].items():
16
+ if question["type"] == "noul":
17
+ answers[key] = {"type": "noul", "noul": 0.75, "confidence": 0.75}
18
+ elif question["type"] == "choice":
19
+ labels = list(question["criteria"])
20
+ answers[key] = {"type": "choice", "choice": labels[0], "confidence": 1, "probabilities": {label: int(i == 0) for i, label in enumerate(labels)}}
21
+ else:
22
+ answers[key] = {"type": "score", "score": 1, "confidence": 1, "legend": {str(i): v for i, v in enumerate(question["criteria"])}, "probabilities": {str(i): int(i == 1) for i, _ in enumerate(question["criteria"])}}
23
+ return {"model": "fixture", "answers": answers, "usage": {"input_tokens": 12, "output_tokens": 0}, "routing": {"model": body.get("model", "multilingual"), "repo": "fixture", "reason": "Fixture routing"}}
24
+
25
+
26
+ server = create_server(("127.0.0.1", 0), Backend(), "e2e-key")
27
+ print(json.dumps({"port": server.server_address[1]}), flush=True)
28
+ server.serve_forever()
@@ -0,0 +1,2 @@
1
+ # Inference dependencies are installed only in the separately deployed Python service.
2
+ laya==0.3.5
@@ -0,0 +1,287 @@
1
+ """Small, authenticated Laya HTTP service; inference stays outside Node.js.
2
+
3
+ Run one process per device. The lock serializes inference and checkpoint access.
4
+ Only configured, preloaded checkpoints are served; no request downloads models.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hmac
10
+ import json
11
+ import math
12
+ import os
13
+ import re
14
+ import threading
15
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16
+
17
+
18
+ MAX_BODY_BYTES = 2 * 1024 * 1024
19
+
20
+
21
+ class RequestError(Exception):
22
+ """A safe client error; never put submitted state or credentials in its text."""
23
+
24
+
25
+ def validate_json(value, depth=0):
26
+ """JSON decoder extensions and invalid Unicode must not reach the model."""
27
+ if depth > 64:
28
+ raise RequestError("JSON nesting exceeds 64 levels")
29
+ if isinstance(value, str):
30
+ try:
31
+ value.encode("utf-8", errors="strict")
32
+ except UnicodeError as error:
33
+ raise RequestError("Invalid Unicode in JSON") from error
34
+ elif isinstance(value, (int, float)):
35
+ try:
36
+ if not math.isfinite(value):
37
+ raise RequestError("Non-finite numbers are not JSON")
38
+ except OverflowError as error:
39
+ raise RequestError("Number exceeds the supported range") from error
40
+ elif isinstance(value, dict):
41
+ for key, item in value.items():
42
+ validate_json(key, depth + 1)
43
+ validate_json(item, depth + 1)
44
+ elif isinstance(value, list):
45
+ for item in value:
46
+ validate_json(item, depth + 1)
47
+
48
+
49
+ def unique_object(pairs):
50
+ value = {}
51
+ for key, item in pairs:
52
+ if key in value:
53
+ raise RequestError("Duplicate JSON keys are not supported")
54
+ value[key] = item
55
+ return value
56
+
57
+
58
+ def check_token_budget(agent, state, questions, render_options, serialize_state):
59
+ """Reject truncation using the formatting/token budgets of pinned Laya 0.3.5.
60
+
61
+ This deliberately rejects long option descriptions even if their distinguishing
62
+ words happen to fit. Updating Laya requires revalidating this boundary.
63
+ """
64
+ tok = agent.tok
65
+ mask = tok.mask_token
66
+ max_len = agent.cfg.get("max_len", 512)
67
+ head_max_len = agent.cfg.get("head_max_len", 192)
68
+ state_tokens = tok(serialize_state(state).replace(mask, " "), add_special_tokens=False)["input_ids"]
69
+ for question in questions.values():
70
+ internal = agent._to_internal(question)
71
+ instructions = str(internal["ins"]).replace(mask, " ")
72
+ head = tok(f"{internal['t']} question: {instructions}", add_special_tokens=False)["input_ids"]
73
+ options = [
74
+ tok(" " + option.replace(mask, " "), add_special_tokens=False)["input_ids"]
75
+ for option in render_options(internal)
76
+ ]
77
+ option_size = sum(len(option) + 1 for option in options)
78
+ remaining = head_max_len - option_size
79
+ if any(len(option) > 48 for option in options) or remaining < 16 or len(head) > max(8, remaining):
80
+ raise RequestError("Question exceeds the checkpoint instruction/option token budget")
81
+ if len(head) + option_size + len(state_tokens) + 4 > max_len:
82
+ raise RequestError("State exceeds the checkpoint token budget; shorten the supplied state")
83
+
84
+
85
+ class LayaBackend:
86
+ def __init__(self):
87
+ # Importing this module and running service contract tests never downloads weights.
88
+ from laya import Router
89
+ from laya.common import render_options, serialize_state
90
+
91
+ names = [name.strip() for name in os.environ.get("LAYA_MODELS", "english").split(",")]
92
+ if not names or any(name not in {"english", "multilingual", "typed-decisions"} for name in names):
93
+ raise RequestError("LAYA_MODELS must list english, multilingual, and/or typed-decisions")
94
+ self.names = tuple(dict.fromkeys(names))
95
+ self.router = Router(device=os.environ.get("LAYA_DEVICE", "cpu"), max_loaded=len(self.names))
96
+ self.router.preload(list(self.names))
97
+ self.render_options = render_options
98
+ self.serialize_state = serialize_state
99
+ self.lock = threading.Lock()
100
+
101
+ def predict(self, body):
102
+ # Reject rather than queue unbounded inference behind one busy device.
103
+ if not self.lock.acquire(blocking=False):
104
+ return None
105
+ try:
106
+ try:
107
+ decision = self.router.route(body["state"], body["questions"], model=body.get("model"), lang=body.get("lang"))
108
+ except ValueError as error:
109
+ raise RequestError("Invalid checkpoint selection") from error
110
+ if decision.model not in self.names:
111
+ raise RequestError("Selected checkpoint is not preloaded; configure LAYA_MODELS or select a loaded model")
112
+ agent = self.router.load(decision.model)
113
+ check_token_budget(agent, body["state"], body["questions"], self.render_options, self.serialize_state)
114
+ result = agent.predict(body["state"], body["questions"])
115
+ result["routing"] = dict(decision)
116
+ return result
117
+ finally:
118
+ self.lock.release()
119
+
120
+
121
+ def validate_body(body):
122
+ # The HTTP boundary also validates callers that do not use the TypeScript adapter.
123
+ validate_json(body)
124
+ if not isinstance(body, dict) or set(body) - {"state", "questions", "model", "lang"}:
125
+ raise RequestError("Invalid request object")
126
+ if not isinstance(body.get("state"), (str, dict, list)):
127
+ raise RequestError("state must be a string, object, or array")
128
+ questions = body.get("questions")
129
+ if not isinstance(questions, dict) or not 1 <= len(questions) <= 256:
130
+ raise RequestError("Provide 1–256 questions")
131
+ for field in ("model", "lang"):
132
+ if field in body and (not isinstance(body[field], str) or not body[field].strip() or not 1 <= len(body[field]) <= 256):
133
+ raise RequestError("Invalid model or language")
134
+ for key, question in questions.items():
135
+ if not key or not isinstance(question, dict) or set(question) - {"type", "instructions", "criteria"}:
136
+ raise RequestError("Invalid question")
137
+ if not isinstance(question.get("instructions"), (str, dict, list)):
138
+ raise RequestError("Invalid question instructions")
139
+ kind, criteria = question.get("type"), question.get("criteria")
140
+ if kind == "choice":
141
+ if not isinstance(criteria, dict) or not 2 <= len(criteria) <= 255 or any(not k for k in criteria):
142
+ raise RequestError("Choice requires 2–255 options")
143
+ if any(v is not None and not isinstance(v, (str, dict, list)) for v in criteria.values()):
144
+ raise RequestError("Invalid choice criteria")
145
+ elif kind == "score":
146
+ if not isinstance(criteria, list) or not 2 <= len(criteria) <= 10 or any(not isinstance(v, (str, dict, list)) for v in criteria):
147
+ raise RequestError("Score requires 2–10 descriptive levels")
148
+ elif kind == "noul":
149
+ if criteria is not None and (not isinstance(criteria, dict) or set(criteria) - {"true", "false"} or any(not isinstance(v, (str, dict, list)) for v in criteria.values())):
150
+ raise RequestError("Invalid noul criteria")
151
+ else:
152
+ raise RequestError("Unsupported question type")
153
+ return body
154
+
155
+
156
+ class BoundedHTTPServer(ThreadingHTTPServer):
157
+ """Limit active handlers before spawning threads, including slow request readers."""
158
+
159
+ def __init__(self, address, handler, max_connections):
160
+ self._connection_slots = threading.BoundedSemaphore(max_connections)
161
+ super().__init__(address, handler)
162
+
163
+ def process_request(self, request, client_address):
164
+ if not self._connection_slots.acquire(blocking=False):
165
+ payload = b'{"error":"Service connection capacity reached"}'
166
+ response = (b"HTTP/1.0 503 Service Unavailable\r\nContent-Type: application/json\r\n"
167
+ b"Connection: close\r\nRetry-After: 1\r\nContent-Length: "
168
+ + str(len(payload)).encode() + b"\r\n\r\n" + payload)
169
+ try:
170
+ request.settimeout(0.5)
171
+ request.sendall(response)
172
+ except OSError:
173
+ pass
174
+ finally:
175
+ self.shutdown_request(request)
176
+ return
177
+ try:
178
+ super().process_request(request, client_address)
179
+ except BaseException:
180
+ self._connection_slots.release()
181
+ raise
182
+
183
+ def process_request_thread(self, request, client_address):
184
+ try:
185
+ super().process_request_thread(request, client_address)
186
+ finally:
187
+ self._connection_slots.release()
188
+
189
+
190
+ def create_server(address, backend, api_key, *, max_connections=32):
191
+ if not api_key or not re.fullmatch(r"[\x21-\x7e]+", api_key):
192
+ raise RequestError("Set LAYA_API_KEY to a nonempty printable ASCII token without whitespace")
193
+ if isinstance(max_connections, bool) or not isinstance(max_connections, int) or not 1 <= max_connections <= 128:
194
+ raise RequestError("max_connections must be an integer from 1 to 128")
195
+
196
+ class Handler(BaseHTTPRequestHandler):
197
+ def setup(self):
198
+ super().setup()
199
+ self.connection.settimeout(30)
200
+
201
+ def respond(self, status, payload):
202
+ data = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode()
203
+ if len(data) > MAX_BODY_BYTES:
204
+ raise RuntimeError("Inference response exceeds the service limit")
205
+ try:
206
+ self.send_response(status)
207
+ self.send_header("Content-Type", "application/json")
208
+ self.send_header("Content-Length", str(len(data)))
209
+ if status == 503:
210
+ self.send_header("Retry-After", "1")
211
+ self.end_headers()
212
+ self.wfile.write(data)
213
+ except (BrokenPipeError, ConnectionResetError, TimeoutError):
214
+ pass # A cancelled HTTP request cannot interrupt an active GPU kernel.
215
+
216
+ def do_GET(self):
217
+ self.respond(200, {"ready": True, "models": backend.names}) if self.path == "/healthz" else self.respond(404, {"error": "Not found"})
218
+
219
+ def do_POST(self):
220
+ if self.path != "/v1/systemone":
221
+ return self.respond(404, {"error": "Not found"})
222
+ expected = api_key.encode()
223
+ auth_headers = self.headers.get_all("Authorization", [])
224
+ auth = re.fullmatch(r"(?i:Bearer) +([\x21-\x7e]+)", auth_headers[0]) if len(auth_headers) == 1 else None
225
+ supplied = auth.group(1).encode() if auth else b""
226
+ if not hmac.compare_digest(expected, supplied):
227
+ return self.respond(401, {"error": "Unauthorized"})
228
+ if self.headers.get("Transfer-Encoding"):
229
+ return self.respond(400, {"error": "Chunked requests are not supported"})
230
+ lengths = self.headers.get_all("Content-Length", [])
231
+ if len(lengths) != 1 or not re.fullmatch(r"[0-9]+", lengths[0]) or len(lengths[0]) > 10:
232
+ return self.respond(400, {"error": "Provide exactly one valid Content-Length"})
233
+ length = int(lengths[0])
234
+ if not 0 < length <= MAX_BODY_BYTES:
235
+ return self.respond(413, {"error": "Provide a JSON body of at most 2 MiB"})
236
+ encodings = self.headers.get_all("Content-Encoding", [])
237
+ if len(encodings) > 1 or (encodings and encodings[0].strip().lower() != "identity"):
238
+ return self.respond(415, {"error": "Unsupported Content-Encoding"})
239
+ content_types = self.headers.get_all("Content-Type", [])
240
+ if len(content_types) != 1 or content_types[0].split(";", 1)[0].strip().lower() != "application/json":
241
+ return self.respond(415, {"error": "Content-Type must be application/json"})
242
+ try:
243
+ def reject_constant(_value):
244
+ raise RequestError("Non-finite numbers are not JSON")
245
+ data = self.rfile.read(length)
246
+ if len(data) != length:
247
+ return self.respond(400, {"error": "Incomplete request body"})
248
+ body = validate_body(json.loads(data.decode("utf-8", errors="strict"), parse_constant=reject_constant, object_pairs_hook=unique_object))
249
+ except (RequestError, ValueError, RecursionError):
250
+ return self.respond(422, {"error": "Invalid JSON decision request"})
251
+ except TimeoutError:
252
+ return self.respond(408, {"error": "Request body timed out"})
253
+ try:
254
+ result = backend.predict(body)
255
+ if result is None:
256
+ return self.respond(503, {"error": "Inference device busy"})
257
+ self.respond(200, result)
258
+ except RequestError:
259
+ self.respond(422, {"error": "Invalid request or checkpoint token budget exceeded"})
260
+ except Exception:
261
+ self.respond(500, {"error": "Laya inference failed"})
262
+
263
+ def log_message(self, _format, *_args):
264
+ pass # Request bodies, API keys, and submitted text are never logged.
265
+
266
+ return BoundedHTTPServer(address, Handler, max_connections)
267
+
268
+
269
+ if __name__ == "__main__":
270
+ key = os.environ.get("LAYA_API_KEY", "")
271
+ if not key or not re.fullmatch(r"[\x21-\x7e]+", key):
272
+ raise SystemExit("Set LAYA_API_KEY to a printable ASCII token without whitespace before starting the service")
273
+ try:
274
+ capacity = int(os.environ.get("LAYA_MAX_CONNECTIONS", "32"))
275
+ if not 1 <= capacity <= 128:
276
+ raise ValueError
277
+ except ValueError:
278
+ raise SystemExit("LAYA_MAX_CONNECTIONS must be an integer from 1 to 128") from None
279
+ backend = LayaBackend()
280
+ server = create_server((os.environ.get("LAYA_HOST", "127.0.0.1"), int(os.environ.get("LAYA_PORT", "8080"))), backend, key, max_connections=capacity)
281
+ print(f"Laya ready on {server.server_address[0]}:{server.server_address[1]}; checkpoints: {', '.join(backend.names)}", flush=True)
282
+ try:
283
+ server.serve_forever()
284
+ except KeyboardInterrupt:
285
+ pass
286
+ finally:
287
+ server.server_close()