@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.
- package/CHANGELOG.md +11 -0
- package/README.md +48 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/laya-adapter.d.ts +11 -0
- package/dist/laya-adapter.d.ts.map +1 -0
- package/dist/laya-adapter.js +41 -0
- package/dist/laya-adapter.js.map +1 -0
- package/instructions/README.md +12 -0
- package/instructions/framework.md +46 -0
- package/instructions/testing.md +19 -0
- package/package.json +61 -0
- package/service/Dockerfile +14 -0
- package/service/fixtures/fake_server.py +28 -0
- package/service/requirements.txt +2 -0
- package/service/server.py +287 -0
- package/service/test_audit.py +170 -0
- package/service/test_audit_round_two.py +165 -0
- package/service/test_server.py +145 -0
|
@@ -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,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()
|