@gobing-ai/ts-laya-mlx 0.5.21 → 0.5.23
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/README.md +36 -1
- package/dist/driver.d.ts +28 -5
- package/dist/driver.d.ts.map +1 -1
- package/dist/driver.js +101 -14
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/native-client.d.ts +61 -0
- package/dist/native-client.d.ts.map +1 -0
- package/dist/native-client.js +111 -0
- package/dist/worker-client.d.ts +7 -1
- package/dist/worker-client.d.ts.map +1 -1
- package/dist/worker-client.js +28 -4
- package/package.json +7 -3
- package/src/driver.ts +131 -15
- package/src/index.ts +12 -2
- package/src/native-client.ts +173 -0
- package/src/worker-client.ts +42 -4
- package/worker/laya_worker.py +12 -1
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ Part of the `@gobing-ai/ts-libs` monorepo and lockstep-versioned with it.
|
|
|
21
21
|
> **Notice:** This is the only package in the workspace with specific host architecture constraints.
|
|
22
22
|
|
|
23
23
|
1. **Platform:** macOS on Apple Silicon (`darwin arm64`). Unsupported platforms fail fast at construction time before any process is spawned.
|
|
24
|
-
2. **Python:** Python 3.10+ carrying the `laya-mlx` runtime (resolvable via `pythonPath`, injected `env.LAYA_PYTHON`, or `python3` on `PATH`).
|
|
24
|
+
2. **Python (core `mlx-python` only):** Python 3.10+ carrying the `laya-mlx` runtime (resolvable via `pythonPath`, injected `env.LAYA_PYTHON`, or `python3` on `PATH`). Core `mlx-native` needs no Python.
|
|
25
25
|
3. **Supported runtime range:** `laya-mlx 0.1.x` (validated against upstream `0.1.0` @ `fc1df62828a3fedf4d8229fdac1cbd85f1cdf337`).
|
|
26
26
|
4. **Install command:**
|
|
27
27
|
```sh
|
|
@@ -32,6 +32,40 @@ Part of the `@gobing-ai/ts-libs` monorepo and lockstep-versioned with it.
|
|
|
32
32
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
|
+
## Execution core and model selection
|
|
36
|
+
|
|
37
|
+
Both cores run Apple MLX on Metal and return identical neutral answers; pick per host.
|
|
38
|
+
|
|
39
|
+
| Option | Env (injected) | Values | Default |
|
|
40
|
+
| --- | --- | --- | --- |
|
|
41
|
+
| `core` | `LAYA_CORE` | `mlx-python` · `mlx-native` · `auto` | `mlx-python` |
|
|
42
|
+
| `modelPath` | `LAYA_MODEL_PATH` | local checkpoint dir (fine-tuned) — wins over `modelId` | — |
|
|
43
|
+
| `modelId` | `LAYA_MODEL_ID` | Hugging Face repo id | `convaiinnovations/laya-multilingual` |
|
|
44
|
+
|
|
45
|
+
- **`mlx-python`** — warm `laya-mlx` Python child process over JSON lines. Lowest latency and memory.
|
|
46
|
+
- **`mlx-native`** — in-process [`@johnhenry/laya`](https://github.com/johnhenry/laya-js) via libmlxc FFI;
|
|
47
|
+
no Python, no child process. Does not support `cacheDir`/`LAYA_CACHE_DIR` (set `HF_HUB_CACHE` for the
|
|
48
|
+
process instead) or `dtype: 'bfloat16'`; both are rejected as `DecisionConfigError`.
|
|
49
|
+
- **`auto`** — loads the native core on first ask; any native load failure falls back to `mlx-python`.
|
|
50
|
+
|
|
51
|
+
Measured on an Apple Silicon Mac (official checkpoint, f16, 3-question batch, 20 warm asks):
|
|
52
|
+
|
|
53
|
+
| Core | Cold (load + first ask) | Warm p50 | Resident memory |
|
|
54
|
+
| --- | --- | --- | --- |
|
|
55
|
+
| `mlx-python` | ~0.8–1.0 s | ~10 ms | ~1.15 GB (78 MB host + ~1.07 GB worker) |
|
|
56
|
+
| `mlx-native` | ~0.8–1.0 s | ~22 ms | ~1.78 GB (all in host process) |
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
createLayaDriver({ core: 'mlx-native', modelPath: '/models/laya-ft-kk' });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The returned driver implements `dispose()`, which closes the client it was built with — the
|
|
63
|
+
`mlx-python` worker is held warm for the client's lifetime, and its open pipes otherwise keep a
|
|
64
|
+
one-shot parent process alive after the last answer. Call it through `maker.dispose()` (from
|
|
65
|
+
`createDecisionMaker`) or `registry.dispose('laya-local')`.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
35
69
|
## Backend selection (`ts-ai-runner`)
|
|
36
70
|
|
|
37
71
|
Downstream applications using `createDecisionMaker` from `@gobing-ai/ts-ai-runner`
|
|
@@ -70,6 +104,7 @@ Resolution order: `options.driver` (custom driver double) → `options.backend`
|
|
|
70
104
|
| Dimension | Hosted TypeSafe (`ts-ai-runner`) | Local Laya (`ts-laya-mlx`) | Notes |
|
|
71
105
|
|-----------|----------------------------------|---------------------------|-------|
|
|
72
106
|
| **Factory** | `createTypesafeDriver(config)` | `createLayaDriver(options)` | Both return `DecisionDriver` with single `ask()` method. |
|
|
107
|
+
| **Release** | none (stateless HTTP) | `dispose()` closes the warm worker | A `laya-local` maker must be disposed or a one-shot parent never exits. |
|
|
73
108
|
| **Driver name** | `'typesafe'` | `'laya-local'` | Available on `driver.name`. |
|
|
74
109
|
| **Credentials** | `apiKey: string` (`TYPESAFE_API_KEY`) | `env.HF_TOKEN` (optional) | Local inference needs no key; token only for gated HF repos. |
|
|
75
110
|
| **Model selector** | `model?: string` | `modelId?: string` | Default: `'convaiinnovations/laya-multilingual'`. |
|
package/dist/driver.d.ts
CHANGED
|
@@ -1,15 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The local Laya decision driver satisfying {@link DecisionDriver} from `@gobing-ai/ts-ai-runner`.
|
|
3
|
-
* Translates provider-neutral decision requests into the
|
|
4
|
-
* and maps
|
|
3
|
+
* Translates provider-neutral decision requests into the laya-mlx question format
|
|
4
|
+
* and maps answers back onto neutral {@link Answer} types (task 0078). Two MLX cores
|
|
5
|
+
* serve the same wire shape: the Python worker (`mlx-python`) and the in-process
|
|
6
|
+
* `@johnhenry/laya` port (`mlx-native`).
|
|
5
7
|
*/
|
|
6
8
|
import { type Answer, type DecisionDriver, type Question } from '@gobing-ai/ts-ai-runner';
|
|
7
|
-
import {
|
|
9
|
+
import { type LayaNativeLoader } from './native-client';
|
|
10
|
+
import { type LayaWorkerClientOptions, type LayaWorkerResult, type WorkerQuestion } from './worker-client';
|
|
11
|
+
/**
|
|
12
|
+
* Execution core behind `laya-local`. Both run Apple MLX on Metal:
|
|
13
|
+
* - `mlx-python`: warm `laya-mlx` Python worker over JSON lines (default; needs a Python env).
|
|
14
|
+
* - `mlx-native`: in-process `@johnhenry/laya` via libmlxc FFI (no Python).
|
|
15
|
+
* - `auto`: try `mlx-native`, fall back to `mlx-python` when it cannot load.
|
|
16
|
+
*/
|
|
17
|
+
export type LayaCore = 'mlx-python' | 'mlx-native' | 'auto';
|
|
18
|
+
/** The `ask` surface both cores implement. */
|
|
19
|
+
export interface LayaClient {
|
|
20
|
+
ask(state: string, questions: Record<string, WorkerQuestion>): Promise<LayaWorkerResult>;
|
|
21
|
+
dispose(): void;
|
|
22
|
+
}
|
|
8
23
|
/** Configuration options for {@link createLayaDriver}. */
|
|
9
24
|
export interface LayaDriverOptions extends LayaWorkerClientOptions {
|
|
10
|
-
/**
|
|
11
|
-
|
|
25
|
+
/** Execution core. Default: `env.LAYA_CORE`, else `'mlx-python'`. */
|
|
26
|
+
core?: LayaCore;
|
|
27
|
+
/** Pre-constructed client (dependency injection in tests); bypasses core selection. */
|
|
28
|
+
client?: LayaClient;
|
|
29
|
+
/** Injected native loader (tests); forwarded to {@link LayaNativeClient}. */
|
|
30
|
+
nativeLoader?: LayaNativeLoader;
|
|
12
31
|
}
|
|
32
|
+
/** Resolve the configured core: explicit option → injected `env.LAYA_CORE` → `'mlx-python'`. */
|
|
33
|
+
export declare function resolveLayaCore(options?: Pick<LayaDriverOptions, 'core' | 'env'>): LayaCore;
|
|
34
|
+
/** Construct the client for the resolved core. */
|
|
35
|
+
export declare function createLayaClient(options?: LayaDriverOptions): LayaClient;
|
|
13
36
|
/** Convert a neutral {@link Question} into the wire shape consumed by the worker. */
|
|
14
37
|
export declare function toWorkerQuestion(question: Question): WorkerQuestion;
|
|
15
38
|
/**
|
package/dist/driver.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../src/driver.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../src/driver.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EACH,KAAK,MAAM,EAGX,KAAK,cAAc,EAInB,KAAK,QAAQ,EAChB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAoB,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAC1E,OAAO,EAEH,KAAK,uBAAuB,EAC5B,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACtB,MAAM,iBAAiB,CAAC;AAEzB;;;;;GAKG;AACH,MAAM,MAAM,QAAQ,GAAG,YAAY,GAAG,YAAY,GAAG,MAAM,CAAC;AAI5D,8CAA8C;AAC9C,MAAM,WAAW,UAAU;IACvB,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACzF,OAAO,IAAI,IAAI,CAAC;CACnB;AAED,0DAA0D;AAC1D,MAAM,WAAW,iBAAkB,SAAQ,uBAAuB;IAC9D,qEAAqE;IACrE,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,uFAAuF;IACvF,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,gBAAgB,CAAC;CACnC;AAED,gGAAgG;AAChG,wBAAgB,eAAe,CAAC,OAAO,GAAE,IAAI,CAAC,iBAAiB,EAAE,MAAM,GAAG,KAAK,CAAM,GAAG,QAAQ,CAS/F;AA8CD,kDAAkD;AAClD,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,iBAAsB,GAAG,UAAU,CAS5E;AAuBD,qFAAqF;AACrF,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,GAAG,cAAc,CA8BnE;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,CA2E9E;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,iBAAsB,GAAG,cAAc,CAwChF"}
|
package/dist/driver.js
CHANGED
|
@@ -1,36 +1,118 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The local Laya decision driver satisfying {@link DecisionDriver} from `@gobing-ai/ts-ai-runner`.
|
|
3
|
-
* Translates provider-neutral decision requests into the
|
|
4
|
-
* and maps
|
|
3
|
+
* Translates provider-neutral decision requests into the laya-mlx question format
|
|
4
|
+
* and maps answers back onto neutral {@link Answer} types (task 0078). Two MLX cores
|
|
5
|
+
* serve the same wire shape: the Python worker (`mlx-python`) and the in-process
|
|
6
|
+
* `@johnhenry/laya` port (`mlx-native`).
|
|
5
7
|
*/
|
|
6
|
-
import { DecisionBackendError, DecisionRequestError, } from '@gobing-ai/ts-ai-runner';
|
|
7
|
-
import {
|
|
8
|
+
import { DecisionBackendError, DecisionConfigError, DecisionRequestError, } from '@gobing-ai/ts-ai-runner';
|
|
9
|
+
import { LayaNativeClient } from './native-client.js';
|
|
10
|
+
import { LayaWorkerClient, } from './worker-client.js';
|
|
11
|
+
const LAYA_CORES = ['mlx-python', 'mlx-native', 'auto'];
|
|
12
|
+
/** Resolve the configured core: explicit option → injected `env.LAYA_CORE` → `'mlx-python'`. */
|
|
13
|
+
export function resolveLayaCore(options = {}) {
|
|
14
|
+
const raw = options.core ?? (options.env?.LAYA_CORE || undefined) ?? 'mlx-python';
|
|
15
|
+
if (!LAYA_CORES.includes(raw)) {
|
|
16
|
+
throw new DecisionConfigError(`Unknown laya core '${raw}'; expected one of ${LAYA_CORES.join(', ')}`, 'LAYA_CORE');
|
|
17
|
+
}
|
|
18
|
+
return raw;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* `auto` core: the first ask probes `mlx-native` by loading the weights; any load
|
|
22
|
+
* failure (missing libmlxc, unsupported option, bad checkpoint) falls back to the
|
|
23
|
+
* Python worker for the client's lifetime. Request-time errors are not retried.
|
|
24
|
+
*/
|
|
25
|
+
class AutoLayaClient {
|
|
26
|
+
options;
|
|
27
|
+
active = null;
|
|
28
|
+
resolved = null;
|
|
29
|
+
disposed = false;
|
|
30
|
+
constructor(options) {
|
|
31
|
+
this.options = options;
|
|
32
|
+
}
|
|
33
|
+
async ask(state, questions) {
|
|
34
|
+
this.active ??= this.select();
|
|
35
|
+
return (await this.active).ask(state, questions);
|
|
36
|
+
}
|
|
37
|
+
dispose() {
|
|
38
|
+
this.disposed = true;
|
|
39
|
+
this.resolved?.dispose();
|
|
40
|
+
}
|
|
41
|
+
async select() {
|
|
42
|
+
let chosen;
|
|
43
|
+
try {
|
|
44
|
+
const native = new LayaNativeClient({ ...this.options, loader: this.options.nativeLoader });
|
|
45
|
+
try {
|
|
46
|
+
await native.warm();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
native.dispose();
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
chosen = native;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (!(error instanceof DecisionConfigError))
|
|
56
|
+
throw error;
|
|
57
|
+
chosen = new LayaWorkerClient(this.options);
|
|
58
|
+
}
|
|
59
|
+
this.resolved = chosen;
|
|
60
|
+
// dispose() raced the native probe: the chosen client must not outlive us.
|
|
61
|
+
if (this.disposed)
|
|
62
|
+
chosen.dispose();
|
|
63
|
+
return chosen;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Construct the client for the resolved core. */
|
|
67
|
+
export function createLayaClient(options = {}) {
|
|
68
|
+
switch (resolveLayaCore(options)) {
|
|
69
|
+
case 'mlx-native':
|
|
70
|
+
return new LayaNativeClient({ ...options, loader: options.nativeLoader });
|
|
71
|
+
case 'auto':
|
|
72
|
+
return new AutoLayaClient(options);
|
|
73
|
+
default:
|
|
74
|
+
return new LayaWorkerClient(options);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
8
77
|
/** Format a {@link Desc} field into a string instructions value for the worker. */
|
|
9
|
-
function formatDesc(desc) {
|
|
78
|
+
function formatDesc(desc, fallback = '') {
|
|
10
79
|
if (typeof desc === 'string')
|
|
11
|
-
return desc;
|
|
80
|
+
return desc.trim().length > 0 ? desc : fallback;
|
|
12
81
|
if (desc === null || desc === undefined)
|
|
13
|
-
return
|
|
82
|
+
return fallback;
|
|
14
83
|
return JSON.stringify(desc);
|
|
15
84
|
}
|
|
85
|
+
/** Rescale a probability distribution to sum to exactly 1.0 when rounding error exceeds 1e-6. */
|
|
86
|
+
function normalizeDistribution(probs) {
|
|
87
|
+
const entries = Object.entries(probs);
|
|
88
|
+
const sum = entries.reduce((s, [, p]) => s + (typeof p === 'number' && Number.isFinite(p) ? p : 0), 0);
|
|
89
|
+
if (sum <= 0 || Math.abs(sum - 1) <= 1e-6) {
|
|
90
|
+
return probs;
|
|
91
|
+
}
|
|
92
|
+
const result = {};
|
|
93
|
+
for (const [k, p] of entries) {
|
|
94
|
+
result[k] = p / sum;
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
16
98
|
/** Convert a neutral {@link Question} into the wire shape consumed by the worker. */
|
|
17
99
|
export function toWorkerQuestion(question) {
|
|
18
100
|
switch (question.kind) {
|
|
19
101
|
case 'choice':
|
|
20
102
|
return {
|
|
21
103
|
type: 'choice',
|
|
22
|
-
instructions: formatDesc(question.prompt),
|
|
104
|
+
instructions: formatDesc(question.prompt, 'Select the best matching option.'),
|
|
23
105
|
labels: question.labels,
|
|
24
106
|
criteria: question.labels,
|
|
25
107
|
};
|
|
26
108
|
case 'score':
|
|
27
109
|
return {
|
|
28
110
|
type: 'score',
|
|
29
|
-
instructions: formatDesc(question.prompt),
|
|
111
|
+
instructions: formatDesc(question.prompt, 'Evaluate according to the rubric.'),
|
|
30
112
|
rubric: question.rubric,
|
|
31
113
|
// The runtime rejects a map here: score criteria is the rubric as a
|
|
32
114
|
// nonempty list of descriptions (laya_mlx agent.py `_to_internal`).
|
|
33
|
-
criteria: question.rubric.map(formatDesc),
|
|
115
|
+
criteria: question.rubric.map((r) => formatDesc(r, 'Level')),
|
|
34
116
|
};
|
|
35
117
|
case 'noul': {
|
|
36
118
|
const outcomes = question.yes === undefined && question.no === undefined
|
|
@@ -38,7 +120,7 @@ export function toWorkerQuestion(question) {
|
|
|
38
120
|
: { true: question.yes ?? null, false: question.no ?? null };
|
|
39
121
|
return {
|
|
40
122
|
type: 'noul',
|
|
41
|
-
instructions: formatDesc(question.prompt),
|
|
123
|
+
instructions: formatDesc(question.prompt, 'Evaluate whether the proposition is true.'),
|
|
42
124
|
...(outcomes !== undefined ? { criteria: outcomes } : {}),
|
|
43
125
|
};
|
|
44
126
|
}
|
|
@@ -60,7 +142,8 @@ export function mapWorkerAnswer(question, rawAnswer) {
|
|
|
60
142
|
throw new DecisionBackendError('laya worker returned invalid choice answer: missing label', undefined);
|
|
61
143
|
}
|
|
62
144
|
const confidence = typeof raw.confidence === 'number' ? raw.confidence : 0;
|
|
63
|
-
const
|
|
145
|
+
const rawProbs = (raw.probabilities ?? {});
|
|
146
|
+
const probabilities = normalizeDistribution(rawProbs);
|
|
64
147
|
return {
|
|
65
148
|
kind: 'choice',
|
|
66
149
|
label,
|
|
@@ -102,7 +185,7 @@ export function mapWorkerAnswer(question, rawAnswer) {
|
|
|
102
185
|
score,
|
|
103
186
|
confidence,
|
|
104
187
|
legend: rawLegend ? normalizedLegend : legend,
|
|
105
|
-
probabilities: normalizedProbs,
|
|
188
|
+
probabilities: normalizeDistribution(normalizedProbs),
|
|
106
189
|
};
|
|
107
190
|
}
|
|
108
191
|
case 'noul': {
|
|
@@ -124,9 +207,13 @@ export function mapWorkerAnswer(question, rawAnswer) {
|
|
|
124
207
|
* Exposes a readonly `name: 'laya-local'` and a single `ask` method with no sugar.
|
|
125
208
|
*/
|
|
126
209
|
export function createLayaDriver(options = {}) {
|
|
127
|
-
const client = options.client ??
|
|
210
|
+
const client = options.client ?? createLayaClient(options);
|
|
128
211
|
return {
|
|
129
212
|
name: 'laya-local',
|
|
213
|
+
// The client holds a warm Python worker for its lifetime (and the worker's open pipes keep
|
|
214
|
+
// a one-shot parent alive), so the driver exposes the release the client already owns; a
|
|
215
|
+
// consumer that made no call has nothing to close and every dispose is idempotent.
|
|
216
|
+
dispose: () => client.dispose(),
|
|
130
217
|
async ask({ state, questions, }) {
|
|
131
218
|
if (!questions || typeof questions !== 'object' || Object.keys(questions).length === 0) {
|
|
132
219
|
throw new DecisionRequestError('expected a nonempty question map', undefined, undefined);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @gobing-ai/ts-laya-mlx — Local Laya decision backend driving the vendored MLX runtime.
|
|
3
|
-
* Implements the {@link DecisionDriver} interface for `@gobing-ai/ts-ai-runner`
|
|
3
|
+
* Implements the {@link DecisionDriver} interface for `@gobing-ai/ts-ai-runner` on one of two MLX cores:
|
|
4
|
+
* a JSON-lines Python worker (`mlx-python`, default) or in-process `@johnhenry/laya` (`mlx-native`).
|
|
4
5
|
*/
|
|
5
|
-
export { createLayaDriver, type LayaDriverOptions } from './driver';
|
|
6
|
-
export {
|
|
6
|
+
export { createLayaClient, createLayaDriver, type LayaClient, type LayaCore, type LayaDriverOptions, resolveLayaCore, } from './driver';
|
|
7
|
+
export { LayaNativeClient, type LayaNativeClientOptions, type LayaNativeLoader } from './native-client';
|
|
8
|
+
export { DEFAULT_LAYA_MODEL_ID, LayaWorkerClient, type LayaWorkerClientOptions, type LayaWorkerResult, translateWorkerError, validateHostPrerequisites, type WorkerQuestion, } from './worker-client';
|
|
7
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACH,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,UAAU,EACf,KAAK,QAAQ,EACb,KAAK,iBAAiB,EACtB,eAAe,GAClB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,gBAAgB,EAAE,KAAK,uBAAuB,EAAE,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACxG,OAAO,EACH,qBAAqB,EACrB,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,gBAAgB,EACrB,oBAAoB,EACpB,yBAAyB,EACzB,KAAK,cAAc,GACtB,MAAM,iBAAiB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @gobing-ai/ts-laya-mlx — Local Laya decision backend driving the vendored MLX runtime.
|
|
3
|
-
* Implements the {@link DecisionDriver} interface for `@gobing-ai/ts-ai-runner`
|
|
3
|
+
* Implements the {@link DecisionDriver} interface for `@gobing-ai/ts-ai-runner` on one of two MLX cores:
|
|
4
|
+
* a JSON-lines Python worker (`mlx-python`, default) or in-process `@johnhenry/laya` (`mlx-native`).
|
|
4
5
|
*/
|
|
5
|
-
export { createLayaDriver } from './driver.js';
|
|
6
|
-
export {
|
|
6
|
+
export { createLayaClient, createLayaDriver, resolveLayaCore, } from './driver.js';
|
|
7
|
+
export { LayaNativeClient } from './native-client.js';
|
|
8
|
+
export { DEFAULT_LAYA_MODEL_ID, LayaWorkerClient, translateWorkerError, validateHostPrerequisites, } from './worker-client.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process MLX client over `@johnhenry/laya` (`mlx-native` core).
|
|
3
|
+
* Runs the same Laya checkpoint on Apple Silicon Metal through libmlxc FFI —
|
|
4
|
+
* no Python interpreter, no child process. Exposes the same `ask()` shape as
|
|
5
|
+
* {@link LayaWorkerClient} so the driver maps answers identically for both cores.
|
|
6
|
+
*/
|
|
7
|
+
import type { LayaAgent } from '@johnhenry/laya';
|
|
8
|
+
import { type LayaWorkerResult, type WorkerQuestion } from './worker-client';
|
|
9
|
+
/** Loads a checkpoint into a ready agent; the seam the tests substitute. */
|
|
10
|
+
export type LayaNativeLoader = (target: string, opts: Record<string, unknown>) => Promise<LayaAgent>;
|
|
11
|
+
/** Configuration for {@link LayaNativeClient}. */
|
|
12
|
+
export interface LayaNativeClientOptions {
|
|
13
|
+
/** Hugging Face model id. Default: `convaiinnovations/laya-multilingual`. */
|
|
14
|
+
modelId?: string;
|
|
15
|
+
/** Local checkpoint directory (e.g. a fine-tuned model); wins over `modelId`. */
|
|
16
|
+
modelPath?: string;
|
|
17
|
+
/** Checkpoint revision pinned for resolution. */
|
|
18
|
+
revision?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Not supported natively: `@johnhenry/laya` resolves the hub cache from the
|
|
21
|
+
* process environment only. Set → construction fails rather than being silently ignored.
|
|
22
|
+
*/
|
|
23
|
+
cacheDir?: string;
|
|
24
|
+
/** Weight precision. `bfloat16` is not supported by the native backend. Default: `'float16'`. */
|
|
25
|
+
dtype?: 'float32' | 'float16' | 'bfloat16';
|
|
26
|
+
/** Questions per forward pass. Default: `16`. */
|
|
27
|
+
batchSize?: number;
|
|
28
|
+
/** Injected environment record — this package never reads the ambient environment (ADR-011). */
|
|
29
|
+
env?: Record<string, string | undefined>;
|
|
30
|
+
platform?: string;
|
|
31
|
+
arch?: string;
|
|
32
|
+
/** Injected loader (tests). Default: dynamic import of `@johnhenry/laya`'s `load`. */
|
|
33
|
+
loader?: LayaNativeLoader;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* In-process MLX client over `@johnhenry/laya`: same wire shape and `ask()`
|
|
37
|
+
* contract as {@link LayaWorkerClient}, backed by libmlxc instead of a Python
|
|
38
|
+
* worker. Construction validates the host and the options the native backend
|
|
39
|
+
* cannot honor; the checkpoint is loaded lazily on the first `warm()`/`ask()`.
|
|
40
|
+
*/
|
|
41
|
+
export declare class LayaNativeClient {
|
|
42
|
+
private agent;
|
|
43
|
+
private loading;
|
|
44
|
+
private chain;
|
|
45
|
+
private disposed;
|
|
46
|
+
private readonly target;
|
|
47
|
+
private readonly loadOptions;
|
|
48
|
+
private readonly loader;
|
|
49
|
+
constructor(options?: LayaNativeClientOptions);
|
|
50
|
+
/** Checkpoint target actually loaded (model path or hub id). */
|
|
51
|
+
get model(): string;
|
|
52
|
+
/** Eagerly load the weights; `auto` core resolution uses this to probe native availability. */
|
|
53
|
+
warm(): Promise<void>;
|
|
54
|
+
/** Ask one batch of worker-format questions; serialized like the python worker. */
|
|
55
|
+
ask(state: string, questions: Record<string, WorkerQuestion>): Promise<LayaWorkerResult>;
|
|
56
|
+
/** Release Metal buffers; safe to call repeatedly. */
|
|
57
|
+
dispose(): void;
|
|
58
|
+
private dispatch;
|
|
59
|
+
private ensureAgent;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=native-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"native-client.d.ts","sourceRoot":"","sources":["../src/native-client.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,SAAS,EAAa,MAAM,iBAAiB,CAAC;AAC5D,OAAO,EAGH,KAAK,gBAAgB,EAErB,KAAK,cAAc,EACtB,MAAM,iBAAiB,CAAC;AAEzB,4EAA4E;AAC5E,MAAM,MAAM,gBAAgB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,SAAS,CAAC,CAAC;AAErG,kDAAkD;AAClD,MAAM,WAAW,uBAAuB;IACpC,6EAA6E;IAC7E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iGAAiG;IACjG,KAAK,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,UAAU,CAAC;IAC3C,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gGAAgG;IAChG,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sFAAsF;IACtF,MAAM,CAAC,EAAE,gBAAgB,CAAC;CAC7B;AAQD;;;;;GAKG;AACH,qBAAa,gBAAgB;IACzB,OAAO,CAAC,KAAK,CAA0B;IACvC,OAAO,CAAC,OAAO,CAAmC;IAClD,OAAO,CAAC,KAAK,CAAuC;IACpD,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA0B;IACtD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmB;gBAE9B,OAAO,GAAE,uBAA4B;IAiCjD,gEAAgE;IAChE,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,+FAA+F;IACzF,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAI3B,mFAAmF;IACnF,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,GAAG,OAAO,CAAC,gBAAgB,CAAC;IASxF,sDAAsD;IACtD,OAAO,IAAI,IAAI;YAMD,QAAQ;IAmBtB,OAAO,CAAC,WAAW;CA2BtB"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process MLX client over `@johnhenry/laya` (`mlx-native` core).
|
|
3
|
+
* Runs the same Laya checkpoint on Apple Silicon Metal through libmlxc FFI —
|
|
4
|
+
* no Python interpreter, no child process. Exposes the same `ask()` shape as
|
|
5
|
+
* {@link LayaWorkerClient} so the driver maps answers identically for both cores.
|
|
6
|
+
*/
|
|
7
|
+
import { DecisionBackendError, DecisionConfigError } from '@gobing-ai/ts-ai-runner';
|
|
8
|
+
import { DEFAULT_LAYA_MODEL_ID, hasNonFiniteValues, validateHostPrerequisites, } from './worker-client.js';
|
|
9
|
+
const defaultLoader = async (target, opts) => {
|
|
10
|
+
// Dynamic import keeps libmlxc/koffi out of the module graph when the python core is used.
|
|
11
|
+
const { load } = await import('@johnhenry/laya');
|
|
12
|
+
return load(target, opts);
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* In-process MLX client over `@johnhenry/laya`: same wire shape and `ask()`
|
|
16
|
+
* contract as {@link LayaWorkerClient}, backed by libmlxc instead of a Python
|
|
17
|
+
* worker. Construction validates the host and the options the native backend
|
|
18
|
+
* cannot honor; the checkpoint is loaded lazily on the first `warm()`/`ask()`.
|
|
19
|
+
*/
|
|
20
|
+
export class LayaNativeClient {
|
|
21
|
+
agent = null;
|
|
22
|
+
loading = null;
|
|
23
|
+
chain = Promise.resolve();
|
|
24
|
+
disposed = false;
|
|
25
|
+
target;
|
|
26
|
+
loadOptions;
|
|
27
|
+
loader;
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
validateHostPrerequisites(options.platform, options.arch);
|
|
30
|
+
const cacheDir = options.cacheDir ?? options.env?.LAYA_CACHE_DIR;
|
|
31
|
+
if (cacheDir !== undefined) {
|
|
32
|
+
throw new DecisionConfigError("laya core 'mlx-native' cannot honor cacheDir/LAYA_CACHE_DIR; set HF_HUB_CACHE for the process or use core 'mlx-python'", 'LAYA_CACHE_DIR');
|
|
33
|
+
}
|
|
34
|
+
if (options.dtype === 'bfloat16') {
|
|
35
|
+
throw new DecisionConfigError("laya core 'mlx-native' supports dtype 'float16' or 'float32', not 'bfloat16'", 'LAYA_DTYPE');
|
|
36
|
+
}
|
|
37
|
+
this.target =
|
|
38
|
+
options.modelPath ??
|
|
39
|
+
options.env?.LAYA_MODEL_PATH ??
|
|
40
|
+
options.modelId ??
|
|
41
|
+
options.env?.LAYA_MODEL_ID ??
|
|
42
|
+
DEFAULT_LAYA_MODEL_ID;
|
|
43
|
+
this.loadOptions = {
|
|
44
|
+
backend: 'mlx',
|
|
45
|
+
dtype: options.dtype === 'float32' ? 'f32' : 'f16',
|
|
46
|
+
batchSize: options.batchSize ?? 16,
|
|
47
|
+
...(options.revision !== undefined ? { revision: options.revision } : {}),
|
|
48
|
+
...(options.env?.HF_TOKEN ? { token: options.env.HF_TOKEN } : {}),
|
|
49
|
+
// The temperature-clamping notice would otherwise land on the host's console.
|
|
50
|
+
warn: () => { },
|
|
51
|
+
};
|
|
52
|
+
this.loader = options.loader ?? defaultLoader;
|
|
53
|
+
}
|
|
54
|
+
/** Checkpoint target actually loaded (model path or hub id). */
|
|
55
|
+
get model() {
|
|
56
|
+
return this.target;
|
|
57
|
+
}
|
|
58
|
+
/** Eagerly load the weights; `auto` core resolution uses this to probe native availability. */
|
|
59
|
+
async warm() {
|
|
60
|
+
await this.ensureAgent();
|
|
61
|
+
}
|
|
62
|
+
/** Ask one batch of worker-format questions; serialized like the python worker. */
|
|
63
|
+
ask(state, questions) {
|
|
64
|
+
const run = this.chain.then(() => this.dispatch(state, questions));
|
|
65
|
+
this.chain = run.then(() => undefined, () => undefined);
|
|
66
|
+
return run;
|
|
67
|
+
}
|
|
68
|
+
/** Release Metal buffers; safe to call repeatedly. */
|
|
69
|
+
dispose() {
|
|
70
|
+
this.disposed = true;
|
|
71
|
+
this.agent?.dispose();
|
|
72
|
+
this.agent = null;
|
|
73
|
+
}
|
|
74
|
+
async dispatch(state, questions) {
|
|
75
|
+
const agent = await this.ensureAgent();
|
|
76
|
+
let result;
|
|
77
|
+
try {
|
|
78
|
+
// WorkerQuestion is the laya-mlx wire shape, which @johnhenry/laya ports 1:1.
|
|
79
|
+
result = (await agent.predict(state, questions));
|
|
80
|
+
}
|
|
81
|
+
catch (cause) {
|
|
82
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
83
|
+
throw new DecisionBackendError(`laya native inference failed: ${message}`, undefined, { cause });
|
|
84
|
+
}
|
|
85
|
+
if (hasNonFiniteValues(result.answers)) {
|
|
86
|
+
throw new DecisionBackendError("laya native returned non-finite model outputs; retry with dtype='float32'", undefined);
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
ensureAgent() {
|
|
91
|
+
if (this.disposed) {
|
|
92
|
+
return Promise.reject(new DecisionBackendError('laya native client has been disposed', undefined));
|
|
93
|
+
}
|
|
94
|
+
if (this.agent)
|
|
95
|
+
return Promise.resolve(this.agent);
|
|
96
|
+
this.loading ??= this.loader(this.target, this.loadOptions).then((agent) => {
|
|
97
|
+
this.loading = null;
|
|
98
|
+
if (this.disposed) {
|
|
99
|
+
agent.dispose();
|
|
100
|
+
throw new DecisionBackendError('laya native client has been disposed', undefined);
|
|
101
|
+
}
|
|
102
|
+
this.agent = agent;
|
|
103
|
+
return agent;
|
|
104
|
+
}, (cause) => {
|
|
105
|
+
this.loading = null;
|
|
106
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
107
|
+
throw new DecisionConfigError(`laya core 'mlx-native' failed to load '${this.target}': ${message}`, 'LAYA_CORE', { cause });
|
|
108
|
+
});
|
|
109
|
+
return this.loading;
|
|
110
|
+
}
|
|
111
|
+
}
|
package/dist/worker-client.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* boundary. Protocol reference: docs/design/laya-local-decision-backend.md.
|
|
13
13
|
*/
|
|
14
14
|
import { type DecisionError } from '@gobing-ai/ts-ai-runner';
|
|
15
|
-
import { type ProcessExecutor } from '@gobing-ai/ts-runtime';
|
|
15
|
+
import { type FileSystem, type ProcessExecutor } from '@gobing-ai/ts-runtime';
|
|
16
16
|
/** Configuration options for {@link LayaWorkerClient}. */
|
|
17
17
|
export interface LayaWorkerClientOptions {
|
|
18
18
|
/** Interpreter that carries `laya-mlx`. Default: `'python3'`. */
|
|
@@ -69,10 +69,16 @@ export interface LayaWorkerResult {
|
|
|
69
69
|
* Throws {@link DecisionConfigError} if the platform is not Apple Silicon macOS.
|
|
70
70
|
*/
|
|
71
71
|
export declare function validateHostPrerequisites(platform?: string, arch?: string): void;
|
|
72
|
+
/** Official Laya checkpoint used when no modelId/modelPath is configured. */
|
|
73
|
+
export declare const DEFAULT_LAYA_MODEL_ID = "convaiinnovations/laya-multilingual";
|
|
74
|
+
/** @internal True when any number nested in `obj` is NaN/Infinity (fp16 overflow). */
|
|
75
|
+
export declare function hasNonFiniteValues(obj: unknown): boolean;
|
|
72
76
|
/**
|
|
73
77
|
* Map worker-reported error kinds and messages onto the decision taxonomy (design: Errors table, task 0077).
|
|
74
78
|
*/
|
|
75
79
|
export declare function translateWorkerError(kind: string | undefined, message: string): DecisionError;
|
|
80
|
+
/** Resolve the Python interpreter to execute: explicit option -> injected env -> candidate discovery -> 'python3'. */
|
|
81
|
+
export declare function resolvePythonPath(options: LayaWorkerClientOptions, packageRoot: string, cwd: string, fileSystem?: FileSystem): string;
|
|
76
82
|
/**
|
|
77
83
|
* TypeScript half of the laya process bridge: start the Python worker lazily,
|
|
78
84
|
* hold it warm for the client's lifetime, and serve serialized asks with
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worker-client.d.ts","sourceRoot":"","sources":["../src/worker-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAKH,KAAK,aAAa,EAGrB,MAAM,yBAAyB,CAAC;AACjC,OAAO,
|
|
1
|
+
{"version":3,"file":"worker-client.d.ts","sourceRoot":"","sources":["../src/worker-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAKH,KAAK,aAAa,EAGrB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACH,KAAK,UAAU,EAKf,KAAK,eAAe,EACvB,MAAM,uBAAuB,CAAC;AAW/B,0DAA0D;AAC1D,MAAM,WAAW,uBAAuB;IACpC,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mEAAmE;IACnE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,KAAK,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,UAAU,CAAC;IAC3C,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mGAAmG;IACnG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,0EAA0E;IAC1E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,gGAAgG;IAChG,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzC,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8EAA8E;IAC9E,IAAI,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQ/G;AAED,2HAA2H;AAC3H,MAAM,WAAW,cAAc;IAC3B,IAAI,EAAE,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC;IAClC,YAAY,EAAE,MAAM,CAAC;IACrB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,qEAAqE;AACrE,MAAM,WAAW,gBAAgB;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAmBD;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,GAAE,MAAyB,EAAE,IAAI,GAAE,MAAqB,GAAG,IAAI,CAOhH;AAED,6EAA6E;AAC7E,eAAO,MAAM,qBAAqB,wCAAwC,CAAC;AAE3E,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAUxD;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,aAAa,CAmD7F;AAsQD,sHAAsH;AACtH,wBAAgB,iBAAiB,CAC7B,OAAO,EAAE,uBAAuB,EAChC,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,EACX,UAAU,GAAE,UAA8C,GAC3D,MAAM,CAmBR;AAED;;;;GAIG;AACH,qBAAa,gBAAgB;IACzB,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAW;IAChC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAyB;IACtD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkB;IAC3C,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,KAAK,CAAuC;IACpD,OAAO,CAAC,QAAQ,CAAS;gBAGrB,OAAO,GAAE,uBAA4B,EACrC,QAAQ,GAAE,eAAwD;IA2BtE;;;;;OAKG;IACH,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,GAAG,OAAO,CAAC,gBAAgB,CAAC;IASxF,kGAAkG;IAClG,OAAO,IAAI,IAAI;YAOD,QAAQ;YAoBR,YAAY;IAsB1B,OAAO,CAAC,KAAK;CA0BhB"}
|
package/dist/worker-client.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* boundary. Protocol reference: docs/design/laya-local-decision-backend.md.
|
|
13
13
|
*/
|
|
14
14
|
import { DecisionAuthError, DecisionBackendError, DecisionConfigError, DecisionConnectionError, DecisionRequestError, DecisionTimeoutError, } from '@gobing-ai/ts-ai-runner';
|
|
15
|
-
import { joinPath, nodeBunFactory } from '@gobing-ai/ts-runtime';
|
|
15
|
+
import { getHomeDir, joinPath, nodeBunFactory, } from '@gobing-ai/ts-runtime';
|
|
16
16
|
/** Environment keys this package may pass to the child process — the full allowlist. */
|
|
17
17
|
const FORWARDED_ENV_KEYS = ['LAYA_MODEL_ID', 'LAYA_MODEL_PATH', 'LAYA_CACHE_DIR', 'LAYA_PYTHON', 'HF_TOKEN'];
|
|
18
18
|
/** How many trailing stderr bytes to keep for exit-failure messages. */
|
|
@@ -44,7 +44,10 @@ export function validateHostPrerequisites(platform = process.platform, arch = pr
|
|
|
44
44
|
throw new DecisionConfigError(`laya-mlx requires macOS on Apple Silicon (darwin arm64); current platform is '${platform} ${arch}'`, 'PLATFORM');
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
/** Official Laya checkpoint used when no modelId/modelPath is configured. */
|
|
48
|
+
export const DEFAULT_LAYA_MODEL_ID = 'convaiinnovations/laya-multilingual';
|
|
49
|
+
/** @internal True when any number nested in `obj` is NaN/Infinity (fp16 overflow). */
|
|
50
|
+
export function hasNonFiniteValues(obj) {
|
|
48
51
|
if (typeof obj === 'number') {
|
|
49
52
|
return !Number.isFinite(obj);
|
|
50
53
|
}
|
|
@@ -317,6 +320,27 @@ class WorkerHandle {
|
|
|
317
320
|
});
|
|
318
321
|
}
|
|
319
322
|
}
|
|
323
|
+
/** Resolve the Python interpreter to execute: explicit option -> injected env -> candidate discovery -> 'python3'. */
|
|
324
|
+
export function resolvePythonPath(options, packageRoot, cwd, fileSystem = nodeBunFactory.createFileSystem()) {
|
|
325
|
+
if (options.pythonPath !== undefined)
|
|
326
|
+
return options.pythonPath;
|
|
327
|
+
if (options.env?.LAYA_PYTHON !== undefined && options.env.LAYA_PYTHON.length > 0) {
|
|
328
|
+
return options.env.LAYA_PYTHON;
|
|
329
|
+
}
|
|
330
|
+
const home = getHomeDir();
|
|
331
|
+
const candidates = [
|
|
332
|
+
home ? joinPath(home, '.local', 'share', 'uv', 'tools', 'laya-mlx', 'bin', 'python') : undefined,
|
|
333
|
+
home ? joinPath(home, '.virtualenvs', 'laya-mlx', 'bin', 'python') : undefined,
|
|
334
|
+
joinPath(packageRoot, '..', '..', 'vendors', 'laya-mlx', '.venv', 'bin', 'python'),
|
|
335
|
+
joinPath(cwd, '.venv', 'bin', 'python'),
|
|
336
|
+
].filter((p) => p !== undefined);
|
|
337
|
+
for (const candidate of candidates) {
|
|
338
|
+
if (fileSystem.exists(candidate)) {
|
|
339
|
+
return candidate;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return 'python3';
|
|
343
|
+
}
|
|
320
344
|
/**
|
|
321
345
|
* TypeScript half of the laya process bridge: start the Python worker lazily,
|
|
322
346
|
* hold it warm for the client's lifetime, and serve serialized asks with
|
|
@@ -336,8 +360,8 @@ export class LayaWorkerClient {
|
|
|
336
360
|
constructor(options = {}, executor = nodeBunFactory.createProcessExecutor()) {
|
|
337
361
|
validateHostPrerequisites(options.platform, options.arch);
|
|
338
362
|
const packageRoot = joinPath(import.meta.dir, '..');
|
|
339
|
-
this.pythonPath = options.pythonPath ?? options.env?.LAYA_PYTHON ?? 'python3';
|
|
340
363
|
this.cwd = options.cwd ?? packageRoot;
|
|
364
|
+
this.pythonPath = resolvePythonPath(options, packageRoot, this.cwd);
|
|
341
365
|
this.startupTimeoutMs = options.startupTimeoutMs ?? 120_000;
|
|
342
366
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
|
|
343
367
|
this.forwardedEnv = resolveForwardedEnv(options.env);
|
|
@@ -346,7 +370,7 @@ export class LayaWorkerClient {
|
|
|
346
370
|
this.args.push('--module', options.module ?? 'laya_mlx');
|
|
347
371
|
const resolvedModelPath = options.modelPath ?? options.env?.LAYA_MODEL_PATH;
|
|
348
372
|
const resolvedCacheDir = options.cacheDir ?? options.env?.LAYA_CACHE_DIR;
|
|
349
|
-
const resolvedModelId = options.modelId ?? options.env?.LAYA_MODEL_ID ??
|
|
373
|
+
const resolvedModelId = options.modelId ?? options.env?.LAYA_MODEL_ID ?? DEFAULT_LAYA_MODEL_ID;
|
|
350
374
|
if (resolvedModelPath !== undefined) {
|
|
351
375
|
this.args.push('--model-path', resolvedModelPath);
|
|
352
376
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/ts-laya-mlx",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.23",
|
|
4
4
|
"description": "@gobing-ai/ts-laya-mlx — Local Laya decision backend driving the vendored MLX runtime over a process bridge.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -52,8 +52,12 @@
|
|
|
52
52
|
"release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-laya-mlx-v<version> && git push --tags' && exit 1"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@gobing-ai/ts-ai-runner": "^0.5.
|
|
56
|
-
"@gobing-ai/ts-runtime": "^0.5.
|
|
55
|
+
"@gobing-ai/ts-ai-runner": "^0.5.23",
|
|
56
|
+
"@gobing-ai/ts-runtime": "^0.5.23",
|
|
57
|
+
"@johnhenry/laya": "0.3.2"
|
|
58
|
+
},
|
|
59
|
+
"optionalDependencies": {
|
|
60
|
+
"@johnhenry/backend-mlx": "0.5.0"
|
|
57
61
|
},
|
|
58
62
|
"devDependencies": {
|
|
59
63
|
"@types/bun": "1.3.14"
|
package/src/driver.ts
CHANGED
|
@@ -1,51 +1,162 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The local Laya decision driver satisfying {@link DecisionDriver} from `@gobing-ai/ts-ai-runner`.
|
|
3
|
-
* Translates provider-neutral decision requests into the
|
|
4
|
-
* and maps
|
|
3
|
+
* Translates provider-neutral decision requests into the laya-mlx question format
|
|
4
|
+
* and maps answers back onto neutral {@link Answer} types (task 0078). Two MLX cores
|
|
5
|
+
* serve the same wire shape: the Python worker (`mlx-python`) and the in-process
|
|
6
|
+
* `@johnhenry/laya` port (`mlx-native`).
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import {
|
|
8
10
|
type Answer,
|
|
9
11
|
DecisionBackendError,
|
|
12
|
+
DecisionConfigError,
|
|
10
13
|
type DecisionDriver,
|
|
11
14
|
DecisionRequestError,
|
|
12
15
|
type DecisionState,
|
|
13
16
|
type Desc,
|
|
14
17
|
type Question,
|
|
15
18
|
} from '@gobing-ai/ts-ai-runner';
|
|
16
|
-
import {
|
|
19
|
+
import { LayaNativeClient, type LayaNativeLoader } from './native-client';
|
|
20
|
+
import {
|
|
21
|
+
LayaWorkerClient,
|
|
22
|
+
type LayaWorkerClientOptions,
|
|
23
|
+
type LayaWorkerResult,
|
|
24
|
+
type WorkerQuestion,
|
|
25
|
+
} from './worker-client';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Execution core behind `laya-local`. Both run Apple MLX on Metal:
|
|
29
|
+
* - `mlx-python`: warm `laya-mlx` Python worker over JSON lines (default; needs a Python env).
|
|
30
|
+
* - `mlx-native`: in-process `@johnhenry/laya` via libmlxc FFI (no Python).
|
|
31
|
+
* - `auto`: try `mlx-native`, fall back to `mlx-python` when it cannot load.
|
|
32
|
+
*/
|
|
33
|
+
export type LayaCore = 'mlx-python' | 'mlx-native' | 'auto';
|
|
34
|
+
|
|
35
|
+
const LAYA_CORES: readonly LayaCore[] = ['mlx-python', 'mlx-native', 'auto'];
|
|
36
|
+
|
|
37
|
+
/** The `ask` surface both cores implement. */
|
|
38
|
+
export interface LayaClient {
|
|
39
|
+
ask(state: string, questions: Record<string, WorkerQuestion>): Promise<LayaWorkerResult>;
|
|
40
|
+
dispose(): void;
|
|
41
|
+
}
|
|
17
42
|
|
|
18
43
|
/** Configuration options for {@link createLayaDriver}. */
|
|
19
44
|
export interface LayaDriverOptions extends LayaWorkerClientOptions {
|
|
20
|
-
/**
|
|
21
|
-
|
|
45
|
+
/** Execution core. Default: `env.LAYA_CORE`, else `'mlx-python'`. */
|
|
46
|
+
core?: LayaCore;
|
|
47
|
+
/** Pre-constructed client (dependency injection in tests); bypasses core selection. */
|
|
48
|
+
client?: LayaClient;
|
|
49
|
+
/** Injected native loader (tests); forwarded to {@link LayaNativeClient}. */
|
|
50
|
+
nativeLoader?: LayaNativeLoader;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolve the configured core: explicit option → injected `env.LAYA_CORE` → `'mlx-python'`. */
|
|
54
|
+
export function resolveLayaCore(options: Pick<LayaDriverOptions, 'core' | 'env'> = {}): LayaCore {
|
|
55
|
+
const raw = options.core ?? (options.env?.LAYA_CORE || undefined) ?? 'mlx-python';
|
|
56
|
+
if (!(LAYA_CORES as readonly string[]).includes(raw)) {
|
|
57
|
+
throw new DecisionConfigError(
|
|
58
|
+
`Unknown laya core '${raw}'; expected one of ${LAYA_CORES.join(', ')}`,
|
|
59
|
+
'LAYA_CORE',
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return raw as LayaCore;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `auto` core: the first ask probes `mlx-native` by loading the weights; any load
|
|
67
|
+
* failure (missing libmlxc, unsupported option, bad checkpoint) falls back to the
|
|
68
|
+
* Python worker for the client's lifetime. Request-time errors are not retried.
|
|
69
|
+
*/
|
|
70
|
+
class AutoLayaClient implements LayaClient {
|
|
71
|
+
private active: Promise<LayaClient> | null = null;
|
|
72
|
+
private resolved: LayaClient | null = null;
|
|
73
|
+
private disposed = false;
|
|
74
|
+
|
|
75
|
+
constructor(private readonly options: LayaDriverOptions) {}
|
|
76
|
+
|
|
77
|
+
async ask(state: string, questions: Record<string, WorkerQuestion>): Promise<LayaWorkerResult> {
|
|
78
|
+
this.active ??= this.select();
|
|
79
|
+
return (await this.active).ask(state, questions);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
dispose(): void {
|
|
83
|
+
this.disposed = true;
|
|
84
|
+
this.resolved?.dispose();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async select(): Promise<LayaClient> {
|
|
88
|
+
let chosen: LayaClient;
|
|
89
|
+
try {
|
|
90
|
+
const native = new LayaNativeClient({ ...this.options, loader: this.options.nativeLoader });
|
|
91
|
+
try {
|
|
92
|
+
await native.warm();
|
|
93
|
+
} catch (error) {
|
|
94
|
+
native.dispose();
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
chosen = native;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (!(error instanceof DecisionConfigError)) throw error;
|
|
100
|
+
chosen = new LayaWorkerClient(this.options);
|
|
101
|
+
}
|
|
102
|
+
this.resolved = chosen;
|
|
103
|
+
// dispose() raced the native probe: the chosen client must not outlive us.
|
|
104
|
+
if (this.disposed) chosen.dispose();
|
|
105
|
+
return chosen;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Construct the client for the resolved core. */
|
|
110
|
+
export function createLayaClient(options: LayaDriverOptions = {}): LayaClient {
|
|
111
|
+
switch (resolveLayaCore(options)) {
|
|
112
|
+
case 'mlx-native':
|
|
113
|
+
return new LayaNativeClient({ ...options, loader: options.nativeLoader });
|
|
114
|
+
case 'auto':
|
|
115
|
+
return new AutoLayaClient(options);
|
|
116
|
+
default:
|
|
117
|
+
return new LayaWorkerClient(options);
|
|
118
|
+
}
|
|
22
119
|
}
|
|
23
120
|
|
|
24
121
|
/** Format a {@link Desc} field into a string instructions value for the worker. */
|
|
25
|
-
function formatDesc(desc: Desc | undefined): string {
|
|
26
|
-
if (typeof desc === 'string') return desc;
|
|
27
|
-
if (desc === null || desc === undefined) return
|
|
122
|
+
function formatDesc(desc: Desc | undefined, fallback = ''): string {
|
|
123
|
+
if (typeof desc === 'string') return desc.trim().length > 0 ? desc : fallback;
|
|
124
|
+
if (desc === null || desc === undefined) return fallback;
|
|
28
125
|
return JSON.stringify(desc);
|
|
29
126
|
}
|
|
30
127
|
|
|
128
|
+
/** Rescale a probability distribution to sum to exactly 1.0 when rounding error exceeds 1e-6. */
|
|
129
|
+
function normalizeDistribution<K extends string | number>(probs: Record<K, number>): Record<K, number> {
|
|
130
|
+
const entries = Object.entries(probs) as [K, number][];
|
|
131
|
+
const sum = entries.reduce<number>((s, [, p]) => s + (typeof p === 'number' && Number.isFinite(p) ? p : 0), 0);
|
|
132
|
+
if (sum <= 0 || Math.abs(sum - 1) <= 1e-6) {
|
|
133
|
+
return probs;
|
|
134
|
+
}
|
|
135
|
+
const result = {} as Record<K, number>;
|
|
136
|
+
for (const [k, p] of entries) {
|
|
137
|
+
result[k] = p / sum;
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
31
142
|
/** Convert a neutral {@link Question} into the wire shape consumed by the worker. */
|
|
32
143
|
export function toWorkerQuestion(question: Question): WorkerQuestion {
|
|
33
144
|
switch (question.kind) {
|
|
34
145
|
case 'choice':
|
|
35
146
|
return {
|
|
36
147
|
type: 'choice',
|
|
37
|
-
instructions: formatDesc(question.prompt),
|
|
148
|
+
instructions: formatDesc(question.prompt, 'Select the best matching option.'),
|
|
38
149
|
labels: question.labels,
|
|
39
150
|
criteria: question.labels,
|
|
40
151
|
};
|
|
41
152
|
case 'score':
|
|
42
153
|
return {
|
|
43
154
|
type: 'score',
|
|
44
|
-
instructions: formatDesc(question.prompt),
|
|
155
|
+
instructions: formatDesc(question.prompt, 'Evaluate according to the rubric.'),
|
|
45
156
|
rubric: question.rubric,
|
|
46
157
|
// The runtime rejects a map here: score criteria is the rubric as a
|
|
47
158
|
// nonempty list of descriptions (laya_mlx agent.py `_to_internal`).
|
|
48
|
-
criteria: question.rubric.map(formatDesc),
|
|
159
|
+
criteria: question.rubric.map((r) => formatDesc(r, 'Level')),
|
|
49
160
|
};
|
|
50
161
|
case 'noul': {
|
|
51
162
|
const outcomes =
|
|
@@ -54,7 +165,7 @@ export function toWorkerQuestion(question: Question): WorkerQuestion {
|
|
|
54
165
|
: { true: question.yes ?? null, false: question.no ?? null };
|
|
55
166
|
return {
|
|
56
167
|
type: 'noul',
|
|
57
|
-
instructions: formatDesc(question.prompt),
|
|
168
|
+
instructions: formatDesc(question.prompt, 'Evaluate whether the proposition is true.'),
|
|
58
169
|
...(outcomes !== undefined ? { criteria: outcomes } : {}),
|
|
59
170
|
};
|
|
60
171
|
}
|
|
@@ -77,7 +188,8 @@ export function mapWorkerAnswer(question: Question, rawAnswer: unknown): Answer
|
|
|
77
188
|
throw new DecisionBackendError('laya worker returned invalid choice answer: missing label', undefined);
|
|
78
189
|
}
|
|
79
190
|
const confidence = typeof raw.confidence === 'number' ? raw.confidence : 0;
|
|
80
|
-
const
|
|
191
|
+
const rawProbs = (raw.probabilities ?? {}) as Record<string, number>;
|
|
192
|
+
const probabilities = normalizeDistribution(rawProbs);
|
|
81
193
|
return {
|
|
82
194
|
kind: 'choice',
|
|
83
195
|
label,
|
|
@@ -120,7 +232,7 @@ export function mapWorkerAnswer(question: Question, rawAnswer: unknown): Answer
|
|
|
120
232
|
score,
|
|
121
233
|
confidence,
|
|
122
234
|
legend: rawLegend ? normalizedLegend : legend,
|
|
123
|
-
probabilities: normalizedProbs,
|
|
235
|
+
probabilities: normalizeDistribution(normalizedProbs),
|
|
124
236
|
};
|
|
125
237
|
}
|
|
126
238
|
case 'noul': {
|
|
@@ -146,10 +258,14 @@ export function mapWorkerAnswer(question: Question, rawAnswer: unknown): Answer
|
|
|
146
258
|
* Exposes a readonly `name: 'laya-local'` and a single `ask` method with no sugar.
|
|
147
259
|
*/
|
|
148
260
|
export function createLayaDriver(options: LayaDriverOptions = {}): DecisionDriver {
|
|
149
|
-
const client = options.client ??
|
|
261
|
+
const client = options.client ?? createLayaClient(options);
|
|
150
262
|
|
|
151
263
|
return {
|
|
152
264
|
name: 'laya-local',
|
|
265
|
+
// The client holds a warm Python worker for its lifetime (and the worker's open pipes keep
|
|
266
|
+
// a one-shot parent alive), so the driver exposes the release the client already owns; a
|
|
267
|
+
// consumer that made no call has nothing to close and every dispose is idempotent.
|
|
268
|
+
dispose: () => client.dispose(),
|
|
153
269
|
async ask({
|
|
154
270
|
state,
|
|
155
271
|
questions,
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @gobing-ai/ts-laya-mlx — Local Laya decision backend driving the vendored MLX runtime.
|
|
3
|
-
* Implements the {@link DecisionDriver} interface for `@gobing-ai/ts-ai-runner`
|
|
3
|
+
* Implements the {@link DecisionDriver} interface for `@gobing-ai/ts-ai-runner` on one of two MLX cores:
|
|
4
|
+
* a JSON-lines Python worker (`mlx-python`, default) or in-process `@johnhenry/laya` (`mlx-native`).
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
export { createLayaDriver, type LayaDriverOptions } from './driver';
|
|
7
7
|
export {
|
|
8
|
+
createLayaClient,
|
|
9
|
+
createLayaDriver,
|
|
10
|
+
type LayaClient,
|
|
11
|
+
type LayaCore,
|
|
12
|
+
type LayaDriverOptions,
|
|
13
|
+
resolveLayaCore,
|
|
14
|
+
} from './driver';
|
|
15
|
+
export { LayaNativeClient, type LayaNativeClientOptions, type LayaNativeLoader } from './native-client';
|
|
16
|
+
export {
|
|
17
|
+
DEFAULT_LAYA_MODEL_ID,
|
|
8
18
|
LayaWorkerClient,
|
|
9
19
|
type LayaWorkerClientOptions,
|
|
10
20
|
type LayaWorkerResult,
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process MLX client over `@johnhenry/laya` (`mlx-native` core).
|
|
3
|
+
* Runs the same Laya checkpoint on Apple Silicon Metal through libmlxc FFI —
|
|
4
|
+
* no Python interpreter, no child process. Exposes the same `ask()` shape as
|
|
5
|
+
* {@link LayaWorkerClient} so the driver maps answers identically for both cores.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { DecisionBackendError, DecisionConfigError } from '@gobing-ai/ts-ai-runner';
|
|
9
|
+
import type { LayaAgent, Questions } from '@johnhenry/laya';
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_LAYA_MODEL_ID,
|
|
12
|
+
hasNonFiniteValues,
|
|
13
|
+
type LayaWorkerResult,
|
|
14
|
+
validateHostPrerequisites,
|
|
15
|
+
type WorkerQuestion,
|
|
16
|
+
} from './worker-client';
|
|
17
|
+
|
|
18
|
+
/** Loads a checkpoint into a ready agent; the seam the tests substitute. */
|
|
19
|
+
export type LayaNativeLoader = (target: string, opts: Record<string, unknown>) => Promise<LayaAgent>;
|
|
20
|
+
|
|
21
|
+
/** Configuration for {@link LayaNativeClient}. */
|
|
22
|
+
export interface LayaNativeClientOptions {
|
|
23
|
+
/** Hugging Face model id. Default: `convaiinnovations/laya-multilingual`. */
|
|
24
|
+
modelId?: string;
|
|
25
|
+
/** Local checkpoint directory (e.g. a fine-tuned model); wins over `modelId`. */
|
|
26
|
+
modelPath?: string;
|
|
27
|
+
/** Checkpoint revision pinned for resolution. */
|
|
28
|
+
revision?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Not supported natively: `@johnhenry/laya` resolves the hub cache from the
|
|
31
|
+
* process environment only. Set → construction fails rather than being silently ignored.
|
|
32
|
+
*/
|
|
33
|
+
cacheDir?: string;
|
|
34
|
+
/** Weight precision. `bfloat16` is not supported by the native backend. Default: `'float16'`. */
|
|
35
|
+
dtype?: 'float32' | 'float16' | 'bfloat16';
|
|
36
|
+
/** Questions per forward pass. Default: `16`. */
|
|
37
|
+
batchSize?: number;
|
|
38
|
+
/** Injected environment record — this package never reads the ambient environment (ADR-011). */
|
|
39
|
+
env?: Record<string, string | undefined>;
|
|
40
|
+
platform?: string;
|
|
41
|
+
arch?: string;
|
|
42
|
+
/** Injected loader (tests). Default: dynamic import of `@johnhenry/laya`'s `load`. */
|
|
43
|
+
loader?: LayaNativeLoader;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const defaultLoader: LayaNativeLoader = async (target, opts) => {
|
|
47
|
+
// Dynamic import keeps libmlxc/koffi out of the module graph when the python core is used.
|
|
48
|
+
const { load } = await import('@johnhenry/laya');
|
|
49
|
+
return load(target, opts);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* In-process MLX client over `@johnhenry/laya`: same wire shape and `ask()`
|
|
54
|
+
* contract as {@link LayaWorkerClient}, backed by libmlxc instead of a Python
|
|
55
|
+
* worker. Construction validates the host and the options the native backend
|
|
56
|
+
* cannot honor; the checkpoint is loaded lazily on the first `warm()`/`ask()`.
|
|
57
|
+
*/
|
|
58
|
+
export class LayaNativeClient {
|
|
59
|
+
private agent: LayaAgent | null = null;
|
|
60
|
+
private loading: Promise<LayaAgent> | null = null;
|
|
61
|
+
private chain: Promise<unknown> = Promise.resolve();
|
|
62
|
+
private disposed = false;
|
|
63
|
+
private readonly target: string;
|
|
64
|
+
private readonly loadOptions: Record<string, unknown>;
|
|
65
|
+
private readonly loader: LayaNativeLoader;
|
|
66
|
+
|
|
67
|
+
constructor(options: LayaNativeClientOptions = {}) {
|
|
68
|
+
validateHostPrerequisites(options.platform, options.arch);
|
|
69
|
+
const cacheDir = options.cacheDir ?? options.env?.LAYA_CACHE_DIR;
|
|
70
|
+
if (cacheDir !== undefined) {
|
|
71
|
+
throw new DecisionConfigError(
|
|
72
|
+
"laya core 'mlx-native' cannot honor cacheDir/LAYA_CACHE_DIR; set HF_HUB_CACHE for the process or use core 'mlx-python'",
|
|
73
|
+
'LAYA_CACHE_DIR',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (options.dtype === 'bfloat16') {
|
|
77
|
+
throw new DecisionConfigError(
|
|
78
|
+
"laya core 'mlx-native' supports dtype 'float16' or 'float32', not 'bfloat16'",
|
|
79
|
+
'LAYA_DTYPE',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
this.target =
|
|
83
|
+
options.modelPath ??
|
|
84
|
+
options.env?.LAYA_MODEL_PATH ??
|
|
85
|
+
options.modelId ??
|
|
86
|
+
options.env?.LAYA_MODEL_ID ??
|
|
87
|
+
DEFAULT_LAYA_MODEL_ID;
|
|
88
|
+
this.loadOptions = {
|
|
89
|
+
backend: 'mlx',
|
|
90
|
+
dtype: options.dtype === 'float32' ? 'f32' : 'f16',
|
|
91
|
+
batchSize: options.batchSize ?? 16,
|
|
92
|
+
...(options.revision !== undefined ? { revision: options.revision } : {}),
|
|
93
|
+
...(options.env?.HF_TOKEN ? { token: options.env.HF_TOKEN } : {}),
|
|
94
|
+
// The temperature-clamping notice would otherwise land on the host's console.
|
|
95
|
+
warn: () => {},
|
|
96
|
+
};
|
|
97
|
+
this.loader = options.loader ?? defaultLoader;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Checkpoint target actually loaded (model path or hub id). */
|
|
101
|
+
get model(): string {
|
|
102
|
+
return this.target;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Eagerly load the weights; `auto` core resolution uses this to probe native availability. */
|
|
106
|
+
async warm(): Promise<void> {
|
|
107
|
+
await this.ensureAgent();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Ask one batch of worker-format questions; serialized like the python worker. */
|
|
111
|
+
ask(state: string, questions: Record<string, WorkerQuestion>): Promise<LayaWorkerResult> {
|
|
112
|
+
const run = this.chain.then(() => this.dispatch(state, questions));
|
|
113
|
+
this.chain = run.then(
|
|
114
|
+
() => undefined,
|
|
115
|
+
() => undefined,
|
|
116
|
+
);
|
|
117
|
+
return run;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Release Metal buffers; safe to call repeatedly. */
|
|
121
|
+
dispose(): void {
|
|
122
|
+
this.disposed = true;
|
|
123
|
+
this.agent?.dispose();
|
|
124
|
+
this.agent = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async dispatch(state: string, questions: Record<string, WorkerQuestion>): Promise<LayaWorkerResult> {
|
|
128
|
+
const agent = await this.ensureAgent();
|
|
129
|
+
let result: LayaWorkerResult;
|
|
130
|
+
try {
|
|
131
|
+
// WorkerQuestion is the laya-mlx wire shape, which @johnhenry/laya ports 1:1.
|
|
132
|
+
result = (await agent.predict(state, questions as unknown as Questions)) as unknown as LayaWorkerResult;
|
|
133
|
+
} catch (cause) {
|
|
134
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
135
|
+
throw new DecisionBackendError(`laya native inference failed: ${message}`, undefined, { cause });
|
|
136
|
+
}
|
|
137
|
+
if (hasNonFiniteValues(result.answers)) {
|
|
138
|
+
throw new DecisionBackendError(
|
|
139
|
+
"laya native returned non-finite model outputs; retry with dtype='float32'",
|
|
140
|
+
undefined,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private ensureAgent(): Promise<LayaAgent> {
|
|
147
|
+
if (this.disposed) {
|
|
148
|
+
return Promise.reject(new DecisionBackendError('laya native client has been disposed', undefined));
|
|
149
|
+
}
|
|
150
|
+
if (this.agent) return Promise.resolve(this.agent);
|
|
151
|
+
this.loading ??= this.loader(this.target, this.loadOptions).then(
|
|
152
|
+
(agent) => {
|
|
153
|
+
this.loading = null;
|
|
154
|
+
if (this.disposed) {
|
|
155
|
+
agent.dispose();
|
|
156
|
+
throw new DecisionBackendError('laya native client has been disposed', undefined);
|
|
157
|
+
}
|
|
158
|
+
this.agent = agent;
|
|
159
|
+
return agent;
|
|
160
|
+
},
|
|
161
|
+
(cause: unknown) => {
|
|
162
|
+
this.loading = null;
|
|
163
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
164
|
+
throw new DecisionConfigError(
|
|
165
|
+
`laya core 'mlx-native' failed to load '${this.target}': ${message}`,
|
|
166
|
+
'LAYA_CORE',
|
|
167
|
+
{ cause },
|
|
168
|
+
);
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
return this.loading;
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/worker-client.ts
CHANGED
|
@@ -20,7 +20,14 @@ import {
|
|
|
20
20
|
DecisionRequestError,
|
|
21
21
|
DecisionTimeoutError,
|
|
22
22
|
} from '@gobing-ai/ts-ai-runner';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
type FileSystem,
|
|
25
|
+
getHomeDir,
|
|
26
|
+
joinPath,
|
|
27
|
+
nodeBunFactory,
|
|
28
|
+
type PipeProcess,
|
|
29
|
+
type ProcessExecutor,
|
|
30
|
+
} from '@gobing-ai/ts-runtime';
|
|
24
31
|
|
|
25
32
|
/** Environment keys this package may pass to the child process — the full allowlist. */
|
|
26
33
|
const FORWARDED_ENV_KEYS = ['LAYA_MODEL_ID', 'LAYA_MODEL_PATH', 'LAYA_CACHE_DIR', 'LAYA_PYTHON', 'HF_TOKEN'] as const;
|
|
@@ -124,7 +131,11 @@ export function validateHostPrerequisites(platform: string = process.platform, a
|
|
|
124
131
|
}
|
|
125
132
|
}
|
|
126
133
|
|
|
127
|
-
|
|
134
|
+
/** Official Laya checkpoint used when no modelId/modelPath is configured. */
|
|
135
|
+
export const DEFAULT_LAYA_MODEL_ID = 'convaiinnovations/laya-multilingual';
|
|
136
|
+
|
|
137
|
+
/** @internal True when any number nested in `obj` is NaN/Infinity (fp16 overflow). */
|
|
138
|
+
export function hasNonFiniteValues(obj: unknown): boolean {
|
|
128
139
|
if (typeof obj === 'number') {
|
|
129
140
|
return !Number.isFinite(obj);
|
|
130
141
|
}
|
|
@@ -452,6 +463,33 @@ class WorkerHandle {
|
|
|
452
463
|
}
|
|
453
464
|
}
|
|
454
465
|
|
|
466
|
+
/** Resolve the Python interpreter to execute: explicit option -> injected env -> candidate discovery -> 'python3'. */
|
|
467
|
+
export function resolvePythonPath(
|
|
468
|
+
options: LayaWorkerClientOptions,
|
|
469
|
+
packageRoot: string,
|
|
470
|
+
cwd: string,
|
|
471
|
+
fileSystem: FileSystem = nodeBunFactory.createFileSystem(),
|
|
472
|
+
): string {
|
|
473
|
+
if (options.pythonPath !== undefined) return options.pythonPath;
|
|
474
|
+
if (options.env?.LAYA_PYTHON !== undefined && options.env.LAYA_PYTHON.length > 0) {
|
|
475
|
+
return options.env.LAYA_PYTHON;
|
|
476
|
+
}
|
|
477
|
+
const home = getHomeDir();
|
|
478
|
+
const candidates = [
|
|
479
|
+
home ? joinPath(home, '.local', 'share', 'uv', 'tools', 'laya-mlx', 'bin', 'python') : undefined,
|
|
480
|
+
home ? joinPath(home, '.virtualenvs', 'laya-mlx', 'bin', 'python') : undefined,
|
|
481
|
+
joinPath(packageRoot, '..', '..', 'vendors', 'laya-mlx', '.venv', 'bin', 'python'),
|
|
482
|
+
joinPath(cwd, '.venv', 'bin', 'python'),
|
|
483
|
+
].filter((p): p is string => p !== undefined);
|
|
484
|
+
|
|
485
|
+
for (const candidate of candidates) {
|
|
486
|
+
if (fileSystem.exists(candidate)) {
|
|
487
|
+
return candidate;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return 'python3';
|
|
491
|
+
}
|
|
492
|
+
|
|
455
493
|
/**
|
|
456
494
|
* TypeScript half of the laya process bridge: start the Python worker lazily,
|
|
457
495
|
* hold it warm for the client's lifetime, and serve serialized asks with
|
|
@@ -475,8 +513,8 @@ export class LayaWorkerClient {
|
|
|
475
513
|
) {
|
|
476
514
|
validateHostPrerequisites(options.platform, options.arch);
|
|
477
515
|
const packageRoot = joinPath(import.meta.dir, '..');
|
|
478
|
-
this.pythonPath = options.pythonPath ?? options.env?.LAYA_PYTHON ?? 'python3';
|
|
479
516
|
this.cwd = options.cwd ?? packageRoot;
|
|
517
|
+
this.pythonPath = resolvePythonPath(options, packageRoot, this.cwd);
|
|
480
518
|
this.startupTimeoutMs = options.startupTimeoutMs ?? 120_000;
|
|
481
519
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
|
|
482
520
|
this.forwardedEnv = resolveForwardedEnv(options.env);
|
|
@@ -485,7 +523,7 @@ export class LayaWorkerClient {
|
|
|
485
523
|
this.args.push('--module', options.module ?? 'laya_mlx');
|
|
486
524
|
const resolvedModelPath = options.modelPath ?? options.env?.LAYA_MODEL_PATH;
|
|
487
525
|
const resolvedCacheDir = options.cacheDir ?? options.env?.LAYA_CACHE_DIR;
|
|
488
|
-
const resolvedModelId = options.modelId ?? options.env?.LAYA_MODEL_ID ??
|
|
526
|
+
const resolvedModelId = options.modelId ?? options.env?.LAYA_MODEL_ID ?? DEFAULT_LAYA_MODEL_ID;
|
|
489
527
|
|
|
490
528
|
if (resolvedModelPath !== undefined) {
|
|
491
529
|
this.args.push('--model-path', resolvedModelPath);
|
package/worker/laya_worker.py
CHANGED
|
@@ -153,8 +153,19 @@ def handle_request(agent, line):
|
|
|
153
153
|
if not isinstance(questions, dict) or not questions:
|
|
154
154
|
return request_error(request_id, "request questions must be a nonempty object")
|
|
155
155
|
|
|
156
|
+
# For choice questions, ensure compatibility with laya_mlx >= 0.4.0 where
|
|
157
|
+
# 'labels' is rejected if present alongside 'criteria'.
|
|
158
|
+
sanitized_questions = {}
|
|
159
|
+
for qid, qdef in questions.items():
|
|
160
|
+
if isinstance(qdef, dict) and qdef.get("type") == "choice" and "labels" in qdef:
|
|
161
|
+
q_copy = dict(qdef)
|
|
162
|
+
q_copy.pop("labels", None)
|
|
163
|
+
sanitized_questions[qid] = q_copy
|
|
164
|
+
else:
|
|
165
|
+
sanitized_questions[qid] = qdef
|
|
166
|
+
|
|
156
167
|
try:
|
|
157
|
-
result = agent.predict(state,
|
|
168
|
+
result = agent.predict(state, sanitized_questions)
|
|
158
169
|
except Exception as exc: # classified and reported, never fatal to the worker
|
|
159
170
|
return {"id": request_id, "ok": False, "error": {"kind": classify(exc), "message": str(exc)}}
|
|
160
171
|
return {"id": request_id, "ok": True, "result": result}
|