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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +290 -0
  3. package/dist/src/aggregator.d.ts +18 -0
  4. package/dist/src/aggregator.js +267 -0
  5. package/dist/src/catalog.d.ts +7 -0
  6. package/dist/src/catalog.js +189 -0
  7. package/dist/src/classifiers/custom/conversation_diegest/manifest.json +28 -0
  8. package/dist/src/classifiers/custom/conversation_diegest/prompt.md +7 -0
  9. package/dist/src/classifiers/custom/memory_retrieval_queries/manifest.json +29 -0
  10. package/dist/src/classifiers/custom/memory_retrieval_queries/prompt.md +5 -0
  11. package/dist/src/classifiers/stock/model_specialization/manifest.json +8 -0
  12. package/dist/src/classifiers/stock/preflight/manifest.json +8 -0
  13. package/dist/src/classifiers/stock/prompts/base.md +1 -0
  14. package/dist/src/classifiers/stock/prompts/classifier-header.md +4 -0
  15. package/dist/src/classifiers/stock/prompts/confidence.md +3 -0
  16. package/dist/src/classifiers/stock/prompts/custom-output.md +1 -0
  17. package/dist/src/classifiers/stock/prompts/model_specialization.md +7 -0
  18. package/dist/src/classifiers/stock/prompts/preflight-output.md +10 -0
  19. package/dist/src/classifiers/stock/prompts/preflight.md +47 -0
  20. package/dist/src/classifiers/stock/prompts/reason.md +3 -0
  21. package/dist/src/classifiers/stock/prompts/routing-output.md +5 -0
  22. package/dist/src/classifiers/stock/prompts/routing.md +9 -0
  23. package/dist/src/classifiers/stock/prompts/security-output.md +8 -0
  24. package/dist/src/classifiers/stock/prompts/security.md +26 -0
  25. package/dist/src/classifiers/stock/prompts/specialty.md +10 -0
  26. package/dist/src/classifiers/stock/prompts/tier.md +7 -0
  27. package/dist/src/classifiers/stock/prompts/tools-output.md +7 -0
  28. package/dist/src/classifiers/stock/prompts/tools.md +10 -0
  29. package/dist/src/classifiers/stock/routing/manifest.json +8 -0
  30. package/dist/src/classifiers/stock/security/manifest.json +12 -0
  31. package/dist/src/classifiers/stock/tools/manifest.json +19 -0
  32. package/dist/src/classifiers.d.ts +14 -0
  33. package/dist/src/classifiers.js +87 -0
  34. package/dist/src/config.d.ts +29 -0
  35. package/dist/src/config.js +144 -0
  36. package/dist/src/enums.d.ts +10 -0
  37. package/dist/src/enums.js +62 -0
  38. package/dist/src/index.d.ts +13 -0
  39. package/dist/src/index.js +18 -0
  40. package/dist/src/input.d.ts +4 -0
  41. package/dist/src/input.js +192 -0
  42. package/dist/src/manifest.d.ts +115 -0
  43. package/dist/src/manifest.js +1 -0
  44. package/dist/src/ollama.d.ts +54 -0
  45. package/dist/src/ollama.js +293 -0
  46. package/dist/src/pipeline.d.ts +17 -0
  47. package/dist/src/pipeline.js +274 -0
  48. package/dist/src/stock-prompt.d.ts +2 -0
  49. package/dist/src/stock-prompt.js +63 -0
  50. package/dist/src/stock-validation.d.ts +22 -0
  51. package/dist/src/stock-validation.js +329 -0
  52. package/dist/src/stock.d.ts +101 -0
  53. package/dist/src/stock.js +14 -0
  54. package/dist/src/types.d.ts +34 -0
  55. package/dist/src/types.js +6 -0
  56. package/dist/src/ui-server.d.ts +1 -0
  57. package/dist/src/ui-server.js +250 -0
  58. package/dist/src/validation.d.ts +17 -0
  59. package/dist/src/validation.js +127 -0
  60. package/open-classify.config.example.json +24 -0
  61. package/package.json +56 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Taylor Bayouth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,290 @@
