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.
- package/README.md +96 -88
- package/bin/open-classify.mjs +201 -0
- package/dist/src/aggregator.d.ts +7 -23
- package/dist/src/aggregator.js +108 -186
- package/dist/src/classifiers/{routing → model_tier}/manifest.json +2 -2
- package/dist/src/classifiers/{routing → model_tier}/prompt.md +1 -1
- package/dist/src/classifiers/preflight/manifest.json +9 -8
- package/dist/src/classifiers/preflight/prompt.md +12 -6
- package/dist/src/classifiers/prompt_injection/manifest.json +2 -3
- package/dist/src/classifiers.d.ts +12 -5
- package/dist/src/classifiers.js +32 -16
- package/dist/src/classify.d.ts +5 -3
- package/dist/src/classify.js +28 -8
- package/dist/src/config.d.ts +1 -3
- package/dist/src/config.js +1 -28
- package/dist/src/index.js +2 -3
- package/dist/src/manifest.d.ts +25 -70
- package/dist/src/ollama.d.ts +5 -6
- package/dist/src/ollama.js +17 -11
- package/dist/src/pipeline.d.ts +3 -2
- package/dist/src/pipeline.js +32 -94
- package/dist/src/stock-validation.js +8 -4
- package/docs/adding-a-classifier.md +50 -27
- package/docs/manifests.md +6 -6
- package/docs/resolver.md +20 -44
- package/docs/signals.md +18 -8
- package/open-classify.config.example.json +2 -7
- package/package.json +6 -1
- /package/{dist/src/classifiers → templates}/context_shift/manifest.json +0 -0
- /package/{dist/src/classifiers → templates}/context_shift/prompt.md +0 -0
- /package/{dist/src/classifiers → templates}/conversation_digest/manifest.json +0 -0
- /package/{dist/src/classifiers → templates}/conversation_digest/prompt.md +0 -0
- /package/{dist/src/classifiers → templates}/memory_retrieval_queries/manifest.json +0 -0
- /package/{dist/src/classifiers → templates}/memory_retrieval_queries/prompt.md +0 -0
- /package/{dist/src/classifiers → templates}/tools/manifest.json +0 -0
- /package/{dist/src/classifiers → templates}/tools/prompt.md +0 -0
package/dist/src/classify.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
// High-level facade for the pipeline. Builds the runner and
|
|
2
|
-
// then returns two functions — classify() for the
|
|
3
|
-
// and inspect() for the assistant-output lean pass.
|
|
4
|
-
// custom `runClassifier` to bypass the bundled
|
|
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 {
|
|
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 };
|
package/dist/src/config.d.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import {
|
|
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";
|
package/dist/src/config.js
CHANGED
|
@@ -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"
|
|
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
|
|
4
|
-
//
|
|
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";
|
package/dist/src/manifest.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { ClassifierInput
|
|
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
|
|
55
|
-
|
|
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
|
|
60
|
-
readonly
|
|
61
|
-
readonly
|
|
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
|
|
84
|
-
readonly action: "route";
|
|
42
|
+
export interface InspectResult {
|
|
85
43
|
readonly target_message_hash: string;
|
|
86
|
-
readonly
|
|
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>;
|
package/dist/src/ollama.d.ts
CHANGED
|
@@ -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
|
|
38
|
+
export declare function createOllamaClassifierRunner(config: OllamaClassifierRunnerConfig): RunClassifier;
|
|
40
39
|
export declare function assertOllamaResources(options?: {
|
|
41
40
|
minTotalMemoryBytes?: number;
|
|
42
41
|
minAvailableMemoryBytes?: number;
|
package/dist/src/ollama.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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(
|
|
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(
|
|
94
|
-
const
|
|
95
|
-
const systemPrompt =
|
|
96
|
-
const configuredBaseModel =
|
|
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(
|
|
146
|
+
return validateClassifierOutput(manifest, parsed, model);
|
|
141
147
|
}
|
|
142
148
|
catch (error) {
|
|
143
149
|
if (error instanceof ClassifierValidationError) {
|
package/dist/src/pipeline.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type RunClassifier } from "./classifiers.js";
|
|
2
|
-
import type {
|
|
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;
|
package/dist/src/pipeline.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
registry:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
63
|
-
return { request, results,
|
|
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
|
|
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
|
|
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
|
|
114
|
+
const failedClassifiers = [];
|
|
115
115
|
for (const s of settled) {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
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}`);
|