@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 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
+ [![pipeline](https://gitlab.com/ejstembler/pi-classifier-router/badges/main/pipeline.svg)](https://gitlab.com/ejstembler/pi-classifier-router/-/pipelines)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
+ [![node](https://img.shields.io/badge/node-%3E%3D24-brightgreen.svg)](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
+ }