@ejstembler/pi-classifier-router 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -260,17 +260,47 @@ so it is not a drop-in accuracy replacement for a hosted Jev model.
260
260
 
261
261
  ## Configuration
262
262
 
263
- Config files are JSON and are searched in this order:
263
+ Config files are searched in this order, project before global and JSON before
264
+ YAML:
264
265
 
265
266
  1. `<project>/.omp/class-router.json`
266
- 2. `~/.omp/class-router.json`
267
+ 2. `<project>/.omp/class-router.yml`
268
+ 3. `<project>/.omp/class-router.yaml`
269
+ 4. `~/.omp/class-router.json`
270
+ 5. `~/.omp/class-router.yml`
271
+ 6. `~/.omp/class-router.yaml`
272
+
273
+ Both formats describe exactly the same configuration, so use whichever you
274
+ prefer:
275
+
276
+ ```yaml
277
+ # ~/.omp/class-router.yml
278
+ backend: jev
279
+ routing:
280
+ confidenceThreshold: 0.7
281
+ modelMapping:
282
+ hard: @slow
283
+ ```
284
+
285
+ ```json
286
+ { "backend": "jev", "routing": { "confidenceThreshold": 0.7, "modelMapping": { "hard": "@slow" } } }
287
+ ```
288
+
289
+ YAML is parsed by the host (`Bun.YAML`), which is free because pi and omp are both
290
+ Bun. A YAML file on a host without a parser is reported as an error rather than
291
+ silently skipped, so a `.yml` that is being ignored is always visible.
267
292
 
268
293
  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
294
+ own config and never reads host settings. In particular this file is **not**
295
+ omp's `~/.omp/agent/config.yml` (that is omp's own settings: `modelRoles`, theme,
296
+ advisor) and not pi's `~/.pi/agent/settings.json`. They sit alongside each other
297
+ and are unrelated.
298
+
299
+ The first existing file that parses and validates wins; a project file therefore
300
+ overrides a global one, and a `.json` overrides a `.yml` in the same scope. A
301
+ missing file everywhere is not an error: built-in defaults apply. A file that
302
+ cannot be read or parsed is reported and the search continues; a file that parses
303
+ but violates the contract is rejected outright
274
304
  (routing falls back to defaults and the errors are notified), because silent
275
305
  mis-routing is worse than leaving the model alone.
276
306
 
@@ -0,0 +1,44 @@
1
+ # YAML form of the configuration. Identical in meaning to class-router.json;
2
+ # use whichever you prefer. Copy to ~/.omp/class-router.yml (global) or
3
+ # <project>/.omp/class-router.yml (project-local, which wins).
4
+ #
5
+ # This is the extension's own config file, unrelated to omp's
6
+ # ~/.omp/agent/config.yml or pi's ~/.pi/agent/settings.json.
7
+
8
+ backend: jev
9
+
10
+ jev:
11
+ endpoint: https://api.typesafe.ai/v1/systemone
12
+ model: jev-latest
13
+ apiKeyEnvVar: TYPESAFE_API_KEY
14
+ timeoutMs: 3000
15
+
16
+ routing:
17
+ # Must name a choice question: score and noul answers cannot name a category.
18
+ primaryQuestion: task_complexity
19
+ # Alias specs are omp-only; on pi use concrete provider/id specs, e.g.
20
+ # fireworks/accounts/fireworks/routers/deepseek-pro-latest
21
+ modelMapping:
22
+ trivial: "@smol"
23
+ standard: "@default"
24
+ hard: "@slow"
25
+ fallbackChains:
26
+ "@smol":
27
+ - "@smol"
28
+ - "@default"
29
+ "@default":
30
+ - "@default"
31
+ - "@slow"
32
+ "@slow":
33
+ - "@slow"
34
+ - "@default"
35
+ confidenceThreshold: 0.5
36
+ defaultCategory: standard
37
+
38
+ circuitBreaker:
39
+ failureThreshold: 3
40
+ cooldownMs: 120000
41
+ halfOpenMaxTrials: 1
42
+
43
+ notify: true
44
+ applyTo: all
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ejstembler/pi-classifier-router",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/config.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Config discovery, defaults, merge, and validation.
3
3
  *
4
- * Precedence is project over global; the first existing, parseable, valid file
5
- * wins. A file that fails to read or parse is reported and skipped, but a file
6
- * that parses yet violates the contract is rejected outright: silently
7
- * mis-routing is worse than falling back to the session's own model.
4
+ * Precedence is project over global; within a scope JSON is preferred over YAML.
5
+ * The first existing, parseable, valid file wins. A file that fails to read or
6
+ * parse is reported and skipped, but a file that parses yet violates the
7
+ * contract is rejected outright: silently mis-routing is worse than falling back
8
+ * to the session's own model.
8
9
  */
9
10
 
10
11
  import * as fs from "node:fs";
@@ -13,9 +14,12 @@ import * as path from "node:path";
13
14
 
