@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Edward J. Stembler
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
# @ejstembler/pi-classifier-router
|
|
2
|
+
|
|
3
|
+
[](https://gitlab.com/ejstembler/pi-classifier-router/-/pipelines)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](package.json)
|
|
6
|
+
|
|
7
|
+
An extension for pi and Oh My Pi (omp) that classifies each incoming prompt with a
|
|
8
|
+
System-One model and routes the session to the model that fits it.
|
|
9
|
+
|
|
10
|
+
On `before_agent_start` the extension sends the prompt to a classifier (TypeSafe
|
|
11
|
+
**Jev** over HTTP, or **Laya** either in a local Python sidecar or on a shared
|
|
12
|
+
host over HTTP), reads the typed answer that names a category, maps that
|
|
13
|
+
category to a model spec, and applies it with `pi.setModel` before the turn
|
|
14
|
+
reaches the provider. A per-spec circuit breaker plus ordered fallback chains
|
|
15
|
+
keep a failing model from being retried forever, and every routing attempt is
|
|
16
|
+
recorded on the session as a `class-router.decision` entry.
|
|
17
|
+
|
|
18
|
+
The extension never breaks a turn: a classification failure, an unresolvable
|
|
19
|
+
spec, or a model without auth all fall back to the session's current model with a
|
|
20
|
+
notification.
|
|
21
|
+
|
|
22
|
+
## Hosts
|
|
23
|
+
|
|
24
|
+
The extension targets the **upstream pi extension API**
|
|
25
|
+
(`@earendil-works/pi-coding-agent`), so one entry point - `src/index.ts` - loads
|
|
26
|
+
on both **pi 0.87.x** and **Oh My Pi (omp) 18.2.x**. omp resolves that upstream
|
|
27
|
+
specifier at runtime, so a single file serves both hosts; only types are imported
|
|
28
|
+
and only with `import type`, so it adds no runtime dependency (Node builtins,
|
|
29
|
+
`fetch`, and the optional `python/laya_worker.py` subprocess are all it uses).
|
|
30
|
+
|
|
31
|
+
`src/host.ts` inspects the live context, and the extension uses whatever the host
|
|
32
|
+
actually provides. On omp it upgrades automatically to:
|
|
33
|
+
|
|
34
|
+
- `ctx.models` for model resolution, including role aliases (`@smol`,
|
|
35
|
+
`@default`, `@slow`) and `ctx.models.current()`.
|
|
36
|
+
- Managed timers (`ctx.setTimeout` / `ctx.clearTimer`), which are unref'd and
|
|
37
|
+
cleared on session shutdown, instead of raw timers.
|
|
38
|
+
- `auto_retry_start` / `auto_retry_end` for failure attribution, with the error
|
|
39
|
+
text classified into the breaker's `rate_limit` / `overloaded` / `auth` /
|
|
40
|
+
`transport` / `other` taxonomy.
|
|
41
|
+
|
|
42
|
+
On upstream pi, where none of that exists, the same behaviour is derived from
|
|
43
|
+
what pi does expose: model resolution falls back to `ctx.modelRegistry` (its
|
|
44
|
+
`find` method, then a scan of `getAvailable()`, then `ctx.model`); timers fall
|
|
45
|
+
back to raw `setTimeout`/`clearTimeout`; and the breaker's failure signal is
|
|
46
|
+
derived from `agent_end` when the trailing assistant message reports
|
|
47
|
+
`stopReason: "error"` (or `"aborted"`, or a non-empty `errorMessage`). That is a
|
|
48
|
+
slightly coarser signal, not a missing feature: it separates a failed run from a
|
|
49
|
+
clean one, but it cannot classify the provider error the way `auto_retry_start`
|
|
50
|
+
does.
|
|
51
|
+
|
|
52
|
+
### Model specs differ per host
|
|
53
|
+
|
|
54
|
+
**On upstream pi there is no `ctx.models` facade, so role aliases cannot be
|
|
55
|
+
resolved.** `routing.modelMapping` and `fallbackChains` values must be concrete
|
|
56
|
+
`provider/id` specs (or bare model ids) that pi's `modelRegistry` can look up.
|
|
57
|
+
The built-in defaults are omp-shaped (`@smol`, `@default`, `@slow`), so the
|
|
58
|
+
defaults - and any config written for omp with role aliases - resolve nothing on
|
|
59
|
+
pi: each affected decision logs and notifies `spec <spec> did not resolve;
|
|
60
|
+
keeping session model` and leaves the session model alone. That is a safe no-op,
|
|
61
|
+
not an error, but it is quiet unless you read the notification.
|
|
62
|
+
|
|
63
|
+
A spec is `provider/id` split on the **first** slash only, because upstream pi's
|
|
64
|
+
model ids themselves contain slashes. Its current models are all fireworks
|
|
65
|
+
routers, so a pi config names them like this:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"backend": "jev",
|
|
70
|
+
"routing": {
|
|
71
|
+
"modelMapping": {
|
|
72
|
+
"trivial": "fireworks/accounts/fireworks/routers/glm-5p3-fast",
|
|
73
|
+
"standard": "fireworks/accounts/fireworks/routers/glm-5p3-fast",
|
|
74
|
+
"hard": "fireworks/accounts/fireworks/routers/deepseek-pro-latest"
|
|
75
|
+
},
|
|
76
|
+
"fallbackChains": {
|
|
77
|
+
"fireworks/accounts/fireworks/routers/deepseek-pro-latest": [
|
|
78
|
+
"fireworks/accounts/fireworks/routers/deepseek-pro-latest",
|
|
79
|
+
"fireworks/accounts/fireworks/routers/glm-5p3-fast"
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Here the provider is the first segment (`fireworks`) and the model id is
|
|
87
|
+
everything after it (`accounts/fireworks/routers/deepseek-pro-latest`).
|
|
88
|
+
|
|
89
|
+
### Differences between hosts
|
|
90
|
+
|
|
91
|
+
| | omp 18.2.x | upstream pi 0.87.x |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| resolvable spec style | `provider/id`, bare id, or role alias (`@slow`) | `provider/id` or bare id only; role aliases do not resolve |
|
|
94
|
+
| failure signal | `auto_retry_start` / `auto_retry_end` | `agent_end`, read off the trailing assistant message |
|
|
95
|
+
| timers | managed (`ctx.setTimeout` / `ctx.clearTimer`) | raw `setTimeout` / `clearTimeout` |
|
|
96
|
+
| automatic continuation | `agent_end` carries `willContinue` | not available, so every `agent_end` settles the run |
|
|
97
|
+
|
|
98
|
+
Detection is structural, not a version check: a context counts as omp when it
|
|
99
|
+
exposes `ctx.models` with callable `resolve`/`current`, or both managed timer
|
|
100
|
+
methods. Anything else - including a partial or unexpected context - takes the pi
|
|
101
|
+
path, and detection never throws.
|
|
102
|
+
|
|
103
|
+
Logging follows the same split: `pi.logger` when the host provides it (omp), and
|
|
104
|
+
otherwise one line to stderr only when `CLASS_ROUTER_DEBUG` is set. Logging never
|
|
105
|
+
throws and never writes to stdout, which is the host's protocol surface.
|
|
106
|
+
|
|
107
|
+
## Install
|
|
108
|
+
|
|
109
|
+
The package declares its own entrypoint, so neither host needs extra wiring:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
"pi": { "extensions": ["./src/index.ts"] }
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Both hosts read that `pi.extensions` manifest, and both load an entrypoint
|
|
116
|
+
directly from the command line:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
# pi
|
|
120
|
+
pi -e /path/to/pi-classifier-router/src/index.ts
|
|
121
|
+
pi install /path/to/pi-classifier-router
|
|
122
|
+
|
|
123
|
+
# omp
|
|
124
|
+
omp -e /path/to/pi-classifier-router/src/index.ts
|
|
125
|
+
omp install /path/to/pi-classifier-router
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`pi install <source>` and `omp install <source>` add the package to the host's
|
|
129
|
+
settings, and both hosts also accept an `extensions` array in their settings
|
|
130
|
+
files: pi reads `~/.pi/agent/settings.json` (user) and `.pi/settings.json`
|
|
131
|
+
(project, after project trust), while omp reads `.omp/settings.json` (project)
|
|
132
|
+
and its user settings file (run `omp config path` for the directory):
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{ "extensions": ["/path/to/pi-classifier-router/src/index.ts"] }
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`devDependencies` carries one type package: `@earendil-works/pi-coding-agent`
|
|
139
|
+
supplies the types the source compiles against, on both hosts. It is not a
|
|
140
|
+
runtime dependency; neither host resolves it at runtime for this extension,
|
|
141
|
+
because the type imports are erased at load. Verifying omp support means running
|
|
142
|
+
the omp binary against the entrypoint, not importing omp's types.
|
|
143
|
+
|
|
144
|
+
## Backends
|
|
145
|
+
|
|
146
|
+
`backend` selects one classifier for the process.
|
|
147
|
+
|
|
148
|
+
### Jev (TypeSafe, HTTP)
|
|
149
|
+
|
|
150
|
+
| key | default | meaning |
|
|
151
|
+
| --- | --- | --- |
|
|
152
|
+
| `endpoint` | `https://api.typesafe.ai/v1/systemone` | System-One evaluation endpoint |
|
|
153
|
+
| `model` | `jev-latest` | model alias sent in the request body |
|
|
154
|
+
| `apiKeyEnvVar` | `TYPESAFE_API_KEY` | environment variable holding the bearer token |
|
|
155
|
+
| `timeoutMs` | `3000` | per-classification budget |
|
|
156
|
+
|
|
157
|
+
The token is read from the named environment variable, sent as
|
|
158
|
+
`Authorization: Bearer <token>`, and never logged or embedded in errors. A
|
|
159
|
+
missing token makes Jev `unavailable` and the session keeps its model.
|
|
160
|
+
|
|
161
|
+
### Laya (System One, local sidecar or remote HTTP)
|
|
162
|
+
|
|
163
|
+
| key | default | meaning |
|
|
164
|
+
| --- | --- | --- |
|
|
165
|
+
| `transport` | `"python"` | where inference runs: `"python"` spawns the local sidecar, `"http"` posts to `endpoint` |
|
|
166
|
+
| `endpoint` | `""` | full URL of a System-One-compatible endpoint; used when `transport` is `"http"` |
|
|
167
|
+
| `apiKeyEnvVar` | `""` | environment variable holding an optional bearer token for `endpoint`; blank means no `Authorization` header |
|
|
168
|
+
| `pythonBin` | `python3` | interpreter used to run the sidecar |
|
|
169
|
+
| `workerScript` | `python/laya_worker.py` | worker path (absolute, or relative to the extension root) |
|
|
170
|
+
| `repo` | `convaiinnovations/laya` | Hugging Face repo bundling the checkpoints |
|
|
171
|
+
| `subfolder` | `null` | `null` = English root, `"multilingual"`, `"typed-decisions"` |
|
|
172
|
+
| `device` | `null` | torch device (`cpu`, `cuda`, `mps`), or `null` for auto-detect |
|
|
173
|
+
| `router` | `true` | serve checkpoints through `laya.Router` (adds multilingual routing) |
|
|
174
|
+
| `preload` | `true` | load weights during warmup instead of on the first classification |
|
|
175
|
+
| `timeoutMs` | `4000` | per-classification budget |
|
|
176
|
+
| `warmupTimeoutMs` | `600000` | budget for loading weights |
|
|
177
|
+
| `hfTokenEnvVar` | `HF_TOKEN` | environment variable holding the Hugging Face token |
|
|
178
|
+
|
|
179
|
+
`transport` gates which fields matter: the python-only keys (`pythonBin`,
|
|
180
|
+
`workerScript`, `repo`, `subfolder`, `device`, `router`, `preload`,
|
|
181
|
+
`warmupTimeoutMs`, `hfTokenEnvVar`) are ignored when `transport` is `"http"`,
|
|
182
|
+
and `endpoint`/`apiKeyEnvVar` are ignored when `transport` is `"python"`. Both
|
|
183
|
+
transports share `timeoutMs`, and both speak the same wire format, so switching
|
|
184
|
+
placements is a config change rather than a code change. `transport: "http"`
|
|
185
|
+
requires a non-blank `endpoint` that parses as an `http:` or `https:` URL; a file
|
|
186
|
+
that points it anywhere else is rejected. Credentials are never required:
|
|
187
|
+
`apiKeyEnvVar` unset or blank means the request carries no `Authorization`
|
|
188
|
+
header, while a non-blank variable name with no value in the environment fails
|
|
189
|
+
the call as `unavailable` without sending a request. When a token is present it
|
|
190
|
+
is sent as `Authorization: Bearer <token>` and is never logged or embedded in
|
|
191
|
+
errors.
|
|
192
|
+
|
|
193
|
+
The Laya request body carries no `model` field: checkpoint selection belongs to
|
|
194
|
+
the host that runs inference, whether that is the local sidecar or a remote
|
|
195
|
+
endpoint.
|
|
196
|
+
|
|
197
|
+
#### Where Laya runs
|
|
198
|
+
|
|
199
|
+
The same typed-question protocol backs all three placements below; `transport`
|
|
200
|
+
picks between the two that are implemented today.
|
|
201
|
+
|
|
202
|
+
**(a) Local Python sidecar** (`transport: "python"`, the default). The faithful
|
|
203
|
+
form: it runs upstream's `laya_worker.py` unchanged. It needs an interpreter with
|
|
204
|
+
the Python requirements installed, plus a one-time multi-GB weight download from
|
|
205
|
+
Hugging Face on first run. After preload it costs roughly 200-460 ms of CPU per
|
|
206
|
+
prompt - the reason its default `timeoutMs` is larger than Jev's. Nothing leaves
|
|
207
|
+
the machine.
|
|
208
|
+
|
|
209
|
+
**(b) Local, Python-free via an ONNX re-host** (planned, not implemented). A JS
|
|
210
|
+
runtime can read ONNX weights, which would drop the interpreter dependency, but
|
|
211
|
+
no Hub ONNX export exists for Laya: the custom decision heads are not part of the
|
|
212
|
+
standard graph, so the export would have to be produced once from the PyTorch
|
|
213
|
+
checkpoint before this path could ship. Until then this placement is unavailable,
|
|
214
|
+
and no config value enables it.
|
|
215
|
+
|
|
216
|
+
**(c) One shared host over HTTP** (`transport: "http"`). Point `endpoint` at a
|
|
217
|
+
containerized `python/laya_worker.py`, a FastAPI wrapper around it, or any
|
|
218
|
+
endpoint speaking the same typed-question protocol, and thin clients route
|
|
219
|
+
without a local install. Upstream measures roughly 33 ms on a GPU, so this is the
|
|
220
|
+
fastest placement and the cheapest to operate from a client machine. The trade is
|
|
221
|
+
that it is a network hop: routing depends on the host being reachable, and it is
|
|
222
|
+
not offline-local.
|
|
223
|
+
|
|
224
|
+
Setup for the local sidecar:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
pip install -r python/requirements.txt
|
|
228
|
+
export HF_TOKEN=... # only needed for a gated repo
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The paragraphs below describe the `transport: "python"` sidecar. With
|
|
232
|
+
`transport: "http"` the worker runs on the remote host instead, so none of this
|
|
233
|
+
local machinery is involved: no spawn, no local weights, and the call is bounded
|
|
234
|
+
by `timeoutMs` composed with the caller's own signal.
|
|
235
|
+
|
|
236
|
+
The first run downloads the checkpoint weights from Hugging Face into the usual
|
|
237
|
+
transformers cache; that download dominates the first startup. The sidecar is
|
|
238
|
+
spawned lazily, keeps the checkpoints resident across requests, and is respawned
|
|
239
|
+
after a crash, so a dead worker degrades to `unavailable` for one prompt instead
|
|
240
|
+
of poisoning the session.
|
|
241
|
+
|
|
242
|
+
Warmup starts at `session_start` and never blocks the session: with
|
|
243
|
+
`preload: true` the worker loads weights in the background (bounded by
|
|
244
|
+
`warmupTimeoutMs`), so a slow load does not collide with the first prompt's
|
|
245
|
+
`timeoutMs`. With `preload: false`, warmup only waits for the worker to report
|
|
246
|
+
ready and the first classification pays the load.
|
|
247
|
+
|
|
248
|
+
Latency is dominated by the device: a GPU (`cuda`/`mps`) handles a typed question
|
|
249
|
+
in tens to low hundreds of milliseconds, while CPU-only inference is roughly an
|
|
250
|
+
order of magnitude slower, which is why the default budget is larger than Jev's.
|
|
251
|
+
Every call is also bounded by an independent outer guard, so a wedged sidecar
|
|
252
|
+
cannot hang a turn even if it ignores its abort.
|
|
253
|
+
|
|
254
|
+
Documented limits: upstream benchmarks evaluate Laya as a fine-tuned System-One
|
|
255
|
+
model. A zero-shot base checkpoint is close to chance on typed decisions, so
|
|
256
|
+
expect near-random routing until the checkpoint you point `repo`/`subfolder` at
|
|
257
|
+
is fine-tuned for the question set you configure. The benefit of Laya is that
|
|
258
|
+
inference runs where you choose - on your own machine, or on a host you control -
|
|
259
|
+
so it is not a drop-in accuracy replacement for a hosted Jev model.
|
|
260
|
+
|
|
261
|
+
## Configuration
|
|
262
|
+
|
|
263
|
+
Config files are JSON and are searched in this order:
|
|
264
|
+
|
|
265
|
+
1. `<project>/.omp/class-router.json`
|
|
266
|
+
2. `~/.omp/class-router.json`
|
|
267
|
+
|
|
268
|
+
These paths are literal and identical on both hosts: the extension discovers its
|
|
269
|
+
own config rather than reading host settings. The first existing file that parses
|
|
270
|
+
and validates wins; a project file therefore overrides a global one. A missing
|
|
271
|
+
file everywhere is not an error: built-in defaults apply. A file that cannot be
|
|
272
|
+
read or parsed is reported and the search continues; a file that parses but
|
|
273
|
+
violates the contract is rejected outright
|
|
274
|
+
(routing falls back to defaults and the errors are notified), because silent
|
|
275
|
+
mis-routing is worse than leaving the model alone.
|
|
276
|
+
|
|
277
|
+
Values are layered over the defaults: top-level sections (`jev`, `laya`,
|
|
278
|
+
`routing`, `circuitBreaker`) merge one level deep, and the `questions`,
|
|
279
|
+
`modelMapping`, and `fallbackChains` maps merge by key. Scalar and array values
|
|
280
|
+
replace wholesale. Config is cached per session and reloaded when the session's
|
|
281
|
+
working directory changes.
|
|
282
|
+
|
|
283
|
+
| key | default |
|
|
284
|
+
| --- | --- |
|
|
285
|
+
| `enabled` | `true` |
|
|
286
|
+
| `backend` | `"jev"` |
|
|
287
|
+
| `dryRun` | `false` |
|
|
288
|
+
| `notify` | `true` |
|
|
289
|
+
| `applyTo` | `"all"` |
|
|
290
|
+
| `routing.questions` | one `task_complexity` choice question (`trivial`, `standard`, `hard`) |
|
|
291
|
+
| `routing.primaryQuestion` | `"task_complexity"` |
|
|
292
|
+
| `routing.modelMapping` | `{ trivial: "@smol", standard: "@default", hard: "@slow" }` (omp-shaped alias specs; see Hosts) |
|
|
293
|
+
| `routing.fallbackChains` | each spec falls back to itself then its neighbour (`@smol` -> `@default`, `@default` -> `@slow`, `@slow` -> `@default`) |
|
|
294
|
+
| `routing.confidenceThreshold` | `0.5` |
|
|
295
|
+
| `routing.defaultCategory` | `"standard"` |
|
|
296
|
+
| `circuitBreaker.failureThreshold` | `3` |
|
|
297
|
+
| `circuitBreaker.cooldownMs` | `120000` |
|
|
298
|
+
| `circuitBreaker.halfOpenMaxTrials` | `1` |
|
|
299
|
+
|
|
300
|
+
`routing.primaryQuestion` must name a `choice` question: score and noul answers
|
|
301
|
+
cannot name a model category. Other questions are answered, reported by
|
|
302
|
+
`/class-router explain`, and stored on the decision, but do not affect routing.
|
|
303
|
+
|
|
304
|
+
`fallbackChains` maps a **spec** (not a category) to the ordered candidates tried
|
|
305
|
+
when its circuit is open; a spec with no entry falls back to itself. If every
|
|
306
|
+
candidate is unavailable the decision reason is `all-circuits-open` and the
|
|
307
|
+
session model is left alone.
|
|
308
|
+
|
|
309
|
+
`applyTo` is `"all"`, `"main"`, or `"subagents"`. Subagent sessions are detected
|
|
310
|
+
from the session file path the host reports (see Limits): the patterns come from
|
|
311
|
+
omp's on-disk layout. Measured on both hosts: omp writes `<tmp>/omp-task-<hex>/<Agent>.jsonl`
|
|
312
|
+
or nests the child under the parent's own `.jsonl` file, whereas upstream pi writes
|
|
313
|
+
`<session-dir>/<timestamp>_<id>.jsonl`, whose parent directory name does not end in
|
|
314
|
+
`.jsonl`. Upstream pi also has no `task` tool, so it has no subagent sessions to
|
|
315
|
+
detect in the first place. A path matching neither pattern is treated as `main`, so
|
|
316
|
+
`applyTo: "main"` cannot silently disable routing everywhere.
|
|
317
|
+
|
|
318
|
+
## Worked example
|
|
319
|
+
|
|
320
|
+
`examples/class-router.json` is a complete, valid config: Jev backend, a second
|
|
321
|
+
noul question, explicit mapping, chains, and breaker settings. It uses role alias
|
|
322
|
+
specs (`@smol`, `@default`, `@slow`), so it is an omp config; on upstream pi
|
|
323
|
+
replace those values with concrete `provider/id` specs (see Hosts above).
|
|
324
|
+
|
|
325
|
+
```json
|
|
326
|
+
{
|
|
327
|
+
"backend": "jev",
|
|
328
|
+
"applyTo": "main",
|
|
329
|
+
"jev": {
|
|
330
|
+
"endpoint": "https://api.typesafe.ai/v1/systemone",
|
|
331
|
+
"model": "jev-latest",
|
|
332
|
+
"apiKeyEnvVar": "TYPESAFE_API_KEY",
|
|
333
|
+
"timeoutMs": 3000
|
|
334
|
+
},
|
|
335
|
+
"routing": {
|
|
336
|
+
"questions": {
|
|
337
|
+
"task_complexity": {
|
|
338
|
+
"type": "choice",
|
|
339
|
+
"instructions": "How demanding is this request for an AI coding agent?",
|
|
340
|
+
"criteria": {
|
|
341
|
+
"trivial": "a single lookup, rename, or one-line answer; no exploration",
|
|
342
|
+
"standard": "a normal multi-step edit or investigation inside one or two files",
|
|
343
|
+
"hard": "large refactor, cross-cutting design, deep debugging, or long multi-file reasoning"
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
"needs_plan": {
|
|
347
|
+
"type": "noul",
|
|
348
|
+
"instructions": "Does this request need an explicit plan before any edit?",
|
|
349
|
+
"criteria": { "true": "the change spans modules, migrations, or public interfaces", "false": "the change is local and reversible" }
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
"primaryQuestion": "task_complexity",
|
|
353
|
+
"modelMapping": { "trivial": "@smol", "standard": "@default", "hard": "@slow" },
|
|
354
|
+
"fallbackChains": {
|
|
355
|
+
"@smol": ["@smol", "@default"],
|
|
356
|
+
"@default": ["@default", "@slow"],
|
|
357
|
+
"@slow": ["@slow", "@default"]
|
|
358
|
+
},
|
|
359
|
+
"confidenceThreshold": 0.5,
|
|
360
|
+
"defaultCategory": "standard"
|
|
361
|
+
},
|
|
362
|
+
"circuitBreaker": { "failureThreshold": 3, "cooldownMs": 120000, "halfOpenMaxTrials": 1 }
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
A Laya deployment over HTTP differs only in the backend section; the routing and
|
|
367
|
+
breaker settings above carry over unchanged. The host behind `endpoint` runs the
|
|
368
|
+
same typed-question protocol, so nothing else in the config moves:
|
|
369
|
+
|
|
370
|
+
```json
|
|
371
|
+
{
|
|
372
|
+
"backend": "laya",
|
|
373
|
+
"laya": {
|
|
374
|
+
"transport": "http",
|
|
375
|
+
"endpoint": "https://gpu-host.internal/v1/systemone",
|
|
376
|
+
"apiKeyEnvVar": "LAYA_ENDPOINT_TOKEN",
|
|
377
|
+
"timeoutMs": 4000
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## Model specs
|
|
383
|
+
|
|
384
|
+
Mapping values and chain entries are resolved through `src/host.ts`. On omp that
|
|
385
|
+
is `ctx.models.resolve(spec)`, which accepts the same forms omp's `--model` flag
|
|
386
|
+
does: `provider/id`, a bare model id, or a configured role alias such as `@slow`.
|
|
387
|
+
Role aliases follow whatever those roles point to in settings, which is why they
|
|
388
|
+
are the recommended form there.
|
|
389
|
+
|
|
390
|
+
Upstream pi has no such facade, so resolution falls back to `ctx.modelRegistry`
|
|
391
|
+
(`find(provider, id)`, then a scan of `getAvailable()`, then the live
|
|
392
|
+
`ctx.model`), which understands concrete `provider/id` specs and bare ids only.
|
|
393
|
+
A role alias therefore resolves nothing on pi - see Hosts above for the concrete
|
|
394
|
+
specs to use there.
|
|
395
|
+
|
|
396
|
+
A spec that does not resolve leaves the session model alone with a warning. A
|
|
397
|
+
spec that resolves to the model already in use is treated as applied without a
|
|
398
|
+
redundant `setModel`. When `setModel` returns `false` (no auth for that model),
|
|
399
|
+
the extension notifies once per session and deliberately does **not** count it as
|
|
400
|
+
a breaker failure, because no request was ever sent. Where the host can answer
|
|
401
|
+
the auth question directly (`ctx.modelRegistry.hasConfiguredAuth`, as upstream pi
|
|
402
|
+
can), the extension asks before calling `setModel` and otherwise relies on its
|
|
403
|
+
return value.
|
|
404
|
+
|
|
405
|
+
## Circuit breaker
|
|
406
|
+
|
|
407
|
+
One breaker is kept per process, with one circuit per spec:
|
|
408
|
+
|
|
409
|
+
- `closed` - requests pass. Consecutive failures reaching `failureThreshold` open
|
|
410
|
+
the circuit.
|
|
411
|
+
- `open` - requests are refused. After `cooldownMs` the circuit becomes
|
|
412
|
+
`half_open` and admits at most `halfOpenMaxTrials` probes.
|
|
413
|
+
- `half_open` - a probe success closes the circuit and clears the failure count; a
|
|
414
|
+
probe failure re-opens it with a fresh cooldown.
|
|
415
|
+
|
|
416
|
+
The breaker is fed by observed outcomes, never by classification errors:
|
|
417
|
+
|
|
418
|
+
On omp:
|
|
419
|
+
|
|
420
|
+
- `auto_retry_start` records a failure for the spec this extension applied,
|
|
421
|
+
classifying the error text as `rate_limit` (429/quota), `overloaded`
|
|
422
|
+
(503/capacity), `auth` (401/403/invalid key), `transport` (abort/ECONNRESET/
|
|
423
|
+
ETIMEDOUT), or `other`. One failure is recorded per run, so a retry storm does
|
|
424
|
+
not open a circuit by repetition alone.
|
|
425
|
+
- `agent_end` settles the run: when `willContinue` is `true` the event is an
|
|
426
|
+
automatic continuation, not a settle, and is ignored. Otherwise the run's
|
|
427
|
+
outcome is attributed: success records a success, a trailing error/aborted
|
|
428
|
+
assistant message (or a failure already seen from `auto_retry_start`) records a
|
|
429
|
+
failure.
|
|
430
|
+
- `auto_retry_end` with `success: true` records a success; a failed retry leaves
|
|
431
|
+
the failure that `auto_retry_start` recorded.
|
|
432
|
+
|
|
433
|
+
On upstream pi there are no retry events, so `agent_end` alone carries the
|
|
434
|
+
signal, and `willContinue` is not available: the run's outcome is read off the
|
|
435
|
+
trailing assistant message, which records a failure when `stopReason` is
|
|
436
|
+
`"error"` or `"aborted"` (or `errorMessage` is non-empty) and otherwise records a
|
|
437
|
+
success. The failure taxonomy above is therefore lossier on pi - the outcome is
|
|
438
|
+
known, the provider error class is not - but the breaker's open/half-open/closed
|
|
439
|
+
behaviour is the same.
|
|
440
|
+
|
|
441
|
+
Registration of the retry events is guarded, so a host whose event union rejects
|
|
442
|
+
them cannot break extension load; the breaker simply relies on `agent_end`.
|
|
443
|
+
|
|
444
|
+
Attribution only happens while the session model still equals the model this
|
|
445
|
+
extension applied. A manual `/model` switch, or another extension's override, is
|
|
446
|
+
never blamed on the router.
|
|
447
|
+
|
|
448
|
+
`/class-router reset` clears every circuit.
|
|
449
|
+
|
|
450
|
+
## Command
|
|
451
|
+
|
|
452
|
+
`/class-router [status|on|off|reset|explain]`
|
|
453
|
+
|
|
454
|
+
- no argument or `status` - enabled/dry-run, backend, config source, breaker
|
|
455
|
+
snapshot (spec, state, failure count), last decision, and how many prompts were
|
|
456
|
+
routed, skipped, or failed this session.
|
|
457
|
+
- `on` / `off` - toggle `enabled` at runtime for this session.
|
|
458
|
+
- `reset` - clear the circuit breaker.
|
|
459
|
+
- `explain` - the full last decision: category, confidence, spec, chain, apply,
|
|
460
|
+
reason, detail, backend, plus every other question answer on its own line.
|
|
461
|
+
- anything else - the valid subcommands. Argument completion offers
|
|
462
|
+
`status|on|off|reset|explain`.
|
|
463
|
+
|
|
464
|
+
All command output goes through `ctx.ui.notify`, one line per notification,
|
|
465
|
+
prefixed `[class-router]`. No notification contains the prompt body, a token, or
|
|
466
|
+
any other secret, and decision entries record only category, confidence, spec,
|
|
467
|
+
apply, reason, backend, and latency.
|
|
468
|
+
|
|
469
|
+
## dryRun
|
|
470
|
+
|
|
471
|
+
With `dryRun: true` the prompt is still classified and the decision is still
|
|
472
|
+
notified and recorded, but `setModel` is never called. It is the way to measure
|
|
473
|
+
what the router would choose against a fixed model before letting it route.
|
|
474
|
+
|
|
475
|
+
## Limits
|
|
476
|
+
|
|
477
|
+
- **Subagent detection is layout-based.** Neither host exposes an agent-kind to
|
|
478
|
+
extensions: omp 18.2.9 has none on `ExtensionContext`, `session_start` carries
|
|
479
|
+
no reason, `getHeader().parentSession` is unpopulated for task spawns, and
|
|
480
|
+
subagents share the parent's process. The extension therefore reads the session
|
|
481
|
+
file path (a subagent nests under `omp-task-<hex>/`, or under a directory named
|
|
482
|
+
for the parent's `.jsonl` file), which depends on omp's on-disk layout rather
|
|
483
|
+
than an API guarantee. Measured on upstream pi: its session files live at
|
|
484
|
+
`<session-dir>/<timestamp>_<id>.jsonl`, which matches neither pattern, and pi has
|
|
485
|
+
no `task` tool, so no subagent sessions arise there; unknown paths fail safe as `main`.
|
|
486
|
+
- **Routing adds one classifier round trip before the turn.** It is bounded by
|
|
487
|
+
`timeoutMs` plus a small outer guard, and any failure degrades to the current
|
|
488
|
+
model, but the latency is real.
|
|
489
|
+
- **Jev spends a token budget per prompt**, and a high-confidence threshold keeps
|
|
490
|
+
more prompts on the session model rather than risking a wrong choice.
|
|
491
|
+
- **Laya accuracy depends on the checkpoint.** Zero-shot base checkpoints are
|
|
492
|
+
near chance on typed decisions; fine-tuning is required for routing-quality
|
|
493
|
+
answers.
|
|
494
|
+
|
|
495
|
+
## Third-party
|
|
496
|
+
|
|
497
|
+
Neither backend is vendored in this package. The MIT license above covers this
|
|
498
|
+
package's own source only; it does not extend to either backend or to any model
|
|
499
|
+
weights, none of which are redistributed here.
|
|
500
|
+
|
|
501
|
+
**Laya** is an optional, separately installed dependency. Its code and all three
|
|
502
|
+
checkpoints (`laya`, `laya-multilingual`, `laya-typed-decisions`) are licensed
|
|
503
|
+
Apache-2.0 by Convai Innovations, and the weights are downloaded from the Hub on
|
|
504
|
+
first use rather than shipped here. Install it yourself with
|
|
505
|
+
`pip install -r python/requirements.txt`; the sidecar imports it as a library and
|
|
506
|
+
contains none of its source. Project: https://github.com/NandhaKishorM/laya
|
|
507
|
+
|
|
508
|
+
**Jev** is a hosted service, not a licensed artifact, so no software license
|
|
509
|
+
applies. There is no code dependency on it: the extension makes HTTP calls to
|
|
510
|
+
TypeSafe's evaluation endpoint using an API key you supply, and your use is
|
|
511
|
+
governed by TypeSafe's own terms. See https://docs.typesafe.ai
|
|
512
|
+
|
|
513
|
+
The question and answer formats this extension speaks are the public wire
|
|
514
|
+
contracts of those two systems, which is what makes a single configuration work
|
|
515
|
+
against either.
|
|
516
|
+
|
|
517
|
+
## License
|
|
518
|
+
|
|
519
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Example omp class-router config. Copy to <project>/.omp/class-router.json (project scope wins) or ~/.omp/class-router.json (global scope). Every key is optional; omitted keys keep the defaults documented in README.md.",
|
|
3
|
+
"enabled": true,
|
|
4
|
+
"backend": "jev",
|
|
5
|
+
"dryRun": false,
|
|
6
|
+
"notify": true,
|
|
7
|
+
"applyTo": "main",
|
|
8
|
+
"jev": {
|
|
9
|
+
"endpoint": "https://api.typesafe.ai/v1/systemone",
|
|
10
|
+
"model": "jev-latest",
|
|
11
|
+
"apiKeyEnvVar": "TYPESAFE_API_KEY",
|
|
12
|
+
"timeoutMs": 3000
|
|
13
|
+
},
|
|
14
|
+
"laya": {
|
|
15
|
+
"transport": "python",
|
|
16
|
+
"endpoint": "",
|
|
17
|
+
"apiKeyEnvVar": "",
|
|
18
|
+
"pythonBin": "python3",
|
|
19
|
+
"workerScript": "python/laya_worker.py",
|
|
20
|
+
"repo": "convaiinnovations/laya",
|
|
21
|
+
"subfolder": null,
|
|
22
|
+
"device": null,
|
|
23
|
+
"router": true,
|
|
24
|
+
"preload": true,
|
|
25
|
+
"timeoutMs": 4000,
|
|
26
|
+
"warmupTimeoutMs": 600000,
|
|
27
|
+
"hfTokenEnvVar": "HF_TOKEN"
|
|
28
|
+
},
|
|
29
|
+
"routing": {
|
|
30
|
+
"questions": {
|
|
31
|
+
"task_complexity": {
|
|
32
|
+
"type": "choice",
|
|
33
|
+
"instructions": "How demanding is this request for an AI coding agent?",
|
|
34
|
+
"criteria": {
|
|
35
|
+
"trivial": "a single lookup, rename, or one-line answer; no exploration",
|
|
36
|
+
"standard": "a normal multi-step edit or investigation inside one or two files",
|
|
37
|
+
"hard": "large refactor, cross-cutting design, deep debugging, or long multi-file reasoning"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"needs_plan": {
|
|
41
|
+
"type": "noul",
|
|
42
|
+
"instructions": "Does this request need an explicit plan before any edit?",
|
|
43
|
+
"criteria": {
|
|
44
|
+
"true": "the change spans modules, migrations, or public interfaces",
|
|
45
|
+
"false": "the change is local and reversible"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"primaryQuestion": "task_complexity",
|
|
50
|
+
"modelMapping": {
|
|
51
|
+
"trivial": "@smol",
|
|
52
|
+
"standard": "@default",
|
|
53
|
+
"hard": "@slow"
|
|
54
|
+
},
|
|
55
|
+
"fallbackChains": {
|
|
56
|
+
"@smol": ["@smol", "@default"],
|
|
57
|
+
"@default": ["@default", "@slow"],
|
|
58
|
+
"@slow": ["@slow", "@default"]
|
|
59
|
+
},
|
|
60
|
+
"confidenceThreshold": 0.5,
|
|
61
|
+
"defaultCategory": "standard"
|
|
62
|
+
},
|
|
63
|
+
"circuitBreaker": {
|
|
64
|
+
"failureThreshold": 3,
|
|
65
|
+
"cooldownMs": 120000,
|
|
66
|
+
"halfOpenMaxTrials": 1
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"backend": "jev",
|
|
3
|
+
"jev": {
|
|
4
|
+
"endpoint": "https://api.typesafe.ai/v1/systemone",
|
|
5
|
+
"model": "jev-latest",
|
|
6
|
+
"apiKeyEnvVar": "TYPESAFE_API_KEY",
|
|
7
|
+
"timeoutMs": 3000
|
|
8
|
+
},
|
|
9
|
+
"routing": {
|
|
10
|
+
"questions": {
|
|
11
|
+
"task_complexity": {
|
|
12
|
+
"type": "choice",
|
|
13
|
+
"instructions": "How demanding is this request for an AI coding agent?",
|
|
14
|
+
"criteria": {
|
|
15
|
+
"trivial": "a single lookup, rename, or one-line answer; no exploration",
|
|
16
|
+
"standard": "a normal multi-step edit or investigation inside one or two files",
|
|
17
|
+
"hard": "large refactor, cross-cutting design, deep debugging, or long multi-file reasoning"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"primaryQuestion": "task_complexity",
|
|
22
|
+
"modelMapping": {
|
|
23
|
+
"trivial": "fireworks/accounts/fireworks/routers/glm-5p3-fast",
|
|
24
|
+
"standard": "fireworks/accounts/fireworks/routers/glm-5p3-fast",
|
|
25
|
+
"hard": "fireworks/accounts/fireworks/routers/deepseek-pro-latest"
|
|
26
|
+
},
|
|
27
|
+
"fallbackChains": {
|
|
28
|
+
"fireworks/accounts/fireworks/routers/glm-5p3-fast": [
|
|
29
|
+
"fireworks/accounts/fireworks/routers/glm-5p3-fast",
|
|
30
|
+
"fireworks/accounts/fireworks/routers/deepseek-flash-latest"
|
|
31
|
+
],
|
|
32
|
+
"fireworks/accounts/fireworks/routers/deepseek-pro-latest": [
|
|
33
|
+
"fireworks/accounts/fireworks/routers/deepseek-pro-latest",
|
|
34
|
+
"fireworks/accounts/fireworks/routers/glm-5p3-fast"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
"confidenceThreshold": 0.5,
|
|
38
|
+
"defaultCategory": "standard"
|
|
39
|
+
},
|
|
40
|
+
"circuitBreaker": {
|
|
41
|
+
"failureThreshold": 3,
|
|
42
|
+
"cooldownMs": 120000,
|
|
43
|
+
"halfOpenMaxTrials": 1
|
|
44
|
+
},
|
|
45
|
+
"notify": true,
|
|
46
|
+
"applyTo": "all"
|
|
47
|
+
}
|