@ejstembler/pi-classifier-router 1.0.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/LICENSE +21 -0
- package/README.md +519 -0
- package/examples/class-router.json +68 -0
- package/examples/class-router.pi.json +47 -0
- package/package.json +55 -0
- package/python/laya_worker.py +203 -0
- package/python/requirements.txt +10 -0
- package/src/breaker.ts +127 -0
- package/src/classify/http.ts +176 -0
- package/src/classify/index.ts +34 -0
- package/src/classify/jev.ts +23 -0
- package/src/classify/laya-http.ts +28 -0
- package/src/classify/laya.ts +383 -0
- package/src/config.ts +288 -0
- package/src/host.ts +363 -0
- package/src/index.ts +793 -0
- package/src/router.ts +135 -0
- package/src/types.ts +299 -0
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ejstembler/pi-classifier-router",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://gitlab.com/ejstembler/pi-classifier-router.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://gitlab.com/ejstembler/pi-classifier-router#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://gitlab.com/ejstembler/pi-classifier-router/-/issues"
|
|
12
|
+
},
|
|
13
|
+
"description": "Classifier-driven model router for pi and Oh My Pi (omp): routes each prompt to a model using Jev (TypeSafe) or Laya typed decisions, with circuit breaking and fallback chains",
|
|
14
|
+
"main": "src/index.ts",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=24"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"pi-package",
|
|
21
|
+
"omp-extension",
|
|
22
|
+
"pi-extension",
|
|
23
|
+
"jev",
|
|
24
|
+
"laya",
|
|
25
|
+
"router"
|
|
26
|
+
],
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./src/index.ts"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"python",
|
|
35
|
+
"examples",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"test": "node --test tests/*.test.ts"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "^0.87.1",
|
|
44
|
+
"@types/node": "^24.13.6",
|
|
45
|
+
"typescript": "^5.9.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"@earendil-works/pi-coding-agent": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Laya sidecar worker: NDJSON request/response over stdio.
|
|
3
|
+
|
|
4
|
+
stdout carries exactly one JSON object per line and nothing else, so the
|
|
5
|
+
TypeScript client can parse strictly; every diagnostic (including warnings and
|
|
6
|
+
progress) goes to stderr. Module-level state keeps the checkpoints loaded across
|
|
7
|
+
requests.
|
|
8
|
+
|
|
9
|
+
Protocol
|
|
10
|
+
request {"id": <int>, "op": "predict"|"ping"|"preload"|"shutdown", ...}
|
|
11
|
+
reply {"id": <int>, "ok": true, "result": {...}}
|
|
12
|
+
| {"id": <int>, "ok": false, "error": "...", "kind": "unavailable"|"timeout"|"protocol"}
|
|
13
|
+
event {"event": "ready", "version": "...", "loaded": ["english", ...]}
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
import traceback
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def emit(payload: dict) -> None:
|
|
25
|
+
"""Write one JSON line and flush immediately (the client is line-driven)."""
|
|
26
|
+
sys.stdout.write(json.dumps(payload) + "\n")
|
|
27
|
+
sys.stdout.flush()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import laya
|
|
32
|
+
except Exception as exc: # A missing or broken install must not look like a protocol fault.
|
|
33
|
+
emit({"event": "error", "error": f"laya import failed: {exc!r}", "kind": "unavailable"})
|
|
34
|
+
print("laya_worker: could not import laya", file=sys.stderr)
|
|
35
|
+
print(traceback.format_exc(), file=sys.stderr)
|
|
36
|
+
sys.stderr.flush()
|
|
37
|
+
raise SystemExit(2)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
STATE = {"agent": None, "loaded": []}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def describe_loaded(agent, fallback: str | None) -> list[str]:
|
|
44
|
+
"""Best-effort checkpoint names, for the ready/preload/ping payloads."""
|
|
45
|
+
for attr in ("loaded", "checkpoints", "loaded_checkpoints", "checkpoint_names"):
|
|
46
|
+
value = getattr(agent, attr, None)
|
|
47
|
+
if isinstance(value, dict) and value:
|
|
48
|
+
return [str(key) for key in value]
|
|
49
|
+
if isinstance(value, (list, tuple)) and value:
|
|
50
|
+
return [str(item) for item in value]
|
|
51
|
+
return [fallback] if fallback else []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ensure_agent(args) -> object:
|
|
55
|
+
"""Load the checkpoints once; later calls reuse the module-level agent."""
|
|
56
|
+
if STATE["agent"] is not None:
|
|
57
|
+
return STATE["agent"]
|
|
58
|
+
if args.router:
|
|
59
|
+
kwargs: dict = {"preload": args.preload}
|
|
60
|
+
if args.device:
|
|
61
|
+
kwargs["device"] = args.device
|
|
62
|
+
try:
|
|
63
|
+
agent = laya.Router(max_loaded=args.max_loaded, **kwargs)
|
|
64
|
+
except TypeError:
|
|
65
|
+
# Older Router builds do not take max_loaded.
|
|
66
|
+
agent = laya.Router(**kwargs)
|
|
67
|
+
else:
|
|
68
|
+
kwargs = {"subfolder": args.subfolder or None}
|
|
69
|
+
if args.device:
|
|
70
|
+
kwargs["device"] = args.device
|
|
71
|
+
agent = laya.load(args.repo, **kwargs)
|
|
72
|
+
STATE["agent"] = agent
|
|
73
|
+
return agent
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def normalize(reply) -> dict:
|
|
77
|
+
"""Reduce a backend reply to `{model?, answers, usage?}`."""
|
|
78
|
+
if isinstance(reply, dict):
|
|
79
|
+
model = reply.get("model")
|
|
80
|
+
answers = reply.get("answers")
|
|
81
|
+
usage = reply.get("usage")
|
|
82
|
+
else:
|
|
83
|
+
model = getattr(reply, "model", None)
|
|
84
|
+
answers = getattr(reply, "answers", None)
|
|
85
|
+
usage = getattr(reply, "usage", None)
|
|
86
|
+
if not isinstance(answers, dict):
|
|
87
|
+
raise ValueError("laya returned no answers mapping")
|
|
88
|
+
result: dict = {"answers": answers}
|
|
89
|
+
if isinstance(model, str):
|
|
90
|
+
result["model"] = model
|
|
91
|
+
if isinstance(usage, dict):
|
|
92
|
+
result["usage"] = usage
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def error_kind(exc: BaseException) -> str:
|
|
97
|
+
if isinstance(exc, TimeoutError):
|
|
98
|
+
return "timeout"
|
|
99
|
+
if isinstance(exc, (ImportError, RuntimeError)):
|
|
100
|
+
return "unavailable"
|
|
101
|
+
return "protocol"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def handle(request: dict, args) -> dict | None:
|
|
105
|
+
"""Serve one request; returns the reply, or None for shutdown."""
|
|
106
|
+
rid = request.get("id")
|
|
107
|
+
op = request.get("op")
|
|
108
|
+
|
|
109
|
+
if op == "predict":
|
|
110
|
+
agent = ensure_agent(args)
|
|
111
|
+
state = request.get("state")
|
|
112
|
+
questions = request.get("questions")
|
|
113
|
+
model = request.get("model")
|
|
114
|
+
reply = agent.predict(state, questions, model=model) if model else agent.predict(state, questions)
|
|
115
|
+
return {"id": rid, "ok": True, "result": normalize(reply)}
|
|
116
|
+
|
|
117
|
+
if op == "ping":
|
|
118
|
+
return {
|
|
119
|
+
"id": rid,
|
|
120
|
+
"ok": True,
|
|
121
|
+
"result": {
|
|
122
|
+
"laya": True,
|
|
123
|
+
"version": getattr(laya, "__version__", "unknown"),
|
|
124
|
+
"loaded": list(STATE["loaded"]),
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if op == "preload":
|
|
129
|
+
agent = ensure_agent(args)
|
|
130
|
+
STATE["loaded"] = describe_loaded(agent, args.subfolder or "english")
|
|
131
|
+
return {"id": rid, "ok": True, "result": {"loaded": list(STATE["loaded"])}}
|
|
132
|
+
|
|
133
|
+
if op == "shutdown":
|
|
134
|
+
return {"id": rid, "ok": True, "result": {"bye": True}}
|
|
135
|
+
|
|
136
|
+
return {"id": rid, "ok": False, "error": f"unknown op: {op!r}", "kind": "protocol"}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main() -> int:
|
|
140
|
+
parser = argparse.ArgumentParser(prog="laya_worker", description="Laya System One sidecar")
|
|
141
|
+
parser.add_argument("--repo", required=True, help="Hugging Face repo bundling the checkpoints")
|
|
142
|
+
parser.add_argument("--subfolder", default="", help="checkpoint subfolder; empty means the English root")
|
|
143
|
+
parser.add_argument("--device", default="", help="torch device; empty means auto-detect")
|
|
144
|
+
parser.add_argument("--router", action="store_true", help="serve through laya.Router")
|
|
145
|
+
parser.add_argument("--preload", action="store_true", help="load checkpoints before serving")
|
|
146
|
+
parser.add_argument("--max-loaded", type=int, default=1, help="checkpoint budget passed to laya.Router")
|
|
147
|
+
args = parser.parse_args()
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass # flush() after every line is the real guarantee.
|
|
153
|
+
|
|
154
|
+
if args.preload:
|
|
155
|
+
ensure_agent(args)
|
|
156
|
+
STATE["loaded"] = describe_loaded(STATE["agent"], args.subfolder or "english")
|
|
157
|
+
else:
|
|
158
|
+
STATE["loaded"] = []
|
|
159
|
+
|
|
160
|
+
emit(
|
|
161
|
+
{
|
|
162
|
+
"event": "ready",
|
|
163
|
+
"version": getattr(laya, "__version__", "unknown"),
|
|
164
|
+
"loaded": list(STATE["loaded"]),
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
for raw in sys.stdin:
|
|
169
|
+
line = raw.strip()
|
|
170
|
+
if not line:
|
|
171
|
+
continue
|
|
172
|
+
try:
|
|
173
|
+
request = json.loads(line)
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
print(f"laya_worker: ignoring unparseable request line ({exc})", file=sys.stderr)
|
|
176
|
+
continue
|
|
177
|
+
if not isinstance(request, dict):
|
|
178
|
+
print("laya_worker: ignoring non-object request", file=sys.stderr)
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
reply = handle(request, args)
|
|
183
|
+
if reply is not None:
|
|
184
|
+
emit(reply)
|
|
185
|
+
except BaseException as exc:
|
|
186
|
+
print(traceback.format_exc(), file=sys.stderr)
|
|
187
|
+
emit(
|
|
188
|
+
{
|
|
189
|
+
"id": request.get("id"),
|
|
190
|
+
"ok": False,
|
|
191
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
192
|
+
"kind": error_kind(exc),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if request.get("op") == "shutdown":
|
|
197
|
+
sys.stdout.flush()
|
|
198
|
+
return 0
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Laya sidecar dependencies for python/laya_worker.py.
|
|
2
|
+
#
|
|
3
|
+
# Upstream floor: Python 3.10+, transformers>=5, torch>=2.14. Install these
|
|
4
|
+
# first (or let pip resolve them transitively below) and match the wheels to
|
|
5
|
+
# the machine:
|
|
6
|
+
# * CPU-only installs are supported but slower; use the CPU wheel index for
|
|
7
|
+
# torch and leave --device unset (auto-detect) or set it to "cpu".
|
|
8
|
+
# * CUDA/MPS installs should use the platform-specific torch wheels before
|
|
9
|
+
# installing laya.
|
|
10
|
+
laya
|
package/src/breaker.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-model circuit breaker.
|
|
3
|
+
*
|
|
4
|
+
* `closed` -> `open` after `failureThreshold` consecutive failures.
|
|
5
|
+
* `open` -> `half_open` once `cooldownMs` has elapsed, letting through at most
|
|
6
|
+
* `halfOpenMaxTrials` concurrent probes. A probe success closes the circuit; a
|
|
7
|
+
* probe failure re-opens it with a fresh cooldown.
|
|
8
|
+
*
|
|
9
|
+
* The clock is injectable so tests need no timers. `allow` is a query: it must
|
|
10
|
+
* stay safe to call repeatedly (the extension arbitrates availability per
|
|
11
|
+
* prompt), so it never accumulates failures or successes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { CircuitBreakerConfig } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
export type BreakerState = "closed" | "open" | "half_open";
|
|
17
|
+
|
|
18
|
+
export interface BreakerSnapshot {
|
|
19
|
+
spec: string;
|
|
20
|
+
state: BreakerState;
|
|
21
|
+
failures: number;
|
|
22
|
+
openedAt: number | null;
|
|
23
|
+
halfOpenTrials: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Circuit {
|
|
27
|
+
state: BreakerState;
|
|
28
|
+
failures: number;
|
|
29
|
+
openedAt: number | null;
|
|
30
|
+
/** Probes started while half-open and not yet resolved by a record call. */
|
|
31
|
+
halfOpenTrials: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class CircuitBreaker {
|
|
35
|
+
readonly #config: CircuitBreakerConfig;
|
|
36
|
+
readonly #now: () => number;
|
|
37
|
+
readonly #circuits = new Map<string, Circuit>();
|
|
38
|
+
|
|
39
|
+
constructor(config: CircuitBreakerConfig, now: () => number = Date.now) {
|
|
40
|
+
this.#config = config;
|
|
41
|
+
this.#now = now;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#circuit(spec: string): Circuit {
|
|
45
|
+
const existing = this.#circuits.get(spec);
|
|
46
|
+
if (existing) return existing;
|
|
47
|
+
const created: Circuit = { state: "closed", failures: 0, openedAt: null, halfOpenTrials: 0 };
|
|
48
|
+
this.#circuits.set(spec, created);
|
|
49
|
+
return created;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether a request to `spec` may proceed right now. */
|
|
53
|
+
allow(spec: string): boolean {
|
|
54
|
+
const circuit = this.#circuit(spec);
|
|
55
|
+
|
|
56
|
+
if (circuit.state === "closed") return true;
|
|
57
|
+
|
|
58
|
+
if (circuit.state === "open") {
|
|
59
|
+
if (circuit.openedAt === null || this.#now() - circuit.openedAt < this.#config.cooldownMs) return false;
|
|
60
|
+
circuit.state = "half_open";
|
|
61
|
+
circuit.halfOpenTrials = 0;
|
|
62
|
+
circuit.halfOpenTrials += 1;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (circuit.halfOpenTrials >= this.#config.halfOpenMaxTrials) return false;
|
|
67
|
+
circuit.halfOpenTrials += 1;
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
recordSuccess(spec: string): void {
|
|
72
|
+
const circuit = this.#circuit(spec);
|
|
73
|
+
circuit.state = "closed";
|
|
74
|
+
circuit.failures = 0;
|
|
75
|
+
circuit.openedAt = null;
|
|
76
|
+
circuit.halfOpenTrials = 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
recordFailure(spec: string): void {
|
|
80
|
+
const circuit = this.#circuit(spec);
|
|
81
|
+
|
|
82
|
+
if (circuit.state === "half_open") {
|
|
83
|
+
circuit.state = "open";
|
|
84
|
+
circuit.openedAt = this.#now();
|
|
85
|
+
circuit.halfOpenTrials = 0;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
circuit.failures += 1;
|
|
90
|
+
if (circuit.state === "closed" && circuit.failures >= this.#config.failureThreshold) {
|
|
91
|
+
circuit.state = "open";
|
|
92
|
+
circuit.openedAt = this.#now();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Clear one spec, or every spec when called without an argument. */
|
|
97
|
+
reset(spec?: string): void {
|
|
98
|
+
if (spec === undefined) {
|
|
99
|
+
this.#circuits.clear();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.#circuits.delete(spec);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One entry per known spec, in first-seen order. */
|
|
106
|
+
snapshot(): BreakerSnapshot[] {
|
|
107
|
+
const snapshots: BreakerSnapshot[] = [];
|
|
108
|
+
for (const [spec, circuit] of this.#circuits) {
|
|
109
|
+
snapshots.push({
|
|
110
|
+
spec,
|
|
111
|
+
state: circuit.state,
|
|
112
|
+
failures: circuit.failures,
|
|
113
|
+
openedAt: circuit.openedAt,
|
|
114
|
+
halfOpenTrials: circuit.halfOpenTrials,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return snapshots;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** First candidate currently allowing a request, or null when none do. */
|
|
121
|
+
firstAvailable(chain: string[]): string | null {
|
|
122
|
+
for (const spec of chain) {
|
|
123
|
+
if (this.allow(spec)) return spec;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared System-One HTTP client.
|
|
3
|
+
*
|
|
4
|
+
* Both backends speak the same wire format, so this is the single transport:
|
|
5
|
+
* `POST <endpoint>` with `Authorization: Bearer <token>` (only when a token is
|
|
6
|
+
* configured and present), `Content-Type: application/json`, and body
|
|
7
|
+
* `{ state, model?, questions }`; response `{ model?, answers, usage? }`.
|
|
8
|
+
*
|
|
9
|
+
* The bearer token is read from the configured environment variable and is
|
|
10
|
+
* never logged or embedded in errors. Every request is bounded by `timeoutMs`,
|
|
11
|
+
* composed with the caller's own signal so either side can abort, and the
|
|
12
|
+
* timer is always cleared.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { ClassifierError } from "../types.ts";
|
|
16
|
+
import type {
|
|
17
|
+
Answers,
|
|
18
|
+
ClassificationResult,
|
|
19
|
+
Classifier,
|
|
20
|
+
ClassifierErrorCode,
|
|
21
|
+
ClassifierState,
|
|
22
|
+
ClassifyOptions,
|
|
23
|
+
Questions,
|
|
24
|
+
} from "../types.ts";
|
|
25
|
+
import type { ClassifierDeps } from "./index.ts";
|
|
26
|
+
|
|
27
|
+
/** Body snippet length kept for diagnostics; long bodies are never echoed. */
|
|
28
|
+
const SNIPPET_LIMIT = 200;
|
|
29
|
+
|
|
30
|
+
export interface HttpClassifierOptions {
|
|
31
|
+
/** Backend id reported on `Classifier.name` and on `ClassifierError.backend`. */
|
|
32
|
+
name: string;
|
|
33
|
+
endpoint: string;
|
|
34
|
+
/** Value for the request body's `model` field; omitted from the body when null. */
|
|
35
|
+
model: string | null;
|
|
36
|
+
/** Env var holding the bearer token; when unset or empty, no Authorization header is sent. */
|
|
37
|
+
apiKeyEnvVar: string;
|
|
38
|
+
timeoutMs: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createHttpClassifier(options: HttpClassifierOptions, deps: ClassifierDeps = {}): Classifier {
|
|
42
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
43
|
+
const backend = options.name;
|
|
44
|
+
|
|
45
|
+
function token(): string {
|
|
46
|
+
const envVar = options.apiKeyEnvVar;
|
|
47
|
+
if (envVar.trim() === "") return "";
|
|
48
|
+
const fromDeps = deps.env?.[envVar];
|
|
49
|
+
const value = fromDeps ?? process.env[envVar];
|
|
50
|
+
return (value ?? "").trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
name: backend,
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Uniform no-op: probing the endpoint would spend tokens for no signal, and
|
|
58
|
+
* `classify` works without a prior warmup.
|
|
59
|
+
*/
|
|
60
|
+
async warmup(): Promise<void> {},
|
|
61
|
+
|
|
62
|
+
async classify(
|
|
63
|
+
state: ClassifierState,
|
|
64
|
+
questions: Questions,
|
|
65
|
+
classifyOptions?: ClassifyOptions,
|
|
66
|
+
): Promise<ClassificationResult> {
|
|
67
|
+
const bearer = token();
|
|
68
|
+
const envVar = options.apiKeyEnvVar;
|
|
69
|
+
if (bearer === "" && envVar.trim() !== "") {
|
|
70
|
+
throw new ClassifierError(
|
|
71
|
+
backend,
|
|
72
|
+
"unavailable",
|
|
73
|
+
`${envVar} is not set; ${backend} is unavailable`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
78
|
+
if (bearer !== "") headers["Authorization"] = `Bearer ${bearer}`;
|
|
79
|
+
|
|
80
|
+
const body: Record<string, unknown> = { state, questions };
|
|
81
|
+
if (options.model !== null) body["model"] = options.model;
|
|
82
|
+
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
let timedOut = false;
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
timedOut = true;
|
|
87
|
+
controller.abort();
|
|
88
|
+
}, options.timeoutMs);
|
|
89
|
+
const caller = classifyOptions?.signal;
|
|
90
|
+
const onCallerAbort = (): void => controller.abort();
|
|
91
|
+
if (caller) {
|
|
92
|
+
if (caller.aborted) controller.abort();
|
|
93
|
+
else caller.addEventListener("abort", onCallerAbort, { once: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const response = await fetchImpl(options.endpoint, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers,
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
signal: controller.signal,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const bodyText = await response.text();
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
const code: ClassifierErrorCode =
|
|
107
|
+
response.status === 401 || response.status === 403
|
|
108
|
+
? "auth"
|
|
109
|
+
: response.status === 429 || response.status >= 500
|
|
110
|
+
? "unavailable"
|
|
111
|
+
: "protocol";
|
|
112
|
+
const collapsed = bodyText.replace(/\s+/g, " ").trim().slice(0, SNIPPET_LIMIT);
|
|
113
|
+
throw new ClassifierError(
|
|
114
|
+
backend,
|
|
115
|
+
code,
|
|
116
|
+
`${backend} endpoint returned ${response.status}: ${collapsed}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let parsed: unknown;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(bodyText);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
throw new ClassifierError(backend, "protocol", `${backend} response was not JSON`, {
|
|
125
|
+
cause: error,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
129
|
+
throw new ClassifierError(backend, "protocol", `${backend} response was not a JSON object`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const record = parsed as Record<string, unknown>;
|
|
133
|
+
const answers = record["answers"];
|
|
134
|
+
if (answers === null || typeof answers !== "object" || Array.isArray(answers)) {
|
|
135
|
+
throw new ClassifierError(backend, "protocol", `${backend} response has no answers object`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const result: ClassificationResult = { answers: answers as Answers };
|
|
139
|
+
if (typeof record["model"] === "string") result.model = record["model"];
|
|
140
|
+
const usage = record["usage"];
|
|
141
|
+
if (usage !== null && typeof usage === "object" && !Array.isArray(usage)) {
|
|
142
|
+
result.usage = usage as ClassificationResult["usage"];
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error instanceof ClassifierError) throw error;
|
|
147
|
+
if (controller.signal.aborted) {
|
|
148
|
+
if (caller?.aborted) {
|
|
149
|
+
throw new ClassifierError(backend, "aborted", `${backend} request aborted by caller`, {
|
|
150
|
+
cause: error,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (timedOut) {
|
|
154
|
+
throw new ClassifierError(
|
|
155
|
+
backend,
|
|
156
|
+
"timeout",
|
|
157
|
+
`${backend} request exceeded ${options.timeoutMs}ms`,
|
|
158
|
+
{ cause: error },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
throw new ClassifierError(
|
|
163
|
+
backend,
|
|
164
|
+
"transport",
|
|
165
|
+
`${backend} request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
166
|
+
{ cause: error },
|
|
167
|
+
);
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
if (caller) caller.removeEventListener("abort", onCallerAbort);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async dispose(): Promise<void> {},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifier selection: one factory per backend, chosen by `config.backend`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Classifier, RouterConfig } from "../types.ts";
|
|
6
|
+
import { createJevClassifier } from "./jev.ts";
|
|
7
|
+
import { createLayaClassifier, type ChildProcessLike, type LayaSpawn } from "./laya.ts";
|
|
8
|
+
import { createLayaHttpClassifier } from "./laya-http.ts";
|
|
9
|
+
|
|
10
|
+
/** Injection seams for tests and for the extension's own environment. */
|
|
11
|
+
export interface ClassifierDeps {
|
|
12
|
+
/** Environment for token lookup; defaults to `process.env`. */
|
|
13
|
+
env?: Record<string, string | undefined>;
|
|
14
|
+
/** `fetch` override; defaults to the global implementation. */
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
/** Sidecar spawner; defaults to `node:child_process.spawn`. */
|
|
17
|
+
spawnImpl?: LayaSpawn;
|
|
18
|
+
/** Diagnostic sink for non-fatal sidecar noise. */
|
|
19
|
+
logger?: (message: string) => void;
|
|
20
|
+
/** Base for relative `laya.workerScript` paths; defaults to the repo root. */
|
|
21
|
+
extensionRoot?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createClassifier(config: RouterConfig, deps: ClassifierDeps = {}): Classifier {
|
|
25
|
+
if (config.backend === "laya") {
|
|
26
|
+
return config.laya.transport === "http"
|
|
27
|
+
? createLayaHttpClassifier(config.laya, deps)
|
|
28
|
+
: createLayaClassifier(config.laya, deps);
|
|
29
|
+
}
|
|
30
|
+
return createJevClassifier(config.jev, deps);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type { ChildProcessLike, LayaSpawn };
|
|
34
|
+
export type { LayaTransport } from "../types.ts";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeSafe Jev (System One) classifier.
|
|
3
|
+
*
|
|
4
|
+
* Jev speaks the shared System-One HTTP wire format, so this is a thin wrapper
|
|
5
|
+
* over `createHttpClassifier` pinned to the `jev` backend id.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Classifier, JevBackendConfig } from "../types.ts";
|
|
9
|
+
import { createHttpClassifier } from "./http.ts";
|
|
10
|
+
import type { ClassifierDeps } from "./index.ts";
|
|
11
|
+
|
|
12
|
+
export function createJevClassifier(config: JevBackendConfig, deps: ClassifierDeps = {}): Classifier {
|
|
13
|
+
return createHttpClassifier(
|
|
14
|
+
{
|
|
15
|
+
name: "jev",
|
|
16
|
+
endpoint: config.endpoint,
|
|
17
|
+
model: config.model,
|
|
18
|
+
apiKeyEnvVar: config.apiKeyEnvVar,
|
|
19
|
+
timeoutMs: config.timeoutMs,
|
|
20
|
+
},
|
|
21
|
+
deps,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Laya (remote System One) classifier over HTTP.
|
|
3
|
+
*
|
|
4
|
+
* Targets any host running the same typed-question protocol as the local
|
|
5
|
+
* sidecar: a containerized `python/laya_worker.py`, a FastAPI wrapper, or any
|
|
6
|
+
* other compatible System-One server. It spawns no child process, so the
|
|
7
|
+
* deployment shape "one shared GPU host, thin TypeScript clients" needs no
|
|
8
|
+
* local Laya install.
|
|
9
|
+
*
|
|
10
|
+
* The remote host owns checkpoint selection, so no `model` field is sent.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Classifier, LayaBackendConfig } from "../types.ts";
|
|
14
|
+
import { createHttpClassifier } from "./http.ts";
|
|
15
|
+
import type { ClassifierDeps } from "./index.ts";
|
|
16
|
+
|
|
17
|
+
export function createLayaHttpClassifier(config: LayaBackendConfig, deps: ClassifierDeps = {}): Classifier {
|
|
18
|
+
return createHttpClassifier(
|
|
19
|
+
{
|
|
20
|
+
name: "laya",
|
|
21
|
+
endpoint: config.endpoint,
|
|
22
|
+
model: null,
|
|
23
|
+
apiKeyEnvVar: config.apiKeyEnvVar,
|
|
24
|
+
timeoutMs: config.timeoutMs,
|
|
25
|
+
},
|
|
26
|
+
deps,
|
|
27
|
+
);
|
|
28
|
+
}
|