14
15
  import type { Question, RouterConfig, RouterConfigInput } from "./types.ts";
15
16
 
17
+ export type ConfigFormat = "json" | "yaml";
18
+
16
19
  export interface ConfigSource {
17
20
  path: string;
18
21
  scope: "project" | "global";
22
+ format: ConfigFormat;
19
23
  }
20
24
 
21
25
  export interface ConfigLoadResult {
@@ -24,12 +28,45 @@ export interface ConfigLoadResult {
24
28
  errors: string[];
25
29
  }
26
30
 
27
- /** Project config first, global config second. */
31
+ /** Parses one YAML document. Supplied by the host, since only Bun has one. */
32
+ export type YamlParser = (text: string) => unknown;
33
+
34
+ /**
35
+ * Bun ships a YAML parser; Node does not. Absent a parser, YAML candidates are
36
+ * reported as errors rather than silently ignored, so a `.yml` config that is
37
+ * being skipped is visible.
38
+ */
39
+ export function defaultYamlParser(): YamlParser | null {
40
+ const scope: unknown = globalThis;
41
+ if (typeof scope !== "object" || scope === null || !("Bun" in scope)) return null;
42
+ const bun = scope.Bun;
43
+ if (typeof bun !== "object" || bun === null || !("YAML" in bun)) return null;
44
+ const yaml = bun.YAML;
45
+ if (typeof yaml !== "object" || yaml === null || !("parse" in yaml)) return null;
46
+ const parse = yaml.parse;
47
+ if (typeof parse !== "function") return null;
48
+ // Verified callable just above; only its signature is unknowable statically.
49
+ const call = parse as (value: string) => unknown;
50
+ return (text: string) => call(text);
51
+ }
52
+
53
+ /** Candidate files per scope, in precedence order. */
54
+ const CANDIDATES: ReadonlyArray<{ name: string; format: ConfigFormat }> = [
55
+ { name: "class-router.json", format: "json" },
56
+ { name: "class-router.yml", format: "yaml" },
57
+ { name: "class-router.yaml", format: "yaml" },
58
+ ];
59
+
60
+ /** Every candidate, project first, JSON before YAML, then global. */
28
61
  export function configPaths(cwd: string, home: string): ConfigSource[] {
29
- return [
30
- { path: path.join(cwd, ".omp", "class-router.json"), scope: "project" },
31
- { path: path.join(home, ".omp", "class-router.json"), scope: "global" },
32
- ];
62
+ const sources: ConfigSource[] = [];
63
+ for (const scope of ["project", "global"] as const) {
64
+ const base = scope === "project" ? cwd : home;
65
+ for (const candidate of CANDIDATES) {
66
+ sources.push({ path: path.join(base, ".omp", candidate.name), scope, format: candidate.format });
67
+ }
68
+ }
69
+ return sources;
33
70
  }
34
71
 
35
72
  /** A complete, valid configuration. Fresh objects on every call. */
@@ -242,6 +279,7 @@ export function loadConfig(
242
279
  cwd: string,
243
280
  home: string = os.homedir(),
244
281
  env: Record<string, string | undefined> = process.env,
282
+ yaml: YamlParser | null = defaultYamlParser(),
245
283
  ): ConfigLoadResult {
246
284
  void env;
247
285
  const errors: string[] = [];
@@ -259,16 +297,33 @@ export function loadConfig(
259
297
  }
260
298
 
261
299
  let parsed: unknown;
262
- try {
263
- parsed = JSON.parse(raw);
264
- } catch (error) {
265
- const message = error instanceof Error ? error.message : String(error);
266
- errors.push(`${source.scope} config ${source.path}: invalid JSON: ${message}`);
267
- continue;
300
+ if (source.format === "yaml") {
301
+ if (yaml === null) {
302
+ errors.push(
303
+ `${source.scope} config ${source.path}: YAML config needs a YAML parser and this host has none ` +
304
+ `(only Bun provides one); use class-router.json instead`,
305
+ );
306
+ continue;
307
+ }
308
+ try {
309
+ parsed = yaml(raw);
310
+ } catch (error) {
311
+ const message = error instanceof Error ? error.message : String(error);
312
+ errors.push(`${source.scope} config ${source.path}: invalid YAML: ${message}`);
313
+ continue;
314
+ }
315
+ } else {
316
+ try {
317
+ parsed = JSON.parse(raw);
318
+ } catch (error) {
319
+ const message = error instanceof Error ? error.message : String(error);
320
+ errors.push(`${source.scope} config ${source.path}: invalid JSON: ${message}`);
321
+ continue;
322
+ }
268
323
  }
269
324
 
270
325
  if (!isPlainObject(parsed)) {
271
- errors.push(`${source.scope} config ${source.path}: expected a JSON object at the top level`);
326
+ errors.push(`${source.scope} config ${source.path}: expected a ${source.format === "yaml" ? "YAML" : "JSON"} object at the top level`);
272
327
  continue;
273
328
  }
274
329