open-classify 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/dist/src/aggregator.d.ts +18 -0
- package/dist/src/aggregator.js +267 -0
- package/dist/src/catalog.d.ts +7 -0
- package/dist/src/catalog.js +189 -0
- package/dist/src/classifiers/custom/conversation_diegest/manifest.json +28 -0
- package/dist/src/classifiers/custom/conversation_diegest/prompt.md +7 -0
- package/dist/src/classifiers/custom/memory_retrieval_queries/manifest.json +29 -0
- package/dist/src/classifiers/custom/memory_retrieval_queries/prompt.md +5 -0
- package/dist/src/classifiers/stock/model_specialization/manifest.json +8 -0
- package/dist/src/classifiers/stock/preflight/manifest.json +8 -0
- package/dist/src/classifiers/stock/prompts/base.md +1 -0
- package/dist/src/classifiers/stock/prompts/classifier-header.md +4 -0
- package/dist/src/classifiers/stock/prompts/confidence.md +3 -0
- package/dist/src/classifiers/stock/prompts/custom-output.md +1 -0
- package/dist/src/classifiers/stock/prompts/model_specialization.md +7 -0
- package/dist/src/classifiers/stock/prompts/preflight-output.md +10 -0
- package/dist/src/classifiers/stock/prompts/preflight.md +47 -0
- package/dist/src/classifiers/stock/prompts/reason.md +3 -0
- package/dist/src/classifiers/stock/prompts/routing-output.md +5 -0
- package/dist/src/classifiers/stock/prompts/routing.md +9 -0
- package/dist/src/classifiers/stock/prompts/security-output.md +8 -0
- package/dist/src/classifiers/stock/prompts/security.md +26 -0
- package/dist/src/classifiers/stock/prompts/specialty.md +10 -0
- package/dist/src/classifiers/stock/prompts/tier.md +7 -0
- package/dist/src/classifiers/stock/prompts/tools-output.md +7 -0
- package/dist/src/classifiers/stock/prompts/tools.md +10 -0
- package/dist/src/classifiers/stock/routing/manifest.json +8 -0
- package/dist/src/classifiers/stock/security/manifest.json +12 -0
- package/dist/src/classifiers/stock/tools/manifest.json +19 -0
- package/dist/src/classifiers.d.ts +14 -0
- package/dist/src/classifiers.js +87 -0
- package/dist/src/config.d.ts +29 -0
- package/dist/src/config.js +144 -0
- package/dist/src/enums.d.ts +10 -0
- package/dist/src/enums.js +62 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +18 -0
- package/dist/src/input.d.ts +4 -0
- package/dist/src/input.js +192 -0
- package/dist/src/manifest.d.ts +115 -0
- package/dist/src/manifest.js +1 -0
- package/dist/src/ollama.d.ts +54 -0
- package/dist/src/ollama.js +293 -0
- package/dist/src/pipeline.d.ts +17 -0
- package/dist/src/pipeline.js +274 -0
- package/dist/src/stock-prompt.d.ts +2 -0
- package/dist/src/stock-prompt.js +63 -0
- package/dist/src/stock-validation.d.ts +22 -0
- package/dist/src/stock-validation.js +329 -0
- package/dist/src/stock.d.ts +101 -0
- package/dist/src/stock.js +14 -0
- package/dist/src/types.d.ts +34 -0
- package/dist/src/types.js +6 -0
- package/dist/src/ui-server.d.ts +1 -0
- package/dist/src/ui-server.js +250 -0
- package/dist/src/validation.d.ts +17 -0
- package/dist/src/validation.js +127 -0
- package/open-classify.config.example.json +24 -0
- package/package.json +56 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { REGISTRY } from "./classifiers.js";
|
|
3
|
+
import { STOCK_CLASSIFIER_NAMES } from "./stock.js";
|
|
4
|
+
import { isRecord } from "./validation.js";
|
|
5
|
+
export const DEFAULT_OPEN_CLASSIFY_CONFIG_PATH = "open-classify.config.json";
|
|
6
|
+
export class OpenClassifyConfigError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "OpenClassifyConfigError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function loadOpenClassifyConfig(path = process.env.OPEN_CLASSIFY_CONFIG ?? DEFAULT_OPEN_CLASSIFY_CONFIG_PATH, options = {}) {
|
|
13
|
+
if (!existsSync(path)) {
|
|
14
|
+
if (options.optional)
|
|
15
|
+
return undefined;
|
|
16
|
+
throw new OpenClassifyConfigError(`config file not found: ${path}`);
|
|
17
|
+
}
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
throw new OpenClassifyConfigError(`${path}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
24
|
+
}
|
|
25
|
+
return validateOpenClassifyConfig(parsed, path);
|
|
26
|
+
}
|
|
27
|
+
export function classifierModelsFromConfig(config) {
|
|
28
|
+
const models = config?.runner?.models;
|
|
29
|
+
if (!models)
|
|
30
|
+
return {};
|
|
31
|
+
return {
|
|
32
|
+
...models.stock,
|
|
33
|
+
...models.custom,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function validateOpenClassifyConfig(value, path = "open-classify config") {
|
|
37
|
+
if (!isRecord(value)) {
|
|
38
|
+
throwConfig(path, "config must be a JSON object");
|
|
39
|
+
}
|
|
40
|
+
ensureAllowedKeys(value, ["runner", "catalog"], path, "<root>");
|
|
41
|
+
return {
|
|
42
|
+
...(value.runner === undefined ? {} : { runner: validateRunner(value.runner, path) }),
|
|
43
|
+
...(value.catalog === undefined ? {} : { catalog: requireString(value.catalog, path, "catalog") }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function validateRunner(value, path) {
|
|
47
|
+
if (!isRecord(value)) {
|
|
48
|
+
throwConfig(path, "runner must be an object");
|
|
49
|
+
}
|
|
50
|
+
ensureAllowedKeys(value, ["provider", "host", "defaultModel", "options", "models"], path, "runner");
|
|
51
|
+
const provider = value.provider === undefined
|
|
52
|
+
? "ollama"
|
|
53
|
+
: requireString(value.provider, path, "runner.provider");
|
|
54
|
+
if (provider !== "ollama") {
|
|
55
|
+
throwConfig(path, `runner.provider must be "ollama"`);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
provider: "ollama",
|
|
59
|
+
...(value.host === undefined ? {} : { host: requireString(value.host, path, "runner.host") }),
|
|
60
|
+
...(value.defaultModel === undefined
|
|
61
|
+
? {}
|
|
62
|
+
: { defaultModel: requireString(value.defaultModel, path, "runner.defaultModel") }),
|
|
63
|
+
...(value.options === undefined ? {} : { options: validateOptions(value.options, path) }),
|
|
64
|
+
...(value.models === undefined ? {} : { models: validateModels(value.models, path) }),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function validateOptions(value, path) {
|
|
68
|
+
if (!isRecord(value)) {
|
|
69
|
+
throwConfig(path, "runner.options must be an object");
|
|
70
|
+
}
|
|
71
|
+
ensureAllowedKeys(value, ["temperature", "top_p", "seed", "num_ctx"], path, "runner.options");
|
|
72
|
+
return {
|
|
73
|
+
...(value.temperature === undefined
|
|
74
|
+
? {}
|
|
75
|
+
: { temperature: requireNumber(value.temperature, path, "runner.options.temperature") }),
|
|
76
|
+
...(value.top_p === undefined
|
|
77
|
+
? {}
|
|
78
|
+
: { top_p: requireNumber(value.top_p, path, "runner.options.top_p") }),
|
|
79
|
+
...(value.seed === undefined
|
|
80
|
+
? {}
|
|
81
|
+
: { seed: requireNumber(value.seed, path, "runner.options.seed") }),
|
|
82
|
+
...(value.num_ctx === undefined
|
|
83
|
+
? {}
|
|
84
|
+
: { num_ctx: requireNumber(value.num_ctx, path, "runner.options.num_ctx") }),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function validateModels(value, path) {
|
|
88
|
+
if (!isRecord(value)) {
|
|
89
|
+
throwConfig(path, "runner.models must be an object");
|
|
90
|
+
}
|
|
91
|
+
ensureAllowedKeys(value, ["stock", "custom"], path, "runner.models");
|
|
92
|
+
return {
|
|
93
|
+
...(value.stock === undefined
|
|
94
|
+
? {}
|
|
95
|
+
: { stock: validateModelMap(value.stock, path, "runner.models.stock", stockClassifierNames()) }),
|
|
96
|
+
...(value.custom === undefined
|
|
97
|
+
? {}
|
|
98
|
+
: { custom: validateModelMap(value.custom, path, "runner.models.custom", customClassifierNames()) }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function validateModelMap(value, path, field, allowedNames) {
|
|
102
|
+
if (!isRecord(value)) {
|
|
103
|
+
throwConfig(path, `${field} must be an object`);
|
|
104
|
+
}
|
|
105
|
+
const out = {};
|
|
106
|
+
for (const [name, model] of Object.entries(value)) {
|
|
107
|
+
if (!allowedNames.has(name)) {
|
|
108
|
+
throwConfig(path, `${field}.${name} is not a known classifier`);
|
|
109
|
+
}
|
|
110
|
+
out[name] = requireString(model, path, `${field}.${name}`);
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
function stockClassifierNames() {
|
|
115
|
+
return new Set(STOCK_CLASSIFIER_NAMES);
|
|
116
|
+
}
|
|
117
|
+
function customClassifierNames() {
|
|
118
|
+
return new Set(REGISTRY
|
|
119
|
+
.filter((classifier) => classifier.kind === "custom")
|
|
120
|
+
.map((classifier) => classifier.name));
|
|
121
|
+
}
|
|
122
|
+
function requireString(value, path, field) {
|
|
123
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
124
|
+
throwConfig(path, `${field} must be a non-empty string`);
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
function requireNumber(value, path, field) {
|
|
129
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
130
|
+
throwConfig(path, `${field} must be a finite number`);
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
function ensureAllowedKeys(value, allowedKeys, path, field) {
|
|
135
|
+
const allowed = new Set(allowedKeys);
|
|
136
|
+
for (const key of Object.keys(value)) {
|
|
137
|
+
if (!allowed.has(key)) {
|
|
138
|
+
throwConfig(path, `${field}.${key} is not a supported field`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function throwConfig(path, message) {
|
|
143
|
+
throw new OpenClassifyConfigError(`${path}: ${message}`);
|
|
144
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const DOWNSTREAM_MODEL_TIER_VALUES: readonly ["local_fast", "local_small", "local_strong", "local_coding", "frontier_fast", "frontier_strong", "frontier_coding"];
|
|
2
|
+
export type DownstreamModelTier = (typeof DOWNSTREAM_MODEL_TIER_VALUES)[number];
|
|
3
|
+
export declare const MODEL_SPECIALIZATION_VALUES: readonly ["agentic_coding", "agentic_workflows", "chat", "code_fixing", "code_reasoning", "code_review", "writing", "reasoning", "planning", "coding", "computer_use", "debugging", "instruction_following", "question_answering", "subagents", "summarization", "tool_assisted_coding", "vision_input"];
|
|
4
|
+
export type ModelSpecialization = (typeof MODEL_SPECIALIZATION_VALUES)[number];
|
|
5
|
+
export declare const SECURITY_DECISION_VALUES: readonly ["allow", "block", "needs_review"];
|
|
6
|
+
export type SecurityDecision = (typeof SECURITY_DECISION_VALUES)[number];
|
|
7
|
+
export declare const SECURITY_RISK_LEVEL_VALUES: readonly ["normal", "suspicious", "high_risk", "unknown"];
|
|
8
|
+
export type SecurityRiskLevel = (typeof SECURITY_RISK_LEVEL_VALUES)[number];
|
|
9
|
+
export declare const SECURITY_SIGNAL_VALUES: readonly ["instruction_attack", "secret_or_private_data_risk", "unsafe_tool_or_action", "untrusted_content_or_code", "injection_or_obfuscation"];
|
|
10
|
+
export type SecuritySignal = (typeof SECURITY_SIGNAL_VALUES)[number];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Shared categorical enums used by more than one classifier or by the
|
|
2
|
+
// catalog. Each enum is exported twice: once as a `*_VALUES` array (used by
|
|
3
|
+
// validators and tests) and once as a string-literal union type derived from
|
|
4
|
+
// the array via `(typeof X)[number]`. Keep the array and the type in sync.
|
|
5
|
+
//
|
|
6
|
+
// Classifier outputs omit uncertain optional fields rather than emitting an
|
|
7
|
+
// escape-hatch value, so these enums list only concrete choices.
|
|
8
|
+
// Coarse capability+latency tier for the downstream model. Callers map these
|
|
9
|
+
// onto concrete model names via the catalog.
|
|
10
|
+
export const DOWNSTREAM_MODEL_TIER_VALUES = [
|
|
11
|
+
"local_fast",
|
|
12
|
+
"local_small",
|
|
13
|
+
"local_strong",
|
|
14
|
+
"local_coding",
|
|
15
|
+
"frontier_fast",
|
|
16
|
+
"frontier_strong",
|
|
17
|
+
"frontier_coding",
|
|
18
|
+
];
|
|
19
|
+
// Which kind of model/prompt specialization fits the request best. Combined
|
|
20
|
+
// with the tier to look up a concrete model in the catalog.
|
|
21
|
+
export const MODEL_SPECIALIZATION_VALUES = [
|
|
22
|
+
"agentic_coding",
|
|
23
|
+
"agentic_workflows",
|
|
24
|
+
"chat",
|
|
25
|
+
"code_fixing",
|
|
26
|
+
"code_reasoning",
|
|
27
|
+
"code_review",
|
|
28
|
+
"writing",
|
|
29
|
+
"reasoning",
|
|
30
|
+
"planning",
|
|
31
|
+
"coding",
|
|
32
|
+
"computer_use",
|
|
33
|
+
"debugging",
|
|
34
|
+
"instruction_following",
|
|
35
|
+
"question_answering",
|
|
36
|
+
"subagents",
|
|
37
|
+
"summarization",
|
|
38
|
+
"tool_assisted_coding",
|
|
39
|
+
"vision_input",
|
|
40
|
+
];
|
|
41
|
+
export const SECURITY_DECISION_VALUES = [
|
|
42
|
+
"allow",
|
|
43
|
+
"block",
|
|
44
|
+
"needs_review",
|
|
45
|
+
];
|
|
46
|
+
// Overall safety posture on the latest user message. Security short-circuiting
|
|
47
|
+
// is driven by safety.decision, not risk level alone.
|
|
48
|
+
export const SECURITY_RISK_LEVEL_VALUES = [
|
|
49
|
+
"normal",
|
|
50
|
+
"suspicious",
|
|
51
|
+
"high_risk",
|
|
52
|
+
"unknown",
|
|
53
|
+
];
|
|
54
|
+
// Specific safety concerns the security classifier can flag. These are
|
|
55
|
+
// advisory; safety.decision controls whether the pipeline blocks or needs review.
|
|
56
|
+
export const SECURITY_SIGNAL_VALUES = [
|
|
57
|
+
"instruction_attack",
|
|
58
|
+
"secret_or_private_data_risk",
|
|
59
|
+
"unsafe_tool_or_action",
|
|
60
|
+
"untrusted_content_or_code",
|
|
61
|
+
"injection_or_obfuscation",
|
|
62
|
+
];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from "./aggregator.js";
|
|
2
|
+
export * from "./catalog.js";
|
|
3
|
+
export * from "./classifiers.js";
|
|
4
|
+
export * from "./config.js";
|
|
5
|
+
export * from "./enums.js";
|
|
6
|
+
export * from "./input.js";
|
|
7
|
+
export * from "./manifest.js";
|
|
8
|
+
export * from "./ollama.js";
|
|
9
|
+
export * from "./pipeline.js";
|
|
10
|
+
export * from "./stock.js";
|
|
11
|
+
export * from "./stock-prompt.js";
|
|
12
|
+
export * from "./stock-validation.js";
|
|
13
|
+
export * from "./types.js";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Public barrel for the Open Classify package. Everything an external caller
|
|
2
|
+
// would need — input types, enums, the registry, the pipeline, the Ollama
|
|
3
|
+
// runner, the catalog loader, the aggregator's confidence threshold — is
|
|
4
|
+
// re-exported here. The build emits a single `index.js` that downstream
|
|
5
|
+
// consumers can import from `open-classify`.
|
|
6
|
+
export * from "./aggregator.js";
|
|
7
|
+
export * from "./catalog.js";
|
|
8
|
+
export * from "./classifiers.js";
|
|
9
|
+
export * from "./config.js";
|
|
10
|
+
export * from "./enums.js";
|
|
11
|
+
export * from "./input.js";
|
|
12
|
+
export * from "./manifest.js";
|
|
13
|
+
export * from "./ollama.js";
|
|
14
|
+
export * from "./pipeline.js";
|
|
15
|
+
export * from "./stock.js";
|
|
16
|
+
export * from "./stock-prompt.js";
|
|
17
|
+
export * from "./stock-validation.js";
|
|
18
|
+
export * from "./types.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ClassifierInput, NormalizedOpenClassifyInput, OpenClassifyInput } from "./types.js";
|
|
2
|
+
export declare function sanitizeText(raw: string): string;
|
|
3
|
+
export declare function normalizeOpenClassifyInput(input: OpenClassifyInput): NormalizedOpenClassifyInput;
|
|
4
|
+
export declare function toClassifierInput(normalized: NormalizedOpenClassifyInput): ClassifierInput;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Input normalization. This module enforces the Open Classify input contract
|
|
2
|
+
// before anything model-shaped happens: structural shape, allowed fields,
|
|
3
|
+
// sanitized text, payload budget, and a stable target-message hash. Anything
|
|
4
|
+
// that throws here surfaces as `OpenClassifyNormalizationError` to callers.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
/*
|
|
7
|
+
* Message text budget:
|
|
8
|
+
*
|
|
9
|
+
* Gemma 4 E4B supports a native 131,072-token (128K) context window. Open
|
|
10
|
+
* Classify does not use that full window in the reference local runtime: it
|
|
11
|
+
* runs the classifier set in parallel with a configured 4,096-token context.
|
|
12
|
+
* The largest fixed classifier prompt is security at about 1,748 estimated
|
|
13
|
+
* tokens using the same 3 chars/token heuristic as the Ollama packer. We round
|
|
14
|
+
* that up to 2,000 fixed-prompt tokens, reserve roughly 400 tokens for output,
|
|
15
|
+
* chat-template variance, and estimation error, then spend the remainder on
|
|
16
|
+
* sanitized conversation text:
|
|
17
|
+
*
|
|
18
|
+
* 4,096 - 2,000 - 400 = 1,696 text tokens
|
|
19
|
+
* 1,696 * 3 chars/token = 5,088 chars
|
|
20
|
+
*
|
|
21
|
+
* Use a round 5,000-character API budget. The Ollama runner still validates the
|
|
22
|
+
* fully rendered prompt for the configured num_ctx and drops older whole
|
|
23
|
+
* context messages when a caller overrides num_ctx lower.
|
|
24
|
+
*/
|
|
25
|
+
const CONVERSATION_TEXT_MAX_CHARS = 5_000;
|
|
26
|
+
const MESSAGE_HISTORY_MAX_COUNT = 20;
|
|
27
|
+
const INPUT_FIELDS = new Set([
|
|
28
|
+
"messages",
|
|
29
|
+
]);
|
|
30
|
+
const CONVERSATION_MESSAGE_FIELDS = new Set([
|
|
31
|
+
"role",
|
|
32
|
+
"text",
|
|
33
|
+
]);
|
|
34
|
+
// Strip the BOM, normalize composed/decomposed Unicode (so "café" never has
|
|
35
|
+
// two encodings), drop control chars except tab/newline/CR, and trim. Tabs
|
|
36
|
+
// and newlines are kept because they're load-bearing in code/markdown
|
|
37
|
+
// snippets that users paste in.
|
|
38
|
+
export function sanitizeText(raw) {
|
|
39
|
+
let text = raw;
|
|
40
|
+
if (text.charCodeAt(0) === 0xfeff) {
|
|
41
|
+
text = text.slice(1);
|
|
42
|
+
}
|
|
43
|
+
return text
|
|
44
|
+
.normalize("NFC")
|
|
45
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
|
|
46
|
+
.trim();
|
|
47
|
+
}
|
|
48
|
+
// 8-hex-char fingerprint of the canonicalized value. Short on purpose —
|
|
49
|
+
// this is for correlation/dedup, not cryptographic identity. Canonicalization
|
|
50
|
+
// (sorted keys, undefined-stripped) makes the hash stable across input order.
|
|
51
|
+
function hashCanonicalValue(value) {
|
|
52
|
+
return createHash("sha256").update(canonicalJson(value)).digest("hex").slice(0, 8);
|
|
53
|
+
}
|
|
54
|
+
export function normalizeOpenClassifyInput(input) {
|
|
55
|
+
assertPlainObject(input, "input");
|
|
56
|
+
rejectUnknownFields(input, INPUT_FIELDS, "input");
|
|
57
|
+
const messages = normalizeMessages(input.messages);
|
|
58
|
+
const target = messages[messages.length - 1];
|
|
59
|
+
const text = target.text;
|
|
60
|
+
const normalized = {
|
|
61
|
+
messages,
|
|
62
|
+
text,
|
|
63
|
+
target_message_hash: "",
|
|
64
|
+
};
|
|
65
|
+
normalized.target_message_hash = hashCanonicalValue({
|
|
66
|
+
role: target.role ?? "user",
|
|
67
|
+
text,
|
|
68
|
+
});
|
|
69
|
+
return normalized;
|
|
70
|
+
}
|
|
71
|
+
export function toClassifierInput(normalized) {
|
|
72
|
+
return {
|
|
73
|
+
text: normalized.text,
|
|
74
|
+
messages: normalized.messages,
|
|
75
|
+
target_message_hash: normalized.target_message_hash,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function normalizeMessages(messages) {
|
|
79
|
+
if (!Array.isArray(messages)) {
|
|
80
|
+
throw new TypeError("input.messages must be an array");
|
|
81
|
+
}
|
|
82
|
+
if (messages.length === 0) {
|
|
83
|
+
throw new Error("input.messages must contain at least one message");
|
|
84
|
+
}
|
|
85
|
+
return takeNewestWholeMessagesThatFit(messages);
|
|
86
|
+
}
|
|
87
|
+
function normalizeConversationMessage(message, path) {
|
|
88
|
+
assertPlainObject(message, path);
|
|
89
|
+
rejectUnknownFields(message, CONVERSATION_MESSAGE_FIELDS, path);
|
|
90
|
+
if (typeof message.text !== "string") {
|
|
91
|
+
throw new TypeError(`${path}.text must be a string`);
|
|
92
|
+
}
|
|
93
|
+
const normalized = {
|
|
94
|
+
text: sanitizeText(message.text),
|
|
95
|
+
};
|
|
96
|
+
if (message.role !== undefined) {
|
|
97
|
+
if (!["user", "assistant"].includes(message.role)) {
|
|
98
|
+
throw new TypeError(`${path}.role must be user or assistant`);
|
|
99
|
+
}
|
|
100
|
+
normalized.role = message.role;
|
|
101
|
+
}
|
|
102
|
+
return normalized;
|
|
103
|
+
}
|
|
104
|
+
// Walk the message history newest → oldest, keeping whole messages while
|
|
105
|
+
// we're under both the count cap and the character budget. The final
|
|
106
|
+
// message is non-negotiable (it's what we're classifying) — we validate it
|
|
107
|
+
// stricter, force role=user, and never drop it on length grounds.
|
|
108
|
+
//
|
|
109
|
+
// We never slice text inside a message: classifiers depend on the full
|
|
110
|
+
// final message, and slicing earlier ones at arbitrary boundaries tends to
|
|
111
|
+
// confuse models more than it helps.
|
|
112
|
+
function takeNewestWholeMessagesThatFit(messages) {
|
|
113
|
+
const selected = [];
|
|
114
|
+
let totalChars = 0;
|
|
115
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
116
|
+
const normalized = normalizeConversationMessage(messages[index], `input.messages[${index}]`);
|
|
117
|
+
const isFinalMessage = index === messages.length - 1;
|
|
118
|
+
if (isFinalMessage) {
|
|
119
|
+
if (normalized.text.length === 0) {
|
|
120
|
+
throw new Error("final message is empty after sanitization");
|
|
121
|
+
}
|
|
122
|
+
if (normalized.role !== undefined && normalized.role !== "user") {
|
|
123
|
+
throw new Error("final message must have role user");
|
|
124
|
+
}
|
|
125
|
+
normalized.role = "user";
|
|
126
|
+
if (normalized.text.length > CONVERSATION_TEXT_MAX_CHARS) {
|
|
127
|
+
throw new RangeError(`final message must be ${CONVERSATION_TEXT_MAX_CHARS} characters or fewer`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else if (normalized.text.length === 0) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!isFinalMessage && selected.length >= MESSAGE_HISTORY_MAX_COUNT) {
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
if (!isFinalMessage && totalChars + normalized.text.length > CONVERSATION_TEXT_MAX_CHARS) {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
selected.push(normalized);
|
|
140
|
+
totalChars += normalized.text.length;
|
|
141
|
+
}
|
|
142
|
+
return selected.reverse();
|
|
143
|
+
}
|
|
144
|
+
// Reject class instances and prototype-tampered objects, not just non-objects.
|
|
145
|
+
// We want plain `{}` shapes here — anything carrying behavior or an exotic
|
|
146
|
+
// prototype could surprise the JSON serializer or smuggle properties past
|
|
147
|
+
// `rejectUnknownFields`.
|
|
148
|
+
function assertPlainObject(value, path) {
|
|
149
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
150
|
+
throw new TypeError(`${path} must be a plain object`);
|
|
151
|
+
}
|
|
152
|
+
const prototype = Object.getPrototypeOf(value);
|
|
153
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
154
|
+
throw new TypeError(`${path} must be a plain object`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function rejectUnknownFields(value, allowedFields, path) {
|
|
158
|
+
for (const key of Object.keys(value)) {
|
|
159
|
+
if (!allowedFields.has(key)) {
|
|
160
|
+
throw new TypeError(`${path}.${key} is not a supported field`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function canonicalJson(value) {
|
|
165
|
+
return JSON.stringify(canonicalize(value));
|
|
166
|
+
}
|
|
167
|
+
// Recursive deep-sort of object keys + drop of `undefined` values. Used by
|
|
168
|
+
// `hashCanonicalValue` so the hash is invariant under input key ordering —
|
|
169
|
+
// `{a:1,b:2}` and `{b:2,a:1}` produce the same fingerprint.
|
|
170
|
+
function canonicalize(value) {
|
|
171
|
+
if (Array.isArray(value)) {
|
|
172
|
+
return value.map(canonicalize);
|
|
173
|
+
}
|
|
174
|
+
if (!isPlainObject(value)) {
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
const result = {};
|
|
178
|
+
for (const key of Object.keys(value).sort()) {
|
|
179
|
+
const child = value[key];
|
|
180
|
+
if (child !== undefined) {
|
|
181
|
+
result[key] = canonicalize(child);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
function isPlainObject(value) {
|
|
187
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
const prototype = Object.getPrototypeOf(value);
|
|
191
|
+
return prototype === Object.prototype || prototype === null;
|
|
192
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { AckReplySignal, ClassifierOutput, CustomClassifierOutput, FinalReplySignal, RoutingSignal, RuntimeClassifierManifest, SafetySignal, ToolsSignal } from "./stock.js";
|
|
2
|
+
import type { ClassifierInput, ClassifierRunStatus } from "./types.js";
|
|
3
|
+
import type { DownstreamModelTier, ModelSpecialization } from "./enums.js";
|
|
4
|
+
export type ClassifierName = string;
|
|
5
|
+
export type ClassifierResults = Record<ClassifierName, ClassifierOutput>;
|
|
6
|
+
export type RunClassifier = (name: ClassifierName, input: ClassifierInput, signal: AbortSignal) => Promise<ClassifierOutput>;
|
|
7
|
+
export interface CatalogEntry {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly specializations: ReadonlyArray<ModelSpecialization>;
|
|
10
|
+
readonly tier: DownstreamModelTier;
|
|
11
|
+
readonly params_in_billions: number | null;
|
|
12
|
+
readonly context_window: number;
|
|
13
|
+
readonly input_tokens_cpm?: number;
|
|
14
|
+
readonly cached_tokens_cpm?: number;
|
|
15
|
+
readonly output_tokens_cpm?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface Catalog {
|
|
18
|
+
readonly models: ReadonlyArray<CatalogEntry>;
|
|
19
|
+
readonly default: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ModelRecommendationResolution {
|
|
22
|
+
readonly constraints_used: Partial<{
|
|
23
|
+
specialization: ModelSpecialization;
|
|
24
|
+
tier: DownstreamModelTier;
|
|
25
|
+
}>;
|
|
26
|
+
readonly constraints_dropped: ReadonlyArray<{
|
|
27
|
+
readonly axis: "specialization" | "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 safety?: SafetySignal;
|
|
50
|
+
readonly custom_outputs: ReadonlyArray<CustomClassifierOutput>;
|
|
51
|
+
readonly model_recommendation: ModelRecommendation;
|
|
52
|
+
}
|
|
53
|
+
export type ClassifierCustomOutputs = Record<string, unknown>;
|
|
54
|
+
export interface DownstreamTargetMessage {
|
|
55
|
+
readonly role: "user";
|
|
56
|
+
readonly text: string;
|
|
57
|
+
readonly hash: string;
|
|
58
|
+
}
|
|
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 PipelineMeta {
|
|
69
|
+
readonly classifiers: Record<string, ClassifierEntry>;
|
|
70
|
+
}
|
|
71
|
+
export interface PipelineAudit extends Envelope {
|
|
72
|
+
readonly meta: PipelineMeta;
|
|
73
|
+
readonly fired_by?: string;
|
|
74
|
+
}
|
|
75
|
+
export type AnswerPipelineResult = {
|
|
76
|
+
readonly action: "answer";
|
|
77
|
+
readonly message_id: string;
|
|
78
|
+
readonly final_reply: FinalReplySignal;
|
|
79
|
+
readonly reason: "already_answered";
|
|
80
|
+
readonly classifier_outputs: ClassifierCustomOutputs;
|
|
81
|
+
readonly audit: Pick<PipelineAudit, "final_reply" | "meta" | "fired_by">;
|
|
82
|
+
};
|
|
83
|
+
export type BlockPipelineResult = {
|
|
84
|
+
readonly action: "block";
|
|
85
|
+
readonly message_id: string;
|
|
86
|
+
readonly reason: {
|
|
87
|
+
readonly risk_level?: SafetySignal["risk_level"];
|
|
88
|
+
readonly signals?: ReadonlyArray<string>;
|
|
89
|
+
};
|
|
90
|
+
readonly classifier_outputs: ClassifierCustomOutputs;
|
|
91
|
+
readonly audit: Pick<PipelineAudit, "safety" | "meta" | "fired_by">;
|
|
92
|
+
};
|
|
93
|
+
export type NeedsReviewPipelineResult = {
|
|
94
|
+
readonly action: "needs_review";
|
|
95
|
+
readonly message_id: string;
|
|
96
|
+
readonly fired_by: string;
|
|
97
|
+
readonly reason: {
|
|
98
|
+
readonly risk_level?: SafetySignal["risk_level"];
|
|
99
|
+
readonly signals?: ReadonlyArray<string>;
|
|
100
|
+
};
|
|
101
|
+
readonly classifier_outputs: ClassifierCustomOutputs;
|
|
102
|
+
readonly audit: Pick<PipelineAudit, "safety" | "meta" | "fired_by">;
|
|
103
|
+
};
|
|
104
|
+
export type RoutePipelineResult = {
|
|
105
|
+
readonly action: "route";
|
|
106
|
+
readonly message_id: string;
|
|
107
|
+
readonly downstream: DownstreamPayload;
|
|
108
|
+
readonly classifier_outputs: ClassifierCustomOutputs;
|
|
109
|
+
readonly audit: PipelineAudit;
|
|
110
|
+
};
|
|
111
|
+
export type PipelineResult = AnswerPipelineResult | BlockPipelineResult | NeedsReviewPipelineResult | RoutePipelineResult;
|
|
112
|
+
export interface AggregatorConfig {
|
|
113
|
+
readonly confidenceThreshold?: number;
|
|
114
|
+
}
|
|
115
|
+
export type ClassifierRegistry = ReadonlyArray<RuntimeClassifierManifest>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type ClassifierName, type RunClassifier } from "./classifiers.js";
|
|
2
|
+
import { type OpenClassifyConfig } from "./config.js";
|
|
3
|
+
import { classifyOpenClassifyInput } from "./pipeline.js";
|
|
4
|
+
import type { Catalog } from "./manifest.js";
|
|
5
|
+
import type { OpenClassifyInput } from "./types.js";
|
|
6
|
+
export declare const OLLAMA_DEFAULT_HOST = "http://localhost:11434";
|
|
7
|
+
export declare const OLLAMA_BASE_MODEL = "gemma4:e4b-it-q4_K_M";
|
|
8
|
+
export declare const OLLAMA_BASE_MODEL_NATIVE_CONTEXT_LENGTH = 131072;
|
|
9
|
+
export declare const OLLAMA_REQUIRED_PARALLELISM: number;
|
|
10
|
+
export declare const OLLAMA_DEFAULT_CATALOG_PATH = "downstream-models.json";
|
|
11
|
+
export declare const OLLAMA_CONTEXT_LENGTH = 4096;
|
|
12
|
+
export declare const OLLAMA_MIN_TOTAL_MEMORY_BYTES: number;
|
|
13
|
+
export declare const OLLAMA_MIN_AVAILABLE_MEMORY_BYTES: number;
|
|
14
|
+
export declare const OLLAMA_CLASSIFIER_MODELS: Record<ClassifierName, string | null>;
|
|
15
|
+
export interface OllamaOptions {
|
|
16
|
+
temperature?: number;
|
|
17
|
+
top_p?: number;
|
|
18
|
+
seed?: number;
|
|
19
|
+
num_ctx?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface OllamaClassifierRunnerConfig {
|
|
22
|
+
host?: string;
|
|
23
|
+
defaultModel?: string;
|
|
24
|
+
models?: Partial<Record<ClassifierName, string | null>>;
|
|
25
|
+
options?: OllamaOptions;
|
|
26
|
+
fetch?: typeof fetch;
|
|
27
|
+
skipResourceCheck?: boolean;
|
|
28
|
+
minAvailableMemoryBytes?: number;
|
|
29
|
+
minTotalMemoryBytes?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface ClassifyWithOllamaConfig extends OllamaClassifierRunnerConfig {
|
|
32
|
+
catalog?: Catalog;
|
|
33
|
+
catalogPath?: string;
|
|
34
|
+
configPath?: string;
|
|
35
|
+
openClassifyConfig?: OpenClassifyConfig;
|
|
36
|
+
}
|
|
37
|
+
export declare class OllamaClassifierError extends Error {
|
|
38
|
+
readonly classifier: ClassifierName;
|
|
39
|
+
readonly model: string;
|
|
40
|
+
constructor(classifier: ClassifierName, model: string, message: string, cause?: unknown);
|
|
41
|
+
}
|
|
42
|
+
export declare class OllamaResourceError extends Error {
|
|
43
|
+
readonly totalMemoryBytes: number;
|
|
44
|
+
readonly availableMemoryBytes: number;
|
|
45
|
+
readonly minTotalMemoryBytes: number;
|
|
46
|
+
readonly minAvailableMemoryBytes: number;
|
|
47
|
+
constructor(totalMemoryBytes: number, availableMemoryBytes: number, minTotalMemoryBytes: number, minAvailableMemoryBytes: number);
|
|
48
|
+
}
|
|
49
|
+
export declare function createOllamaClassifierRunner(config?: OllamaClassifierRunnerConfig): RunClassifier;
|
|
50
|
+
export declare function assertOllamaResources(options?: {
|
|
51
|
+
minTotalMemoryBytes?: number;
|
|
52
|
+
minAvailableMemoryBytes?: number;
|
|
53
|
+
}): Promise<void>;
|
|
54
|
+
export declare function classifyWithOllama(input: OpenClassifyInput, config?: ClassifyWithOllamaConfig): ReturnType<typeof classifyOpenClassifyInput>;
|