1
+ <p align="center">
2
+ <img src="open-classify-logo.png" alt="Open Classify" width="220">
3
+ </p>
4
+
5
+ <p align="center">
6
+ Decide what should happen to a user message <em>before</em> it reaches your downstream model.
7
+ </p>
8
+
9
+ Open Classify is a pre-routing layer for AI products. It runs a small set of fast classifiers in parallel against the latest user message, then tells your app one of four things: **route** it, **answer** it immediately, **block** it, or flag it for **review**.
10
+
11
+ Use it when your frontier model should not be the first thing every request touches. Open Classify can handle tiny terminal replies before they hit an expensive model, recommend the right downstream model for the actual task, suggest what tools or context the downstream model should receive, and add a safety pass for prompt injection and permission-boundary risk.
12
+
13
+ The result is a small, auditable decision envelope your app can act on before spending the big tokens.
14
+
15
+ ```
16
+ message
17
+
18
+
19
+ normalize + trim classifier context
20
+
21
+ ├─► preflight ─────────────► final_reply? / ack_reply?
22
+ ├─► routing ───────────────► model_tier?
23
+ ├─► model_specialization ──► specialization?
24
+ ├─► tools ─────────────────► tools?
25
+ ├─► security ──────────────► safety verdict
26
+ └─► custom classifiers ────► JSON-Schema output
27
+ (run in parallel)
28
+
29
+
30
+ aggregator + model catalog
31
+
32
+
33
+ route / answer / block / needs_review
34
+ ```
35
+
36
+ Stock classifiers have fixed typed signals. Custom classifiers carry their own JSON-Schema-validated payload. The aggregator merges everything, resolves a concrete model from your catalog, and short-circuits when preflight has a final answer or security flags risk.
37
+
38
+ ## Why Open Classify
39
+
40
+ - **Spend frontier tokens only when they matter.** Simple greetings, thanks, spelling checks, and small arithmetic can return `action: "answer"` with a `final_reply` and skip downstream work entirely.
41
+ - **Keep the user interface responsive.** For complex work, preflight can return an `ack_reply` while your app routes the request to the real worker.
42
+ - **Pick the right model per message.** Classifiers emit soft constraints like tier and specialization; your catalog turns those into a concrete model optimized for cost, capability, and fit.
43
+ - **Shape downstream context intentionally.** Built-in and custom classifiers can recommend tools, retrieval queries, summaries, or other context hints without passing the full conversation history back to the caller.
44
+ - **Add another defensive layer.** The security classifier can block or require review for prompt injection, secret exposure risk, unsafe tool use, and related boundary violations.
45
+
46
+ ## Install
47
+
48
+ ```sh
49
+ npm install open-classify
50
+ ```
51
+
52
+ Node 18+. The packaged runner is local Ollama and ships with `gemma4:e4b-it-q4_K_M` as the zero-config classifier model. That runner is configurable through `open-classify.config.json`; arbitrary backends are supported in code by implementing `RunClassifier`.
53
+
54
+ ## Hello World
55
+
56
+ ```ts
57
+ import { classifyWithOllama, loadCatalog } from "open-classify";
58
+
59
+ const result = await classifyWithOllama(
60
+ {
61
+ messages: [
62
+ { role: "user", text: "Can you review the attached contract?" },
63
+ ],
64
+ },
65
+ { catalog: loadCatalog("downstream-models.json") },
66
+ );
67
+
68
+ if (result.action === "route") {
69
+ // result.downstream.model_id is a concrete model from your catalog.
70
+ // result.downstream.tools is the recommended tool exposure.
71
+ // result.classifier_outputs holds any custom classifier payloads.
72
+ }
73
+ ```
74
+
75
+ ## What you get back
76
+
77
+ Every call returns a `PipelineResult` with one of four `action` values:
78
+
79
+ | `action` | When | Key fields |
80
+ |---|---|---|
81
+ | `route` | Default — downstream work should continue | `downstream.{model_id, target_message, tools}`, `audit.ack_reply?` |
82
+ | `answer` | Preflight had a tiny terminal reply | `final_reply` |
83
+ | `block` | Security flagged `decision: "block"` (with `high_risk`) | `reason.{risk_level, signals}` |
84
+ | `needs_review` | Security flagged `decision: "needs_review"` | `reason.{risk_level, signals}` |
85
+
86
+ All four also carry `message_id`, `classifier_outputs` (custom classifier payloads, keyed by name), and an `audit` block. Route results include the downstream target message, not the caller's message history. Short-circuit results include the firing classifier's audit context.
87
+
88
+ For complex requests, look for `audit.ack_reply` on `route` results. It is the immediate acknowledgement your UI can show while the downstream model works. For trivial requests, `result.final_reply.reply` is the complete response and no downstream model is needed.
89
+
90
+ Example `route` result:
91
+
92
+ ```json
93
+ {
94
+ "action": "route",
95
+ "message_id": "b11d5268",
96
+ "downstream": {
97
+ "model_id": "gpt-5.5",
98
+ "tools": { "tools": ["workspace"] },
99
+ "target_message": { "role": "user", "text": "...", "hash": "b11d5268" }
100
+ },
101
+ "classifier_outputs": {
102
+ "memory_retrieval_queries": { "queries": ["user code review preferences"] }
103
+ },
104
+ "audit": {
105
+ "ack_reply": { "reply": "Let me check." },
106
+ "routing": { "model_tier": "frontier_strong" },
107
+ "model_specialization": { "specialization": "coding" },
108
+ "tools": { "tools": ["workspace"] },
109
+ "model_recommendation": {
110
+ "id": "gpt-5.5",
111
+ "context_window": 1050000,
112
+ "input_tokens_cpm": 5,
113
+ "cached_tokens_cpm": 0.5,
114
+ "output_tokens_cpm": 30,
115
+ "resolution": { "...": "..." }
116
+ },
117
+ "meta": { "classifiers": { "...": "..." } }
118
+ }
119
+ }
120
+ ```
121
+
122
+ ## Stock classifiers
123
+
124
+ Stock classifiers are built in and have fixed, typed output shapes. Each one owns exactly one signal. Its manifest lives in `src/classifiers/stock/<name>/manifest.json`; the shared stock prompt building blocks live in `src/classifiers/stock/prompts/`.
125
+
126
+ Every classifier prompt includes a shared header with its `Classifier` name, `Purpose`, and an instruction to treat that purpose as a hard scope boundary. In practice:
127
+
128
+ - `routing` chooses only `model_tier`
129
+ - `model_specialization` chooses only `specialization`
130
+ - `security` is only for safety and permission-boundary risk, not contradiction, feasibility, or freshness checks
131
+
132
+ | Name | Signal | Short-circuits? |
133
+ |---|---|---|
134
+ | `preflight` | `final_reply?` / `ack_reply?` | `final_reply` → `answer` |
135
+ | `routing` | `model_tier?` | no |
136
+ | `model_specialization` | `specialization?` | no |
137
+ | `tools` | `{ tools[] }` | no |
138
+ | `security` | `{ decision?, risk_level, signals[] }` | `decision: "block"` → `block`, `"needs_review"` → `needs_review` |
139
+
140
+ Each output may also carry optional `reason` (≤120 chars) and `confidence` (0–1). Below-threshold signals are dropped from aggregation; the default threshold is `0.6`.
141
+
142
+ ## Custom classifiers
143
+
144
+ A custom classifier is two files in `src/classifiers/custom/<name>/`:
145
+
146
+ `manifest.json`:
147
+
148
+ ```json
149
+ {
150
+ "kind": "custom",
151
+ "name": "memory_retrieval_queries",
152
+ "version": "1.0.0",
153
+ "purpose": "Generate retrieval queries likely to surface helpful user-specific context for the downstream model.",
154
+ "order": 60,
155
+ "fallback": { "output": { "queries": [] } },
156
+ "output_schema": {
157
+ "type": "object",
158
+ "additionalProperties": false,
159
+ "required": ["queries"],
160
+ "properties": {
161
+ "queries": {
162
+ "type": "array", "maxItems": 5,
163
+ "items": { "type": "string", "minLength": 1, "maxLength": 120 }
164
+ }
165
+ }
166
+ }
167
+ }
168
+ ```
169
+
170
+ `prompt.md`: your classifier-specific instructions.
171
+
172
+ Custom classifiers receive the same shared `Classifier` + `Purpose` header and the same scope-boundary instruction, so keep the manifest `purpose` specific and operational.
173
+
174
+ The runtime auto-discovers it, validates outputs against your schema, and surfaces them on `result.classifier_outputs.<name>`. No TypeScript edits required.
175
+
176
+ See [docs/adding-a-classifier.md](docs/adding-a-classifier.md) for a full walkthrough.
177
+
178
+ ## Model catalog
179
+
180
+ Classifiers never emit model ids. They emit constraints; your catalog maps constraints to concrete models.
181
+
182
+ ```json
183
+ {
184
+ "models": [
185
+ {
186
+ "id": "gpt-5.5",
187
+ "provider": "openai",
188
+ "runtime": "api",
189
+ "specializations": [
190
+ "chat",
191
+ "writing",
192
+ "reasoning",
193
+ "planning",
194
+ "coding",
195
+ "instruction_following",
196
+ "agentic_workflows"
197
+ ],
198
+ "tier": "frontier_strong",
199
+ "params_in_billions": null,
200
+ "context_window": 1050000,
201
+ "max_output_tokens": 128000,
202
+ "input_tokens_cpm": 5,
203
+ "cached_tokens_cpm": 0.5,
204
+ "output_tokens_cpm": 30
205
+ }
206
+ ],
207
+ "default": "gpt-5.5",
208
+ "pricing_unit": "USD per 1M tokens"
209
+ }
210
+ ```
211
+
212
+ OpenAI's GPT-5.5 model page lists text and image input, text output, a 1,050,000-token context window, 128,000 max output tokens, and text-token pricing of $5.00 input, $0.50 cached input, and $30.00 output per 1M tokens. OpenAI does not publish parameter counts, so use `null` for `params_in_billions`. See the [GPT-5.5 model details](https://developers.openai.com/api/docs/models/gpt-5.5) for current pricing and capability details.
213
+
214
+ The resolver picks the cheapest model matching `specialization` and `tier`, relaxing constraints in order when nothing fits, and reports what it dropped on `audit.model_recommendation.resolution`. See [docs/resolver.md](docs/resolver.md) for ranking details.
215
+
216
+ ## Input contract
217
+
218
+ `classifyWithOllama({ messages })` — that's the whole input.
219
+
220
+ - `messages` is chronological, oldest to newest, and must end with the user message you want classified.
221
+ - Open Classify keeps whole messages only, drops oldest first to fit a 5,000-char budget, and caps history at 20 messages.
222
+ - Unknown fields are rejected, not passed through.
223
+
224
+ ## Local workbench
225
+
226
+ ```sh
227
+ npm run setup
228
+ npm run start
229
+ ```
230
+
231
+ UI opens at `http://127.0.0.1:4317/`. Classifier cards use classifier names from the runtime, displayed with underscores as spaces; result rendering remains generic.
232
+
233
+ Optional Ollama runtime config:
234
+
235
+ ```sh
236
+ cp open-classify.config.example.json open-classify.config.json
237
+ ```
238
+
239
+ ```json
240
+ {
241
+ "runner": {
242
+ "provider": "ollama",
243
+ "defaultModel": "gemma4:e4b-it-q4_K_M",
244
+ "models": {
245
+ "stock": {
246
+ "routing": "qwen2.5:7b-instruct-q4_K_M",
247
+ "security": "llama-guard3:8b"
248
+ },
249
+ "custom": {
250
+ "memory_retrieval_queries": "qwen2.5:7b-instruct-q4_K_M"
251
+ }
252
+ }
253
+ },
254
+ "catalog": "downstream-models.json"
255
+ }
256
+ ```
257
+
258
+ `runner.provider` currently supports `"ollama"` only. `runner.defaultModel` applies to any classifier without an explicit entry. `runner.models.stock` configures built-in classifiers; `runner.models.custom` configures custom classifiers by manifest name. The setup and start scripts read `open-classify.config.json`, or `OPEN_CLASSIFY_CONFIG` when you want a different path.
259
+
260
+ ## Bring your own backend
261
+
262
+ The Ollama runner is one implementation of a single function:
263
+
264
+ ```ts
265
+ type RunClassifier = (
266
+ name: string,
267
+ input: ClassifierInput,
268
+ signal: AbortSignal,
269
+ ) => Promise<ClassifierOutput>;
270
+ ```
271
+
272
+ Pass any `RunClassifier` to `classifyOpenClassifyInput(input, { runClassifier, catalog })` to back classifiers with OpenAI, Anthropic, a remote service, or anything else. This is a code-level extension point, separate from the Ollama-only config file runner.
273
+
274
+ ## Further reading
275
+
276
+ - [docs/signals.md](docs/signals.md) — full signal contracts and validation rules
277
+ - [docs/manifests.md](docs/manifests.md) — manifest reference (stock and custom)
278
+ - [docs/resolver.md](docs/resolver.md) — aggregation and model resolution
279
+ - [docs/adding-a-classifier.md](docs/adding-a-classifier.md) — author guide
280
+
281
+ ## Development
282
+
283
+ ```sh
284
+ npm test # build + run the Node test runner suite
285
+ npm run ui # build + serve the local workbench
286
+ ```
287
+
288
+ ## Screenshot
289
+
290
+ ![Open Classify local workbench](open-classify-screenshot.png)
@@ -0,0 +1,18 @@
1
+ import type { AggregatorConfig, Catalog, ClassifierRegistry, ClassifierResults, Envelope, ModelRecommendation, ModelRecommendationResolution } from "./manifest.js";
2
+ import type { AckReplySignal, ModelSpecializationClassifierOutput, FinalReplySignal, RoutingClassifierOutput, RoutingSignal } from "./stock.js";
3
+ import type { ClassifierInput } from "./types.js";
4
+ export declare const DEFAULT_CONFIDENCE_THRESHOLD = 0.6;
5
+ export interface ComposeEnvelopeArgs {
6
+ readonly registry: ClassifierRegistry;
7
+ readonly results: ClassifierResults;
8
+ readonly catalog: Catalog;
9
+ readonly input: ClassifierInput;
10
+ readonly config?: AggregatorConfig;
11
+ }
12
+ export declare function composeEnvelope(args: ComposeEnvelopeArgs): Envelope;
13
+ export declare function resolveModelFromRouting(routing: RoutingSignal | undefined, catalog: Catalog, confidence: number | undefined, ignoredConstraints?: ModelRecommendationResolution["constraints_dropped"]): ModelRecommendation;
14
+ export declare function resolveModel(results: Readonly<{
15
+ routing?: RoutingClassifierOutput;
16
+ model_specialization?: ModelSpecializationClassifierOutput;
17
+ }>, catalog: Catalog, threshold: number): ModelRecommendation;
18
+ export type { FinalReplySignal, AckReplySignal };
@@ -0,0 +1,267 @@
1
+ import { isCustomManifest, isStockManifest } from "./stock.js";
2
+ export const DEFAULT_CONFIDENCE_THRESHOLD = 0.6;
3
+ export function composeEnvelope(args) {
4
+ const { registry, results, catalog, config } = args;
5
+ const threshold = config?.confidenceThreshold ?? DEFAULT_CONFIDENCE_THRESHOLD;
6
+ const stockByName = stockResultsByName(registry, results);
7
+ const preflight = stockByName.preflight;
8
+ const routing = stockByName.routing;
9
+ const modelSpec = stockByName.model_specialization;
10
+ const tools = stockByName.tools;
11
+ const security = stockByName.security;
12
+ const preflightConfident = isConfident(preflight, threshold);
13
+ const finalReply = preflightConfident ? preflight?.final_reply : undefined;
14
+ const ackReply = preflightConfident ? preflight?.ack_reply : undefined;
15
+ const mergedRouting = mergeRouting(routing, modelSpec, threshold);
16
+ const lowConfidenceDrops = lowConfidenceRoutingDrops(routing, modelSpec, mergedRouting, threshold);
17
+ const toolsSignal = isConfident(tools, threshold) ? extractToolsSignal(tools) : undefined;
18
+ const safety = isConfident(security, threshold) ? extractSafetySignal(security) : undefined;
19
+ const envelope = {
20
+ ...optional("final_reply", finalReply),
21
+ ...optional("ack_reply", ackReply),
22
+ ...optional("routing", mergedRouting),
23
+ ...optional("tools", toolsSignal),
24
+ ...optional("safety", safety),
25
+ custom_outputs: customOutputs(registry, results),
26
+ model_recommendation: resolveModelFromRouting(mergedRouting, catalog, routingMaxConfidence(routing, modelSpec), lowConfidenceDrops),
27
+ };
28
+ return envelope;
29
+ }
30
+ function optional(key, value) {
31
+ return value === undefined ? {} : { [key]: value };
32
+ }
33
+ function stockResultsByName(registry, results) {
34
+ const map = {};
35
+ for (const manifest of registry) {
36
+ if (!isStockManifest(manifest))
37
+ continue;
38
+ const result = results[manifest.name];
39
+ if (result !== undefined) {
40
+ map[manifest.name] = result;
41
+ }
42
+ }
43
+ return map;
44
+ }
45
+ function isConfident(result, threshold) {
46
+ if (!result)
47
+ return false;
48
+ return (result.confidence ?? 0) >= threshold;
49
+ }
50
+ function mergeRouting(routing, modelSpec, threshold) {
51
+ const tier = pickConfidentAxis([
52
+ ["routing", routing, routing?.model_tier],
53
+ ], threshold);
54
+ const specialization = pickConfidentAxis([
55
+ ["model_specialization", modelSpec, modelSpec?.specialization],
56
+ ], threshold);
57
+ if (tier === undefined && specialization === undefined)
58
+ return undefined;
59
+ return {
60
+ ...(tier === undefined ? {} : { model_tier: tier }),
61
+ ...(specialization === undefined ? {} : { specialization }),
62
+ };
63
+ }
64
+ function pickConfidentAxis(candidates, threshold) {
65
+ let best;
66
+ for (const [, source, value] of candidates) {
67
+ if (value === undefined)
68
+ continue;
69
+ if (!isConfident(source, threshold))
70
+ continue;
71
+ const confidence = source.confidence ?? 0;
72
+ if (best === undefined || confidence > best.confidence) {
73
+ best = { value, confidence };
74
+ }
75
+ }
76
+ return best?.value;
77
+ }
78
+ function routingMaxConfidence(routing, modelSpec) {
79
+ const values = [routing?.confidence, modelSpec?.confidence].filter((v) => typeof v === "number");
80
+ if (values.length === 0)
81
+ return undefined;
82
+ return Math.max(...values);
83
+ }
84
+ function extractToolsSignal(result) {
85
+ return { tools: result.tools };
86
+ }
87
+ function extractSafetySignal(result) {
88
+ return {
89
+ ...(result.decision === undefined ? {} : { decision: result.decision }),
90
+ risk_level: result.risk_level,
91
+ signals: result.signals,
92
+ };
93
+ }
94
+ function customOutputs(registry, results) {
95
+ const out = [];
96
+ for (const manifest of registry) {
97
+ if (!isCustomManifest(manifest))
98
+ continue;
99
+ const result = results[manifest.name];
100
+ if (result === undefined)
101
+ continue;
102
+ out.push({
103
+ classifier: manifest.name,
104
+ ...(result.reason === undefined ? {} : { reason: result.reason }),
105
+ ...(result.confidence === undefined ? {} : { confidence: result.confidence }),
106
+ output: result.output,
107
+ });
108
+ }
109
+ return out;
110
+ }
111
+ // ─── Model recommendation ───────────────────────────────────────────────────
112
+ function lowConfidenceRoutingDrops(routing, modelSpec, merged, threshold) {
113
+ const dropped = [];
114
+ if (merged?.specialization === undefined) {
115
+ if (hasLowConfidenceAxis(routing, "specialization", threshold) ||
116
+ hasLowConfidenceAxis(modelSpec, "specialization", threshold)) {
117
+ dropped.push({ axis: "specialization", reason: "low_confidence" });
118
+ }
119
+ }
120
+ if (merged?.model_tier === undefined) {
121
+ if (hasLowConfidenceAxis(routing, "model_tier", threshold) ||
122
+ hasLowConfidenceAxis(modelSpec, "model_tier", threshold)) {
123
+ dropped.push({ axis: "tier", reason: "low_confidence" });
124
+ }
125
+ }
126
+ return dropped;
127
+ }
128
+ function hasLowConfidenceAxis(result, field, threshold) {
129
+ if (!result)
130
+ return false;
131
+ if (result[field] === undefined)
132
+ return false;
133
+ return (result.confidence ?? 0) < threshold;
134
+ }
135
+ export function resolveModelFromRouting(routing, catalog, confidence, ignoredConstraints = []) {
136
+ const requested = {};
137
+ const confidences = {};
138
+ if (confidence !== undefined) {
139
+ confidences.routing = confidence;
140
+ }
141
+ if (routing?.specialization !== undefined)
142
+ requested.specialization = routing.specialization;
143
+ if (routing?.model_tier !== undefined)
144
+ requested.tier = routing.model_tier;
145
+ const passes = [
146
+ { useSpecialization: true, useTier: true },
147
+ { useSpecialization: true, useTier: false },
148
+ { useSpecialization: false, useTier: true },
149
+ { useSpecialization: false, useTier: false },
150
+ ];
151
+ for (const pass of passes) {
152
+ const constraints_used = constraintsForPass(requested, pass);
153
+ const matching = catalog.models.filter((model) => matchesConstraints(model, constraints_used));
154
+ if (matching.length === 0)
155
+ continue;
156
+ const winner = pickBestModel(matching, catalog.models);
157
+ return {
158
+ ...modelRecommendationFields(winner),
159
+ resolution: {
160
+ constraints_used,
161
+ constraints_dropped: [
162
+ ...ignoredConstraints,
163
+ ...relaxedConstraints(requested, constraints_used),
164
+ ],
165
+ confidences,
166
+ fell_back_to_default: false,
167
+ },
168
+ };
169
+ }
170
+ const fallback = catalog.models.find((model) => model.id === catalog.default);
171
+ if (!fallback) {
172
+ throw new Error(`catalog default "${catalog.default}" not found in models — catalog skipped validation`);
173
+ }
174
+ return {
175
+ ...modelRecommendationFields(fallback),
176
+ resolution: {
177
+ constraints_used: {},
178
+ constraints_dropped: [
179
+ ...ignoredConstraints,
180
+ ...defaultFallbackConstraints(requested),
181
+ ],
182
+ confidences,
183
+ fell_back_to_default: true,
184
+ },
185
+ };
186
+ }
187
+ // Test-friendly convenience wrapper: builds a routing signal from a typed
188
+ // results map and resolves a model. Mirrors `composeEnvelope` for callers
189
+ // that want just the model recommendation without the rest of the envelope.
190
+ export function resolveModel(results, catalog, threshold) {
191
+ const routing = mergeRouting(results.routing, results.model_specialization, threshold);
192
+ return resolveModelFromRouting(routing, catalog, routingMaxConfidence(results.routing, results.model_specialization), lowConfidenceRoutingDrops(results.routing, results.model_specialization, routing, threshold));
193
+ }
194
+ function constraintsForPass(requested, pass) {
195
+ return {
196
+ ...(pass.useSpecialization && requested.specialization !== undefined
197
+ ? { specialization: requested.specialization }
198
+ : {}),
199
+ ...(pass.useTier && requested.tier !== undefined ? { tier: requested.tier } : {}),
200
+ };
201
+ }
202
+ function matchesConstraints(model, constraints) {
203
+ return ((constraints.specialization === undefined ||
204
+ model.specializations.includes(constraints.specialization)) &&
205
+ (constraints.tier === undefined || model.tier === constraints.tier));
206
+ }
207
+ function relaxedConstraints(requested, used) {
208
+ const dropped = [];
209
+ if (requested.specialization !== undefined && used.specialization === undefined) {
210
+ dropped.push({ axis: "specialization", reason: "no_match_relaxed" });
211
+ }
212
+ if (requested.tier !== undefined && used.tier === undefined) {
213
+ dropped.push({ axis: "tier", reason: "no_match_relaxed" });
214
+ }
215
+ return dropped;
216
+ }
217
+ function defaultFallbackConstraints(requested) {
218
+ const dropped = [];
219
+ if (requested.specialization !== undefined) {
220
+ dropped.push({ axis: "specialization", reason: "default_fallback" });
221
+ }
222
+ if (requested.tier !== undefined) {
223
+ dropped.push({ axis: "tier", reason: "default_fallback" });
224
+ }
225
+ return dropped;
226
+ }
227
+ function pickBestModel(candidates, catalogOrder) {
228
+ let winner = candidates[0];
229
+ for (let i = 1; i < candidates.length; i++) {
230
+ const candidate = candidates[i];
231
+ if (compareModels(candidate, winner, catalogOrder) < 0) {
232
+ winner = candidate;
233
+ }
234
+ }
235
+ return winner;
236
+ }
237
+ function compareModels(a, b, catalogOrder) {
238
+ const costDiff = priceIndex(a) - priceIndex(b);
239
+ if (Math.abs(costDiff) > Number.EPSILON)
240
+ return costDiff;
241
+ if (a.params_in_billions !== b.params_in_billions) {
242
+ return comparableParams(b) - comparableParams(a);
243
+ }
244
+ if (a.context_window !== b.context_window) {
245
+ return b.context_window - a.context_window;
246
+ }
247
+ return catalogOrder.indexOf(a) - catalogOrder.indexOf(b);
248
+ }
249
+ function priceIndex(model) {
250
+ if (model.input_tokens_cpm === undefined || model.output_tokens_cpm === undefined) {
251
+ return 0;
252
+ }
253
+ return model.input_tokens_cpm + model.output_tokens_cpm;
254
+ }
255
+ function comparableParams(model) {
256
+ return model.params_in_billions ?? 0;
257
+ }
258
+ function modelRecommendationFields(winner) {
259
+ return {
260
+ id: winner.id,
261
+ params_in_billions: winner.params_in_billions,
262
+ context_window: winner.context_window,
263
+ ...(winner.input_tokens_cpm === undefined ? {} : { input_tokens_cpm: winner.input_tokens_cpm }),
264
+ ...(winner.cached_tokens_cpm === undefined ? {} : { cached_tokens_cpm: winner.cached_tokens_cpm }),
265
+ ...(winner.output_tokens_cpm === undefined ? {} : { output_tokens_cpm: winner.output_tokens_cpm }),
266
+ };
267
+ }
@@ -0,0 +1,7 @@
1
+ import type { Catalog } from "./manifest.js";
2
+ export declare class CatalogError extends Error {
3
+ readonly path?: string;
4
+ constructor(message: string, path?: string);
5
+ }
6
+ export declare function loadCatalog(configPath: string): Catalog;
7
+ export declare function validateCatalog(value: unknown, path?: string): Catalog;