open-classify 0.5.0 → 0.7.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.
Files changed (36) hide show
  1. package/README.md +96 -88
  2. package/bin/open-classify.mjs +201 -0
  3. package/dist/src/aggregator.d.ts +7 -23
  4. package/dist/src/aggregator.js +108 -186
  5. package/dist/src/classifiers/{routing → model_tier}/manifest.json +2 -2
  6. package/dist/src/classifiers/{routing → model_tier}/prompt.md +1 -1
  7. package/dist/src/classifiers/preflight/manifest.json +9 -8
  8. package/dist/src/classifiers/preflight/prompt.md +12 -6
  9. package/dist/src/classifiers/prompt_injection/manifest.json +2 -3
  10. package/dist/src/classifiers.d.ts +12 -5
  11. package/dist/src/classifiers.js +32 -16
  12. package/dist/src/classify.d.ts +5 -3
  13. package/dist/src/classify.js +28 -8
  14. package/dist/src/config.d.ts +1 -3
  15. package/dist/src/config.js +1 -28
  16. package/dist/src/index.js +2 -3
  17. package/dist/src/manifest.d.ts +25 -70
  18. package/dist/src/ollama.d.ts +5 -6
  19. package/dist/src/ollama.js +17 -11
  20. package/dist/src/pipeline.d.ts +3 -2
  21. package/dist/src/pipeline.js +32 -94
  22. package/dist/src/stock-validation.js +8 -4
  23. package/docs/adding-a-classifier.md +50 -27
  24. package/docs/manifests.md +6 -6
  25. package/docs/resolver.md +20 -44
  26. package/docs/signals.md +18 -8
  27. package/open-classify.config.example.json +2 -7
  28. package/package.json +6 -1
  29. /package/{dist/src/classifiers → templates}/context_shift/manifest.json +0 -0
  30. /package/{dist/src/classifiers → templates}/context_shift/prompt.md +0 -0
  31. /package/{dist/src/classifiers → templates}/conversation_digest/manifest.json +0 -0
  32. /package/{dist/src/classifiers → templates}/conversation_digest/prompt.md +0 -0
  33. /package/{dist/src/classifiers → templates}/memory_retrieval_queries/manifest.json +0 -0
  34. /package/{dist/src/classifiers → templates}/memory_retrieval_queries/prompt.md +0 -0
  35. /package/{dist/src/classifiers → templates}/tools/manifest.json +0 -0
  36. /package/{dist/src/classifiers → templates}/tools/prompt.md +0 -0
@@ -1,9 +1,11 @@
1
- // High-level facade for the pipeline. Builds the runner and catalog once,
2
- // then returns two functions — classify() for the user-input/routing pass
3
- // and inspect() for the assistant-output lean pass. Backend-agnostic: pass a
4
- // custom `runClassifier` to bypass the bundled Ollama runner entirely.
1
+ // High-level facade for the pipeline. Builds the runner, registry, and
2
+ // catalog once, then returns two functions — classify() for the
3
+ // user-input/routing pass and inspect() for the assistant-output lean pass.
4
+ // Backend-agnostic: pass a custom `runClassifier` to bypass the bundled
5
+ // Ollama runner entirely.
5
6
  import { loadCatalog } from "./catalog.js";
6
- import { classifierModelsFromConfig, loadOpenClassifyConfig, } from "./config.js";
7
+ import { buildClassifierRegistry, ClassifierManifestError, } from "./classifiers.js";
8
+ import { classifierModelsFromConfig, loadOpenClassifyConfig, OpenClassifyConfigError, } from "./config.js";
7
9
  import { assertOllamaResources, createOllamaClassifierRunner, OLLAMA_DEFAULT_CATALOG_PATH, } from "./ollama.js";
8
10
  import { classifyOpenClassifyInput, inspectOpenClassifyInput, } from "./pipeline.js";
9
11
  export function createClassifier(options = {}) {
@@ -12,6 +14,20 @@ export function createClassifier(options = {}) {
12
14
  optional: options.configPath === undefined &&
13
15
  process.env.OPEN_CLASSIFY_CONFIG === undefined,
14
16
  });
17
+ const registryBundle = buildClassifierRegistry({
18
+ extraDirs: options.extraClassifierDirs,
19
+ });
20
+ // Cross-check `runner.models` keys against the loaded registry so a typo
21
+ // or stale reference fails fast at construction time instead of being
22
+ // silently ignored by the runner.
23
+ if (fileConfig?.runner?.models !== undefined) {
24
+ const known = new Set(registryBundle.names);
25
+ for (const name of Object.keys(fileConfig.runner.models)) {
26
+ if (!known.has(name)) {
27
+ throw new OpenClassifyConfigError(`runner.models.${name} is not a loaded classifier (loaded: ${registryBundle.names.join(", ")})`);
28
+ }
29
+ }
30
+ }
15
31
  // When we own the runner, hoist the resource check to the wrapper so a
16
32
  // failure surfaces as a top-level rejection — the per-classifier fallback
17
33
  // path would otherwise mask it as five "classifier failed" entries.
@@ -19,6 +35,7 @@ export function createClassifier(options = {}) {
19
35
  const needsResourceCheck = ownsRunner && !options.skipResourceCheck;
20
36
  const runClassifier = options.runClassifier ??
21
37
  createOllamaClassifierRunner({
38
+ modulesByName: registryBundle.modulesByName,
22
39
  host: fileConfig?.runner?.host,
23
40
  defaultModel: fileConfig?.runner?.defaultModel,
24
41
  models: classifierModelsFromConfig(fileConfig),
@@ -28,7 +45,6 @@ export function createClassifier(options = {}) {
28
45
  });
29
46
  const catalog = options.catalog ??
30
47
  loadCatalog(options.catalogPath ?? fileConfig?.catalog ?? OLLAMA_DEFAULT_CATALOG_PATH);
31
- const aggregator = options.aggregator ?? fileConfig?.aggregator;
32
48
  let resourceCheck;
33
49
  const ensureResources = async () => {
34
50
  if (!needsResourceCheck)
@@ -44,10 +60,10 @@ export function createClassifier(options = {}) {
44
60
  return classifyOpenClassifyInput(input, {
45
61
  runClassifier,
46
62
  catalog,
63
+ registry: registryBundle.registry,
47
64
  classifierTimeoutMs: options.classifierTimeoutMs,
48
65
  classifierRetryCount: options.classifierRetryCount,
49
66
  maxConcurrency: options.maxConcurrency,
50
- aggregator,
51
67
  signal: callOptions?.signal,
52
68
  });
53
69
  };
@@ -55,11 +71,15 @@ export function createClassifier(options = {}) {
55
71
  await ensureResources();
56
72
  return inspectOpenClassifyInput(input, {
57
73
  runClassifier,
74
+ registry: registryBundle.registry,
58
75
  classifierTimeoutMs: options.classifierTimeoutMs,
59
76
  classifierRetryCount: options.classifierRetryCount,
60
77
  maxConcurrency: options.maxConcurrency,
61
78
  signal: callOptions?.signal,
62
79
  });
63
80
  };
64
- return { classify, inspect };
81
+ return { classify, inspect, registry: registryBundle };
65
82
  }
83
+ // Re-export so callers can `import { ClassifierManifestError } from "open-classify"`
84
+ // and catch directory/name collision errors from createClassifier().
85
+ export { ClassifierManifestError };
@@ -1,10 +1,8 @@
1
- import { type ClassifierName } from "./classifiers.js";
2
- import { type AggregatorConfig } from "./manifest.js";
1
+ import type { ClassifierName } from "./classifiers.js";
3
2
  export declare const DEFAULT_OPEN_CLASSIFY_CONFIG_PATH = "open-classify.config.json";
4
3
  export interface OpenClassifyConfig {
5
4
  readonly runner?: OllamaRunnerConfig;
6
5
  readonly catalog?: string;
7
- readonly aggregator?: AggregatorConfig;
8
6
  }
9
7
  export interface OllamaRunnerConfig {
10
8
  readonly provider: "ollama";
@@ -1,5 +1,4 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { CLASSIFIER_NAMES } from "./classifiers.js";
3
2
  import { isRecord } from "./validation.js";
4
3
  export const DEFAULT_OPEN_CLASSIFY_CONFIG_PATH = "open-classify.config.json";
5
4
  export class OpenClassifyConfigError extends Error {
@@ -30,25 +29,10 @@ export function validateOpenClassifyConfig(value, path = "open-classify config")
30
29
  if (!isRecord(value)) {
31
30
  throwConfig(path, "config must be a JSON object");
32
31
  }
33
- ensureAllowedKeys(value, ["runner", "catalog", "aggregator"], path, "<root>");
32
+ ensureAllowedKeys(value, ["runner", "catalog"], path, "<root>");
34
33
  return {
35
34
  ...(value.runner === undefined ? {} : { runner: validateRunner(value.runner, path) }),
36
35
  ...(value.catalog === undefined ? {} : { catalog: requireString(value.catalog, path, "catalog") }),
37
- ...(value.aggregator === undefined ? {} : { aggregator: validateAggregator(value.aggregator, path) }),
38
- };
39
- }
40
- function validateAggregator(value, path) {
41
- if (!isRecord(value)) {
42
- throwConfig(path, "aggregator must be an object");
43
- }
44
- ensureAllowedKeys(value, ["certaintyThreshold", "confidenceThreshold"], path, "aggregator");
45
- return {
46
- ...(value.certaintyThreshold === undefined
47
- ? {}
48
- : { certaintyThreshold: requireUnitFloat(value.certaintyThreshold, path, "aggregator.certaintyThreshold") }),
49
- ...(value.confidenceThreshold === undefined
50
- ? {}
51
- : { confidenceThreshold: requireUnitFloat(value.confidenceThreshold, path, "aggregator.confidenceThreshold") }),
52
36
  };
53
37
  }
54
38
  function validateRunner(value, path) {
@@ -96,12 +80,8 @@ function validateModels(value, path) {
96
80
  if (!isRecord(value)) {
97
81
  throwConfig(path, "runner.models must be an object");
98
82
  }
99
- const allowed = new Set(CLASSIFIER_NAMES);
100
83
  const out = {};
101
84
  for (const [name, model] of Object.entries(value)) {
102
- if (!allowed.has(name)) {
103
- throwConfig(path, `runner.models.${name} is not a known classifier`);
104
- }
105
85
  out[name] = requireString(model, path, `runner.models.${name}`);
106
86
  }
107
87
  return out;
@@ -118,13 +98,6 @@ function requireNumber(value, path, field) {
118
98
  }
119
99
  return value;
120
100
  }
121
- function requireUnitFloat(value, path, field) {
122
- const number = requireNumber(value, path, field);
123
- if (number < 0 || number > 1) {
124
- throwConfig(path, `${field} must be a finite number between 0 and 1 inclusive`);
125
- }
126
- return number;
127
- }
128
101
  function ensureAllowedKeys(value, allowedKeys, path, field) {
129
102
  const allowed = new Set(allowedKeys);
130
103
  for (const key of Object.keys(value)) {
package/dist/src/index.js CHANGED
@@ -1,8 +1,7 @@
1
1
  // Public barrel for the Open Classify package. Everything an external caller
2
2
  // would need — input types, enums, the registry, the pipeline, the Ollama
3
- // runner, the catalog loader, the aggregator's certainty threshold is
4
- // re-exported here. The build emits a single `index.js` that downstream
5
- // consumers can import from `open-classify`.
3
+ // runner, the catalog loader is re-exported here. The build emits a single
4
+ // `index.js` that downstream consumers can import from `open-classify`.
6
5
  export * from "./aggregator.js";
7
6
  export * from "./catalog.js";
8
7
  export * from "./classifiers.js";
@@ -1,9 +1,9 @@
1
- import type { AckReplySignal, ClassifierAuditOutput, ClassifierOutput, FinalReplySignal, PromptInjectionSignal, RoutingSignal, RuntimeClassifierManifest, ToolsSignal } from "./stock.js";
2
- import type { ClassifierInput, ClassifierRunStatus } from "./types.js";
3
- import type { DownstreamModelTier, ModelSpecialization } from "./enums.js";
1
+ import type { RuntimeClassifierManifest } from "./stock.js";
2
+ import type { ClassifierInput } from "./types.js";
3
+ import type { DownstreamModelTier, ModelSpecialization, PromptInjectionRiskLevel } from "./enums.js";
4
4
  export type ClassifierName = string;
5
- export type ClassifierResults = Record<ClassifierName, ClassifierOutput>;
6
- export type RunClassifier = (name: ClassifierName, input: ClassifierInput, signal: AbortSignal) => Promise<ClassifierOutput>;
5
+ export type ClassifierResults = Record<ClassifierName, import("./stock.js").ClassifierOutput>;
6
+ export type RunClassifier = (name: ClassifierName, input: ClassifierInput, signal: AbortSignal) => Promise<import("./stock.js").ClassifierOutput>;
7
7
  export interface CatalogEntry {
8
8
  readonly id: string;
9
9
  readonly specializations: ReadonlyArray<ModelSpecialization>;
@@ -18,78 +18,33 @@ export interface Catalog {
18
18
  readonly models: ReadonlyArray<CatalogEntry>;
19
19
  readonly default: string;
20
20
  }
21
- export interface ModelRecommendationResolution {
22
- readonly constraints_used: Partial<{
23
- model_specialization: ModelSpecialization;
24
- model_tier: DownstreamModelTier;
25
- }>;
26
- readonly constraints_dropped: ReadonlyArray<{
27
- readonly axis: "model_specialization" | "model_tier";
28
- readonly reason: "low_confidence" | "no_match_relaxed" | "default_fallback";
29
- }>;
30
- readonly confidences: Partial<{
31
- routing: number;
32
- }>;
33
- readonly fell_back_to_default: boolean;
34
- }
35
- export interface ModelRecommendation {
36
- readonly id: string;
37
- readonly params_in_billions: number | null;
38
- readonly context_window: number;
39
- readonly input_tokens_cpm?: number;
40
- readonly cached_tokens_cpm?: number;
41
- readonly output_tokens_cpm?: number;
42
- readonly resolution: ModelRecommendationResolution;
43
- }
44
- export interface Envelope {
45
- readonly final_reply?: FinalReplySignal;
46
- readonly ack_reply?: AckReplySignal;
47
- readonly routing?: RoutingSignal;
48
- readonly tools?: ToolsSignal;
49
- readonly prompt_injection?: PromptInjectionSignal;
50
- readonly classifier_outputs: ReadonlyArray<ClassifierAuditOutput>;
51
- readonly model_recommendation: ModelRecommendation;
52
- }
53
21
  export type ClassifierPublicOutputs = Record<string, Record<string, unknown>>;
54
- export interface DownstreamTargetMessage {
55
- readonly role: "user";
22
+ export type PipelineAction = "route" | "block" | "reply";
23
+ export type BlockReason = "prompt_injection" | "classification_error";
24
+ export interface ReplySignal {
56
25
  readonly text: string;
57
- readonly hash: string;
58
26
  }
59
- export interface DownstreamPayload {
60
- readonly model_id: string;
61
- readonly target_message: DownstreamTargetMessage;
62
- readonly tools: ToolsSignal;
63
- }
64
- export type ClassifierEntry = ClassifierOutput & {
65
- readonly status: ClassifierRunStatus;
66
- readonly version: string;
67
- };
68
- export interface CertaintySummary {
69
- readonly min: number;
70
- readonly avg: number;
71
- }
72
- export interface PipelineMeta {
73
- readonly classifiers: Record<string, ClassifierEntry>;
74
- readonly certainty: CertaintySummary;
75
- }
76
- export interface PipelineAudit extends Envelope {
77
- readonly meta: PipelineMeta;
78
- }
79
- export interface InspectResult {
27
+ export interface PipelineResult {
28
+ readonly action: PipelineAction;
29
+ readonly block_reason?: BlockReason;
80
30
  readonly target_message_hash: string;
31
+ readonly model_id: string | null;
32
+ readonly tools: ReadonlyArray<string>;
33
+ readonly reply: ReplySignal | null;
34
+ readonly prompt_injection: {
35
+ readonly risk_level: PromptInjectionRiskLevel;
36
+ } | null;
37
+ readonly avg_certainty: number;
38
+ readonly min_certainty: number;
39
+ readonly failed_classifiers: ReadonlyArray<string>;
81
40
  readonly classifier_outputs: ClassifierPublicOutputs;
82
41
  }
83
- export interface PipelineResult {
84
- readonly action: "route";
42
+ export interface InspectResult {
85
43
  readonly target_message_hash: string;
86
- readonly downstream: DownstreamPayload;
44
+ readonly message: {
45
+ readonly role: "assistant";
46
+ readonly text: string;
47
+ };
87
48
  readonly classifier_outputs: ClassifierPublicOutputs;
88
- readonly audit: PipelineAudit;
89
- }
90
- export interface AggregatorConfig {
91
- readonly certaintyThreshold?: number;
92
- /** @deprecated Use certaintyThreshold. */
93
- readonly confidenceThreshold?: number;
94
49
  }
95
50
  export type ClassifierRegistry = ReadonlyArray<RuntimeClassifierManifest>;
@@ -1,13 +1,11 @@
1
- import { type ClassifierName, type RunClassifier } from "./classifiers.js";
1
+ import { type ClassifierModuleMap, type ClassifierName, type RunClassifier } from "./classifiers.js";
2
2
  export declare const OLLAMA_DEFAULT_HOST = "http://localhost:11434";
3
3
  export declare const OLLAMA_BASE_MODEL = "gemma4:e4b-it-q4_K_M";
4
4
  export declare const OLLAMA_BASE_MODEL_NATIVE_CONTEXT_LENGTH = 131072;
5
- export declare const OLLAMA_REQUIRED_PARALLELISM: number;
6
5
  export declare const OLLAMA_DEFAULT_CATALOG_PATH = "downstream-models.json";
7
6
  export declare const OLLAMA_CONTEXT_LENGTH = 4096;
8
7
  export declare const OLLAMA_MIN_TOTAL_MEMORY_BYTES: number;
9
8
  export declare const OLLAMA_MIN_AVAILABLE_MEMORY_BYTES: number;
10
- export declare const OLLAMA_CLASSIFIER_MODELS: Record<ClassifierName, string | null>;
11
9
  export interface OllamaOptions {
12
10
  temperature?: number;
13
11
  top_p?: number;
@@ -15,14 +13,15 @@ export interface OllamaOptions {
15
13
  num_ctx?: number;
16
14
  }
17
15
  export interface OllamaClassifierRunnerConfig {
16
+ modulesByName: ClassifierModuleMap;
17
+ minTotalMemoryBytes?: number;
18
+ minAvailableMemoryBytes?: number;
18
19
  host?: string;
19
20
  defaultModel?: string;
20
21
  models?: Partial<Record<ClassifierName, string | null>>;
21
22
  options?: OllamaOptions;
22
23
  fetch?: typeof fetch;
23
24
  skipResourceCheck?: boolean;
24
- minAvailableMemoryBytes?: number;
25
- minTotalMemoryBytes?: number;
26
25
  }
27
26
  export declare class OllamaClassifierError extends Error {
28
27
  readonly classifier: ClassifierName;
@@ -36,7 +35,7 @@ export declare class OllamaResourceError extends Error {
36
35
  readonly minAvailableMemoryBytes: number;
37
36
  constructor(totalMemoryBytes: number, availableMemoryBytes: number, minTotalMemoryBytes: number, minAvailableMemoryBytes: number);
38
37
  }
39
- export declare function createOllamaClassifierRunner(config?: OllamaClassifierRunnerConfig): RunClassifier;
38
+ export declare function createOllamaClassifierRunner(config: OllamaClassifierRunnerConfig): RunClassifier;
40
39
  export declare function assertOllamaResources(options?: {
41
40
  minTotalMemoryBytes?: number;
42
41
  minAvailableMemoryBytes?: number;
@@ -10,12 +10,11 @@
10
10
  // `classifyOpenClassifyInput` — you don't have to use this module at all.
11
11
  import { execFile } from "node:child_process";
12
12
  import { promisify } from "node:util";
13
- import { CLASSIFIER_NAMES, MODULES_BY_NAME, validateClassifierOutput, } from "./classifiers.js";
13
+ import { validateClassifierOutput, } from "./classifiers.js";
14
14
  import { ClassifierValidationError, isRecord, } from "./validation.js";
15
15
  export const OLLAMA_DEFAULT_HOST = "http://localhost:11434";
16
16
  export const OLLAMA_BASE_MODEL = "gemma4:e4b-it-q4_K_M";
17
17
  export const OLLAMA_BASE_MODEL_NATIVE_CONTEXT_LENGTH = 131_072;
18
- export const OLLAMA_REQUIRED_PARALLELISM = CLASSIFIER_NAMES.length;
19
18
  export const OLLAMA_DEFAULT_CATALOG_PATH = "downstream-models.json";
20
19
  /*
21
20
  * Gemma 4 E4B's native context is 131,072 tokens (128K). The reference local
@@ -28,7 +27,6 @@ export const OLLAMA_MIN_TOTAL_MEMORY_BYTES = 16 * 1024 * 1024 * 1024;
28
27
  export const OLLAMA_MIN_AVAILABLE_MEMORY_BYTES = 16 * 1024 * 1024 * 1024;
29
28
  const ESTIMATED_CHARS_PER_TOKEN = 3;
30
29
  const execFileAsync = promisify(execFile);
31
- export const OLLAMA_CLASSIFIER_MODELS = Object.fromEntries(CLASSIFIER_NAMES.map((name) => [name, null]));
32
30
  export class OllamaClassifierError extends Error {
33
31
  classifier;
34
32
  model;
@@ -45,7 +43,7 @@ export class OllamaResourceError extends Error {
45
43
  minTotalMemoryBytes;
46
44
  minAvailableMemoryBytes;
47
45
  constructor(totalMemoryBytes, availableMemoryBytes, minTotalMemoryBytes, minAvailableMemoryBytes) {
48
- super(`Ollama resource check failed: ${formatBytes(totalMemoryBytes)} total and ${formatBytes(availableMemoryBytes)} available; ${formatBytes(minTotalMemoryBytes)} total and ${formatBytes(minAvailableMemoryBytes)} available required for ${OLLAMA_REQUIRED_PARALLELISM} parallel classifiers`);
46
+ super(`Ollama resource check failed: ${formatBytes(totalMemoryBytes)} total and ${formatBytes(availableMemoryBytes)} available; ${formatBytes(minTotalMemoryBytes)} total and ${formatBytes(minAvailableMemoryBytes)} available required to run classifiers in parallel`);
49
47
  this.name = "OllamaResourceError";
50
48
  this.totalMemoryBytes = totalMemoryBytes;
51
49
  this.availableMemoryBytes = availableMemoryBytes;
@@ -56,7 +54,11 @@ export class OllamaResourceError extends Error {
56
54
  // Build a `RunClassifier` bound to a specific Ollama host + model selection.
57
55
  // The resource check is lazy and runs once per runner — the first classifier
58
56
  // invocation pays for it; subsequent ones reuse the same promise.
59
- export function createOllamaClassifierRunner(config = {}) {
57
+ export function createOllamaClassifierRunner(config) {
58
+ if (!config?.modulesByName) {
59
+ throw new Error("createOllamaClassifierRunner requires `modulesByName` from buildClassifierRegistry()");
60
+ }
61
+ const modulesByName = config.modulesByName;
60
62
  const host = trimTrailingSlash(config.host ?? OLLAMA_DEFAULT_HOST);
61
63
  const fetchImpl = config.fetch ?? fetch;
62
64
  const models = config.models ?? {};
@@ -76,9 +78,13 @@ export function createOllamaClassifierRunner(config = {}) {
76
78
  });
77
79
  await resourceCheck;
78
80
  }
81
+ const manifest = modulesByName[name];
82
+ if (manifest === undefined) {
83
+ throw new OllamaClassifierError(name, defaultModel, `unknown classifier "${name}" — not present in registry`);
84
+ }
79
85
  const configuredModel = models[name];
80
86
  const model = configuredModel ?? defaultModel;
81
- return runOllamaClassifier(name, input, signal, fetchImpl, host, model, options, configuredModel === undefined && !hasDefaultModelOverride);
87
+ return runOllamaClassifier(manifest, input, signal, fetchImpl, host, model, options, configuredModel === undefined && !hasDefaultModelOverride);
82
88
  };
83
89
  }
84
90
  export async function assertOllamaResources(options = {}) {
@@ -90,10 +96,10 @@ export async function assertOllamaResources(options = {}) {
90
96
  throw new OllamaResourceError(totalMemoryBytes, availableMemoryBytes, minTotalMemoryBytes, minAvailableMemoryBytes);
91
97
  }
92
98
  }
93
- async function runOllamaClassifier(name, input, signal, fetchImpl, host, model, options, allowManifestModel) {
94
- const module_ = MODULES_BY_NAME[name];
95
- const systemPrompt = module_.systemPrompt;
96
- const configuredBaseModel = module_.backend?.ollama?.base_model;
99
+ async function runOllamaClassifier(manifest, input, signal, fetchImpl, host, model, options, allowManifestModel) {
100
+ const name = manifest.name;
101
+ const systemPrompt = manifest.systemPrompt;
102
+ const configuredBaseModel = manifest.backend?.ollama?.base_model;
97
103
  if (allowManifestModel && configuredBaseModel) {
98
104
  model = configuredBaseModel;
99
105
  }
@@ -137,7 +143,7 @@ async function runOllamaClassifier(name, input, signal, fetchImpl, host, model,
137
143
  }
138
144
  const parsed = parseJsonObject(content, name, model);
139
145
  try {
140
- return validateClassifierOutput(name, parsed, model);
146
+ return validateClassifierOutput(manifest, parsed, model);
141
147
  }
142
148
  catch (error) {
143
149
  if (error instanceof ClassifierValidationError) {
@@ -1,5 +1,5 @@
1
1
  import { type RunClassifier } from "./classifiers.js";
2
- import type { AggregatorConfig, Catalog, InspectResult, PipelineResult } from "./manifest.js";
2
+ import type { Catalog, ClassifierRegistry, InspectResult, PipelineResult } from "./manifest.js";
3
3
  import type { OpenClassifyInput } from "./types.js";
4
4
  export declare const DEFAULT_CLASSIFIER_TIMEOUT_MS = 15000;
5
5
  export declare const DEFAULT_CLASSIFIER_RETRY_COUNT = 1;
@@ -10,14 +10,15 @@ export declare class OpenClassifyNormalizationError extends Error {
10
10
  export interface ClassifyOptions {
11
11
  runClassifier: RunClassifier;
12
12
  catalog: Catalog;
13
+ registry: ClassifierRegistry;
13
14
  classifierTimeoutMs?: number;
14
15
  classifierRetryCount?: number;
15
16
  maxConcurrency?: number;
16
- aggregator?: AggregatorConfig;
17
17
  signal?: AbortSignal;
18
18
  }
19
19
  export interface InspectOptions {
20
20
  runClassifier: RunClassifier;
21
+ registry: ClassifierRegistry;
21
22
  classifierTimeoutMs?: number;
22
23
  classifierRetryCount?: number;
23
24
  maxConcurrency?: number;
@@ -1,7 +1,5 @@
1
- import { composeEnvelope } from "./aggregator.js";
2
- import { MODULES_BY_NAME, REGISTRY, } from "./classifiers.js";
1
+ import { assembleResult, buildPublicOutputs } from "./aggregator.js";
3
2
  import { normalizeOpenClassifyInput, toClassifierInput } from "./input.js";
4
- import { certaintyScore } from "./stock.js";
5
3
  export const DEFAULT_CLASSIFIER_TIMEOUT_MS = 15_000;
6
4
  export const DEFAULT_CLASSIFIER_RETRY_COUNT = 1;
7
5
  export const DEFAULT_MAX_CONCURRENCY = 7;
@@ -12,22 +10,27 @@ export class OpenClassifyNormalizationError extends Error {
12
10
  }
13
11
  }
14
12
  export async function classifyOpenClassifyInput(input, options) {
15
- const { request, results, meta } = await runPipeline(input, "user", options);
16
- const classifierInput = toClassifierInput(request);
17
- const envelope = composeEnvelope({
18
- registry: filteredRegistry("user"),
13
+ const { request, results, failedClassifiers } = await runPipeline(input, "user", options);
14
+ const reg = filteredRegistry(options.registry, "user");
15
+ const assembled = assembleResult({
16
+ registry: reg,
19
17
  results,
18
+ failedClassifiers,
20
19
  catalog: options.catalog,
21
- input: classifierInput,
22
- config: options.aggregator,
23
20
  });
24
- return buildRouteResult(request, envelope, results, meta);
21
+ return {
22
+ ...assembled,
23
+ target_message_hash: request.target_message_hash,
24
+ };
25
25
  }
26
26
  export async function inspectOpenClassifyInput(input, options) {
27
27
  const { request, results } = await runPipeline(input, "assistant", options);
28
+ const reg = filteredRegistry(options.registry, "assistant");
29
+ const lastMsg = request.messages[request.messages.length - 1];
28
30
  return {
29
31
  target_message_hash: request.target_message_hash,
30
- classifier_outputs: classifierPublicOutputs(filteredRegistry("assistant"), results),
32
+ message: { role: "assistant", text: lastMsg.text },
33
+ classifier_outputs: buildPublicOutputs(reg, results),
31
34
  };
32
35
  }
33
36
  async function runPipeline(input, role, options) {
@@ -52,22 +55,19 @@ async function runPipeline(input, role, options) {
52
55
  const classifierTimeoutMs = options.classifierTimeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS;
53
56
  const classifierRetryCount = options.classifierRetryCount ?? DEFAULT_CLASSIFIER_RETRY_COUNT;
54
57
  const maxConcurrency = resolveMaxConcurrency(options.maxConcurrency);
55
- // REGISTRY is already sorted by `dispatch_order` ascending. Filter by
56
- // applies_to so we only dispatch classifiers relevant to this role; the
57
- // worker pool then runs them in the remaining order.
58
- const registry = filteredRegistry(role);
58
+ const registry = filteredRegistry(options.registry, role);
59
59
  const queue = registry.map((m) => m.name);
60
60
  try {
61
61
  const settled = await runWithConcurrency(queue, maxConcurrency, controller.signal, (name) => runClassifierWithRetry(name, classifierInput, options.runClassifier, controller.signal, classifierTimeoutMs, classifierRetryCount));
62
- const { results, meta } = collectFullEntries(settled, registry);
63
- return { request, results, meta };
62
+ const { results, failedClassifiers } = collectResults(registry, settled);
63
+ return { request, results, failedClassifiers };
64
64
  }
65
65
  finally {
66
66
  options.signal?.removeEventListener("abort", abortFromOptions);
67
67
  }
68
68
  }
69
- function filteredRegistry(role) {
70
- return REGISTRY.filter((m) => roleAppliesTo(m.appliesTo, role));
69
+ function filteredRegistry(registry, role) {
70
+ return registry.filter((m) => roleAppliesTo(m.appliesTo, role));
71
71
  }
72
72
  function roleAppliesTo(appliesTo, role) {
73
73
  return appliesTo === "both" || appliesTo === role;
@@ -91,9 +91,6 @@ async function runWithConcurrency(names, maxConcurrency, signal, start) {
91
91
  return;
92
92
  const name = names[i];
93
93
  if (signal.aborted) {
94
- // Queued classifiers that never started are reported as not-run so
95
- // the audit shows their fallback in `meta.classifiers`. In-flight
96
- // classifiers receive the abort signal directly and resolve normally.
97
94
  results[i] = {
98
95
  ok: false,
99
96
  name,
@@ -109,71 +106,22 @@ async function runWithConcurrency(names, maxConcurrency, signal, start) {
109
106
  await Promise.all(Array.from({ length: workerCount }, () => worker()));
110
107
  return results;
111
108
  }
112
- function collectFullEntries(settled, registry) {
109
+ function collectResults(registry, settled) {
110
+ const fallbackByName = new Map();
111
+ for (const m of registry)
112
+ fallbackByName.set(m.name, m.fallback);
113
113
  const results = {};
114
- const classifiers = {};
114
+ const failedClassifiers = [];
115
115
  for (const s of settled) {
116
- const manifest = MODULES_BY_NAME[s.name];
117
- const value = s.ok ? s.value : manifest.fallback;
118
- results[s.name] = value;
119
- classifiers[s.name] = {
120
- ...value,
121
- status: classifierRunStatus(s),
122
- version: manifest.version,
123
- };
124
- }
125
- return { results, meta: { classifiers, certainty: certaintySummary(results, registry) } };
126
- }
127
- function certaintySummary(results, registry) {
128
- const scores = registry.map((m) => scoreCertainty(results[m.name]?.certainty));
129
- if (scores.length === 0)
130
- return { min: 0, avg: 0 };
131
- const min = Math.min(...scores);
132
- const avg = scores.reduce((sum, v) => sum + v, 0) / scores.length;
133
- return { min, avg };
134
- }
135
- function scoreCertainty(certainty) {
136
- return certainty === undefined ? 0 : certaintyScore[certainty];
137
- }
138
- function buildRouteResult(request, envelope, results, meta) {
139
- const downstream = {
140
- model_id: envelope.model_recommendation.id,
141
- target_message: {
142
- role: "user",
143
- text: request.text,
144
- hash: request.target_message_hash,
145
- },
146
- tools: envelope.tools ?? { tools: [] },
147
- };
148
- return {
149
- action: "route",
150
- target_message_hash: request.target_message_hash,
151
- downstream,
152
- classifier_outputs: classifierPublicOutputs(filteredRegistry("user"), results),
153
- audit: {
154
- ...envelope,
155
- meta,
156
- },
157
- };
158
- }
159
- // Expose each classifier's payload — every output field except `reason` and
160
- // `certainty`. Iterates the supplied registry so we only surface classifiers
161
- // that actually ran for this role.
162
- function classifierPublicOutputs(registry, results) {
163
- const out = {};
164
- for (const manifest of registry) {
165
- const result = results[manifest.name];
166
- if (result === undefined)
167
- continue;
168
- out[manifest.name] = stripMetadata(result);
116
+ const fallback = fallbackByName.get(s.name);
117
+ if (fallback === undefined) {
118
+ throw new Error(`pipeline: classifier "${s.name}" missing from registry`);
119
+ }
120
+ results[s.name] = s.ok ? s.value : fallback;
121
+ if (!s.ok)
122
+ failedClassifiers.push(s.name);
169
123
  }
170
- return out;
171
- }
172
- function stripMetadata(output) {
173
- const { reason, certainty, ...payload } = output;
174
- void reason;
175
- void certainty;
176
- return payload;
124
+ return { results, failedClassifiers };
177
125
  }
178
126
  async function runClassifierWithRetry(name, input, runClassifier, rootSignal, timeoutMs, retryCount) {
179
127
  let lastError = new Error(`${name} classifier did not run`);
@@ -219,16 +167,6 @@ async function runClassifierAttempt(name, input, runClassifier, rootSignal, time
219
167
  rootSignal.removeEventListener("abort", abortAttempt);
220
168
  }
221
169
  }
222
- function classifierRunStatus(settled) {
223
- if (settled.ok)
224
- return { ok: true, source: "model" };
225
- return {
226
- ok: false,
227
- source: "fallback",
228
- reason: settled.reason,
229
- error: errorMessage(settled.error),
230
- };
231
- }
232
170
  function errorMessage(error) {
233
171
  return error instanceof Error ? error.message : String(error);
234
172
  }
@@ -212,10 +212,14 @@ function validateFallback(raw, composedSchema, classifier, model) {
212
212
  if (!isRecord(raw)) {
213
213
  throwInvalid(classifier, model, "fallback must be a JSON object");
214
214
  }
215
- // Fallback represents the "I have no signal" state, so reserved fields are
216
- // optional. The composed schema already marks them optional except for
217
- // reason/certainty.
218
- const validate = ajv.compile(composedSchema);
215
+ // Fallback is the "no signal" state: only reason and certainty are required.
216
+ // Strip any custom `required` entries beyond those two so that reserved fields
217
+ // and output_schema.required fields don't force the fallback to emit values
218
+ // it cannot meaningfully provide when the classifier has failed.
219
+ const fallbackSchema = isRecord(composedSchema)
220
+ ? { ...composedSchema, required: ["reason", "certainty"] }
221
+ : composedSchema;
222
+ const validate = ajv.compile(fallbackSchema);
219
223
  if (!validate(raw)) {
220
224
  const message = formatSchemaErrors(validate.errors, "fallback");
221
225
  throwInvalid(classifier, model, `fallback is invalid: ${message}`);