@juspay/neurolink 12.18.1 → 12.19.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/CHANGELOG.md +3 -3
- package/README.md +277 -75
- package/dist/browser/neurolink.min.js +396 -396
- package/dist/cli/commands/setup.js +2 -1
- package/dist/cli/factories/commandFactory.d.ts +22 -2
- package/dist/cli/factories/commandFactory.js +14 -2
- package/dist/cli/loop/optionsSchema.js +4 -0
- package/dist/cli/utils/classifierRouterFlags.js +39 -1
- package/dist/constants/enums.d.ts +13 -0
- package/dist/constants/enums.js +14 -0
- package/dist/context/budgetChecker.d.ts +3 -1
- package/dist/context/budgetChecker.js +20 -4
- package/dist/context/contextCompactor.d.ts +43 -3
- package/dist/context/contextCompactor.js +157 -41
- package/dist/context/contextDecision.d.ts +56 -0
- package/dist/context/contextDecision.js +234 -0
- package/dist/core/baseProvider.d.ts +27 -1
- package/dist/core/baseProvider.js +39 -0
- package/dist/core/toolRouting.d.ts +8 -0
- package/dist/core/toolRouting.js +52 -1
- package/dist/core/toolRoutingDecision.d.ts +35 -0
- package/dist/core/toolRoutingDecision.js +194 -0
- package/dist/factories/providerDescriptors.d.ts +15 -0
- package/dist/factories/providerDescriptors.js +57 -1
- package/dist/factories/providerRegistry.js +9 -1
- package/dist/index.d.ts +57 -1
- package/dist/index.js +60 -1
- package/dist/models/manifestRegistry.js +2 -0
- package/dist/models/manifests/typesafe.d.ts +16 -0
- package/dist/models/manifests/typesafe.js +42 -0
- package/dist/neurolink.d.ts +57 -1
- package/dist/neurolink.js +254 -10
- package/dist/observability/exporters/laminarExporter.js +3 -0
- package/dist/observability/exporters/posthogExporter.js +3 -0
- package/dist/observability/utils/spanSerializer.js +4 -0
- package/dist/providers/catalog/schema.d.ts +1 -1
- package/dist/providers/typesafe.d.ts +112 -0
- package/dist/providers/typesafe.js +654 -0
- package/dist/rag/pipeline/RAGPipeline.d.ts +3 -1
- package/dist/rag/pipeline/RAGPipeline.js +65 -4
- package/dist/rag/retrieval/searchDecision.d.ts +27 -0
- package/dist/rag/retrieval/searchDecision.js +124 -0
- package/dist/routing/classifierRouter.d.ts +74 -2
- package/dist/routing/classifierRouter.js +232 -32
- package/dist/routing/classifierStrategies.d.ts +33 -1
- package/dist/routing/classifierStrategies.js +237 -0
- package/dist/routing/index.d.ts +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/modelCatalog.d.ts +79 -0
- package/dist/routing/modelCatalog.js +382 -0
- package/dist/telemetry/tracers.d.ts +1 -0
- package/dist/telemetry/tracers.js +1 -0
- package/dist/types/classifierRouter.d.ts +160 -4
- package/dist/types/cli.d.ts +17 -1
- package/dist/types/config.d.ts +12 -0
- package/dist/types/context.d.ts +55 -1
- package/dist/types/decision.d.ts +200 -0
- package/dist/types/decision.js +21 -0
- package/dist/types/generate.d.ts +20 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/providers.d.ts +59 -1
- package/dist/types/providers.js +5 -0
- package/dist/types/rag.d.ts +48 -0
- package/dist/types/span.d.ts +11 -0
- package/dist/types/span.js +11 -0
- package/dist/types/stream.d.ts +13 -0
- package/dist/types/toolRouting.d.ts +56 -0
- package/dist/utils/decisionAnswers.d.ts +56 -0
- package/dist/utils/decisionAnswers.js +95 -0
- package/dist/utils/modelChoices.js +13 -1
- package/dist/utils/pricing.js +11 -0
- package/dist/utils/providerConfig.d.ts +4 -0
- package/dist/utils/providerConfig.js +17 -0
- package/docs-site/static/search-index.json +1 -1
- package/package.json +16 -3
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model catalogue — turning the registry into a routable pool.
|
|
3
|
+
*
|
|
4
|
+
* `ClassifierRouter` has always taken a host-declared `pool`, because the set
|
|
5
|
+
* of models a host is *willing to be billed for* is not something NeuroLink
|
|
6
|
+
* can invent. But the registry already knows 64 models across 7 providers
|
|
7
|
+
* (132 aliases), each carrying a context window, in/out pricing, speed and
|
|
8
|
+
* quality buckets, capability flags and seven 1–10 use-case scores — and
|
|
9
|
+
* routing read none of it beyond cost and quality. `maxContextTokens` in
|
|
10
|
+
* particular was never read anywhere.
|
|
11
|
+
*
|
|
12
|
+
* The registry covers 7 of the 40 registered providers, so the catalogue is
|
|
13
|
+
* an addition to a declared pool, never a replacement: a host routing over
|
|
14
|
+
* LiteLLM, OpenRouter or a self-hosted model still declares those by hand,
|
|
15
|
+
* and `enrichCandidate` ranks declared and catalogue members on one scale.
|
|
16
|
+
*
|
|
17
|
+
* This module closes that gap for hosts that opt in (`catalog.enabled`): it
|
|
18
|
+
* builds pool members from the registry, intersected with the credentials
|
|
19
|
+
* actually present, and renders each as one line for a decision model to rank.
|
|
20
|
+
*
|
|
21
|
+
* Two constraints shape everything here:
|
|
22
|
+
*
|
|
23
|
+
* 1. **The rendered catalogue is a question, not state.** A decision model's
|
|
24
|
+
* binding limit is `state + the single longest question`, and the model
|
|
25
|
+
* choice question's `criteria` map is that question. Rendering has to stay
|
|
26
|
+
* terse and the count has to stay capped.
|
|
27
|
+
* 2. **A deterministic ranker must exist first.** Every other decision
|
|
28
|
+
* consumer falls back to what the code did before, but there was no
|
|
29
|
+
* "pick from the whole registry" behaviour to fall back to.
|
|
30
|
+
* {@link rankCatalogue}
|
|
31
|
+
* is that fallback, and it runs whenever no decision provider is
|
|
32
|
+
* configured, the call fails, or the verdict is not confident enough.
|
|
33
|
+
*
|
|
34
|
+
* @module routing/modelCatalog
|
|
35
|
+
*/
|
|
36
|
+
import type { ClassifierCandidate, ClassifierCatalogConfig, ClassifierDifficulty, ClassifierRouterInput, ClassifierRouterPoolMember, ModelInfo } from "../types/index.js";
|
|
37
|
+
/** Provider names this host has credentials for AND that generate text. */
|
|
38
|
+
export declare function reachableTextProviders(): Set<string>;
|
|
39
|
+
/**
|
|
40
|
+
* Rank candidates deterministically for a difficulty. The fallback that makes
|
|
41
|
+
* the catalogue safe to enable: with no decision provider configured this is
|
|
42
|
+
* the whole selection, and it never consults the network.
|
|
43
|
+
*
|
|
44
|
+
* `requiredCapabilities` filters leniently — a candidate with no recorded
|
|
45
|
+
* capabilities is kept rather than starving the pool on missing metadata,
|
|
46
|
+
* matching `ClassifierRouter.filterByCapabilities`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function rankCatalogue(candidates: readonly ClassifierCandidate[], difficulty: ClassifierDifficulty, input?: Pick<ClassifierRouterInput, "estimatedInputTokens">, requiredCapabilities?: readonly string[]): ClassifierCandidate[];
|
|
49
|
+
/**
|
|
50
|
+
* Build catalogue pool members from the registry.
|
|
51
|
+
*
|
|
52
|
+
* Returns `[]` when the catalogue is disabled or nothing is reachable, which
|
|
53
|
+
* leaves the declared pool as the only source — the pre-catalogue behaviour.
|
|
54
|
+
*/
|
|
55
|
+
export declare function buildModelCatalog(config?: ClassifierCatalogConfig): ClassifierRouterPoolMember[];
|
|
56
|
+
/**
|
|
57
|
+
* Enrich a pool member into a classifier candidate, pulling registry metadata
|
|
58
|
+
* the member did not declare.
|
|
59
|
+
*
|
|
60
|
+
* Works for any pool, catalogue-derived or hand-declared: a host that listed
|
|
61
|
+
* `{ provider: "openai", model: "gpt-4o" }` gets the same context window and
|
|
62
|
+
* pricing as a catalogue entry, because both resolve through the registry.
|
|
63
|
+
* Members the registry does not know (LiteLLM, self-hosted) keep exactly what
|
|
64
|
+
* the host declared and are still rankable — `tierScore` falls back to
|
|
65
|
+
* neutral values rather than dropping them.
|
|
66
|
+
*/
|
|
67
|
+
export declare function enrichCandidate(id: string, member: ClassifierRouterPoolMember, difficulty: ClassifierDifficulty, registry?: Map<string, ModelInfo>): ClassifierCandidate;
|
|
68
|
+
/** Index the registry by model id once per build, for `enrichCandidate`. */
|
|
69
|
+
export declare function buildRegistryIndex(): Map<string, ModelInfo>;
|
|
70
|
+
/**
|
|
71
|
+
* Render one candidate as a single criteria line for the decision model.
|
|
72
|
+
*
|
|
73
|
+
* Terse on purpose: this text is multiplied by the candidate count inside a
|
|
74
|
+
* single question, and that question competes with the request for the
|
|
75
|
+
* ~33K ceiling. Numbers are rendered in units a reader can compare at a
|
|
76
|
+
* glance (K tokens, ¢ per 1K) because a decision model reads digits as text
|
|
77
|
+
* and does better with "128K" than with "131072".
|
|
78
|
+
*/
|
|
79
|
+
export declare function renderCandidate(candidate: ClassifierCandidate): string;
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model catalogue — turning the registry into a routable pool.
|
|
3
|
+
*
|
|
4
|
+
* `ClassifierRouter` has always taken a host-declared `pool`, because the set
|
|
5
|
+
* of models a host is *willing to be billed for* is not something NeuroLink
|
|
6
|
+
* can invent. But the registry already knows 64 models across 7 providers
|
|
7
|
+
* (132 aliases), each carrying a context window, in/out pricing, speed and
|
|
8
|
+
* quality buckets, capability flags and seven 1–10 use-case scores — and
|
|
9
|
+
* routing read none of it beyond cost and quality. `maxContextTokens` in
|
|
10
|
+
* particular was never read anywhere.
|
|
11
|
+
*
|
|
12
|
+
* The registry covers 7 of the 40 registered providers, so the catalogue is
|
|
13
|
+
* an addition to a declared pool, never a replacement: a host routing over
|
|
14
|
+
* LiteLLM, OpenRouter or a self-hosted model still declares those by hand,
|
|
15
|
+
* and `enrichCandidate` ranks declared and catalogue members on one scale.
|
|
16
|
+
*
|
|
17
|
+
* This module closes that gap for hosts that opt in (`catalog.enabled`): it
|
|
18
|
+
* builds pool members from the registry, intersected with the credentials
|
|
19
|
+
* actually present, and renders each as one line for a decision model to rank.
|
|
20
|
+
*
|
|
21
|
+
* Two constraints shape everything here:
|
|
22
|
+
*
|
|
23
|
+
* 1. **The rendered catalogue is a question, not state.** A decision model's
|
|
24
|
+
* binding limit is `state + the single longest question`, and the model
|
|
25
|
+
* choice question's `criteria` map is that question. Rendering has to stay
|
|
26
|
+
* terse and the count has to stay capped.
|
|
27
|
+
* 2. **A deterministic ranker must exist first.** Every other decision
|
|
28
|
+
* consumer falls back to what the code did before, but there was no
|
|
29
|
+
* "pick from the whole registry" behaviour to fall back to.
|
|
30
|
+
* {@link rankCatalogue}
|
|
31
|
+
* is that fallback, and it runs whenever no decision provider is
|
|
32
|
+
* configured, the call fails, or the verdict is not confident enough.
|
|
33
|
+
*
|
|
34
|
+
* @module routing/modelCatalog
|
|
35
|
+
*/
|
|
36
|
+
import { getAllModels } from "../models/modelRegistry.js";
|
|
37
|
+
import { PROVIDER_DESCRIPTORS } from "../factories/providerDescriptors.js";
|
|
38
|
+
import { DEFAULT_INFERENCE_KINDS } from "../types/index.js";
|
|
39
|
+
/**
|
|
40
|
+
* Default cap on catalogue members. ~40 tokens per rendered line puts 120
|
|
41
|
+
* models near 5K tokens, comfortably inside the ~33K state-plus-longest-
|
|
42
|
+
* question ceiling even alongside a long request.
|
|
43
|
+
*/
|
|
44
|
+
const DEFAULT_MAX_MODELS = 120;
|
|
45
|
+
/** Registry buckets mapped to comparable numbers. */
|
|
46
|
+
const QUALITY_RANK = { high: 3, medium: 2, low: 1 };
|
|
47
|
+
const SPEED_RANK = { fast: 3, medium: 2, slow: 1 };
|
|
48
|
+
/**
|
|
49
|
+
* Which use-case dimension each difficulty tier cares about, and how much
|
|
50
|
+
* weight to give quality against cost.
|
|
51
|
+
*
|
|
52
|
+
* `costWeight` is what makes the fallback ranker a real ranker rather than a
|
|
53
|
+
* sort by price: at `trivial` the cheapest adequate model wins outright, at
|
|
54
|
+
* `expert` price is nearly irrelevant.
|
|
55
|
+
*/
|
|
56
|
+
const TIER_PROFILE = {
|
|
57
|
+
trivial: { dimension: "conversation", costWeight: 1 },
|
|
58
|
+
simple: { dimension: "conversation", costWeight: 0.7 },
|
|
59
|
+
moderate: { dimension: "coding", costWeight: 0.4 },
|
|
60
|
+
hard: { dimension: "reasoning", costWeight: 0.15 },
|
|
61
|
+
expert: { dimension: "reasoning", costWeight: 0.05 },
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Whether this host can actually call the provider.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately permissive in two directions. A provider whose credentials
|
|
67
|
+
* resolve through an external chain (Bedrock's AWS default chain, Vertex's
|
|
68
|
+
* several auth paths) cannot be decided from env-var presence, so it is kept
|
|
69
|
+
* — the worst case is a candidate that later fails and falls back. A local
|
|
70
|
+
* runtime with no credential at all (Ollama, LM Studio) is likewise kept.
|
|
71
|
+
* What this filter removes is the large majority: cloud providers with a
|
|
72
|
+
* single named key that is simply not set.
|
|
73
|
+
*/
|
|
74
|
+
function providerIsReachable(descriptor) {
|
|
75
|
+
if (descriptor.envVars.optional || descriptor.localRuntime) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
if (descriptor.credentialsResolvedExternally) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
const names = [
|
|
82
|
+
descriptor.envVars.apiKey,
|
|
83
|
+
...(descriptor.envVars.fallbacks ?? []),
|
|
84
|
+
].filter((n) => typeof n === "string");
|
|
85
|
+
if (names.length === 0) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
return names.some((name) => (process.env[name] ?? "").trim() !== "");
|
|
89
|
+
}
|
|
90
|
+
/** Provider names this host has credentials for AND that generate text. */
|
|
91
|
+
export function reachableTextProviders() {
|
|
92
|
+
const reachable = new Set();
|
|
93
|
+
for (const descriptor of PROVIDER_DESCRIPTORS) {
|
|
94
|
+
const kinds = descriptor.inferenceKinds ?? DEFAULT_INFERENCE_KINDS;
|
|
95
|
+
if (!kinds.includes("generate")) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (providerIsReachable(descriptor)) {
|
|
99
|
+
reachable.add(descriptor.name);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return reachable;
|
|
103
|
+
}
|
|
104
|
+
/** Capability tags, in the vocabulary the classifier and router already use. */
|
|
105
|
+
function capabilityTags(model) {
|
|
106
|
+
const tags = [];
|
|
107
|
+
if (model.capabilities.vision) {
|
|
108
|
+
tags.push("vision");
|
|
109
|
+
}
|
|
110
|
+
if (model.capabilities.functionCalling) {
|
|
111
|
+
tags.push("tools");
|
|
112
|
+
}
|
|
113
|
+
if (model.capabilities.reasoning) {
|
|
114
|
+
tags.push("reasoning");
|
|
115
|
+
}
|
|
116
|
+
if (model.capabilities.codeGeneration) {
|
|
117
|
+
tags.push("code");
|
|
118
|
+
}
|
|
119
|
+
if (model.capabilities.multimodal) {
|
|
120
|
+
tags.push("multimodal");
|
|
121
|
+
}
|
|
122
|
+
return tags;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Score a model for a difficulty tier, 0–1 where higher is better.
|
|
126
|
+
*
|
|
127
|
+
* Combines the registry's own use-case suitability for the tier's dimension
|
|
128
|
+
* with its quality bucket, then discounts by price. Purely deterministic —
|
|
129
|
+
* this is the ordering used when no decision model is available, and the
|
|
130
|
+
* ordering the decision model's own pick is compared against.
|
|
131
|
+
*/
|
|
132
|
+
function tierScore(model, difficulty) {
|
|
133
|
+
const profile = TIER_PROFILE[difficulty];
|
|
134
|
+
const suitability = (model.useCases[profile.dimension] ?? 5) / 10;
|
|
135
|
+
const quality = (QUALITY_RANK[model.performance.quality] ?? 2) / 3;
|
|
136
|
+
const speed = (SPEED_RANK[model.performance.speed] ?? 2) / 3;
|
|
137
|
+
// Price per 1K input, squashed into 0–1 where 0 is free and 1 is expensive.
|
|
138
|
+
// $0.02/1K is roughly the top of the current frontier band, so the curve is
|
|
139
|
+
// scaled to saturate a little past it rather than at an arbitrary maximum.
|
|
140
|
+
const price = model.pricing.inputCostPer1K + model.pricing.outputCostPer1K;
|
|
141
|
+
const priceFactor = Math.min(1, price / 0.06);
|
|
142
|
+
const merit = suitability * 0.5 + quality * 0.35 + speed * 0.15;
|
|
143
|
+
return merit - priceFactor * profile.costWeight;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Rank a member the registry has never heard of, on the SAME scale
|
|
147
|
+
* `tierScore` produces, so declared and catalogue members interleave rather
|
|
148
|
+
* than one bucket always sorting after the other.
|
|
149
|
+
*
|
|
150
|
+
* The `/ 3` mirrors `tierScore`'s `QUALITY_RANK[...] / 3`: the registry's
|
|
151
|
+
* quality is a 1–3 rank, and the divisor is what puts it on 0–1. A host's
|
|
152
|
+
* `quality`, though, is an unbounded relative scale — the type says only
|
|
153
|
+
* "higher is more capable", and a pool declaring `cost: 20` for "the expensive
|
|
154
|
+
* one" makes `quality: 10` just as plausible. Divided by 3 that is 3.33, which
|
|
155
|
+
* is off the top of the scale every registry candidate is confined to, so a
|
|
156
|
+
* single generous declaration would outrank the entire catalogue at every
|
|
157
|
+
* difficulty — including tiers the host never meant that model for.
|
|
158
|
+
*
|
|
159
|
+
* Clamping into the 1–3 band the divisor assumes keeps the comparison honest.
|
|
160
|
+
* It does saturate: a pool declaring quality 1–10 loses the ordering above 3.
|
|
161
|
+
* That is the right trade against a member that silently wins everything, and
|
|
162
|
+
* a host wanting finer control has `tiers`, which is exact rather than scored.
|
|
163
|
+
*/
|
|
164
|
+
function declaredScore(member, difficulty) {
|
|
165
|
+
const quality = Math.min(3, Math.max(0, member.quality ?? 2)) / 3;
|
|
166
|
+
const priceFactor = Math.min(1, Math.max(0, member.cost ?? 0) / 0.06);
|
|
167
|
+
return quality - priceFactor * TIER_PROFILE[difficulty].costWeight;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Rank candidates deterministically for a difficulty. The fallback that makes
|
|
171
|
+
* the catalogue safe to enable: with no decision provider configured this is
|
|
172
|
+
* the whole selection, and it never consults the network.
|
|
173
|
+
*
|
|
174
|
+
* `requiredCapabilities` filters leniently — a candidate with no recorded
|
|
175
|
+
* capabilities is kept rather than starving the pool on missing metadata,
|
|
176
|
+
* matching `ClassifierRouter.filterByCapabilities`.
|
|
177
|
+
*/
|
|
178
|
+
export function rankCatalogue(candidates, difficulty, input, requiredCapabilities) {
|
|
179
|
+
const needed = requiredCapabilities ?? [];
|
|
180
|
+
const capable = candidates.filter((c) => {
|
|
181
|
+
if (needed.length === 0 || !c.capabilities || c.capabilities.length === 0) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
return needed.every((cap) => c.capabilities?.includes(cap));
|
|
185
|
+
});
|
|
186
|
+
const pool = capable.length > 0 ? capable : [...candidates];
|
|
187
|
+
// A model that cannot hold the request is not a cheaper option, it is a
|
|
188
|
+
// failed one — and a context_window error puts the model in ModelPool's
|
|
189
|
+
// permanent (10-year) cooldown, so this filter is load-bearing rather than
|
|
190
|
+
// an optimisation. Applied only when it leaves something behind.
|
|
191
|
+
const needTokens = input?.estimatedInputTokens ?? 0;
|
|
192
|
+
const fits = needTokens > 0
|
|
193
|
+
? pool.filter((c) => c.contextWindow === undefined || c.contextWindow >= needTokens)
|
|
194
|
+
: pool;
|
|
195
|
+
const finalPool = fits.length > 0 ? fits : pool;
|
|
196
|
+
const originalIndex = new Map(finalPool.map((c, i) => [c, i]));
|
|
197
|
+
return [...finalPool].sort((a, b) => {
|
|
198
|
+
const delta = (b.score ?? 0) - (a.score ?? 0);
|
|
199
|
+
if (delta !== 0) {
|
|
200
|
+
return delta;
|
|
201
|
+
}
|
|
202
|
+
return (originalIndex.get(a) ?? 0) - (originalIndex.get(b) ?? 0);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Build catalogue pool members from the registry.
|
|
207
|
+
*
|
|
208
|
+
* Returns `[]` when the catalogue is disabled or nothing is reachable, which
|
|
209
|
+
* leaves the declared pool as the only source — the pre-catalogue behaviour.
|
|
210
|
+
*/
|
|
211
|
+
export function buildModelCatalog(config) {
|
|
212
|
+
if (!config?.enabled) {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
const allowed = config.providers ? new Set(config.providers) : undefined;
|
|
216
|
+
const reachable = reachableTextProviders();
|
|
217
|
+
const minWindow = config.minContextWindow ?? 0;
|
|
218
|
+
const eligible = getAllModels().filter((model) => {
|
|
219
|
+
if (!config.includeDeprecated && model.deprecated) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
if (!reachable.has(model.provider)) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
if (allowed && !allowed.has(model.provider)) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
return model.limits.maxContextTokens >= minWindow;
|
|
229
|
+
});
|
|
230
|
+
// Cap by a tier-neutral merit score so the truncation keeps the models most
|
|
231
|
+
// likely to be useful rather than whichever the registry happened to list
|
|
232
|
+
// first. "moderate" is the neutral tier the classifier itself measures
|
|
233
|
+
// upgrades and downgrades against.
|
|
234
|
+
const capped = [...eligible]
|
|
235
|
+
.sort((a, b) => tierScore(b, "moderate") - tierScore(a, "moderate"))
|
|
236
|
+
.slice(0, config.maxModels ?? DEFAULT_MAX_MODELS);
|
|
237
|
+
// Deliberately NO `cost` / `quality` here, though both are trivially
|
|
238
|
+
// available. Those two fields are how a HOST states its own opinion about a
|
|
239
|
+
// model, and `renderCandidate` gives a host's opinion precedence over the
|
|
240
|
+
// registry's. Copying registry values into them would make every catalogue
|
|
241
|
+
// member look hand-declared: the rich registry line (real price in cents,
|
|
242
|
+
// speed, quality bucket, "strong at …") would be suppressed in favour of
|
|
243
|
+
// `relative cost 0.002`, which is raw per-1K pricing wearing a relative
|
|
244
|
+
// label — meaningless to compare against a host's `cost: 20`, and it would
|
|
245
|
+
// discard the use-case scores the catalogue exists to surface.
|
|
246
|
+
//
|
|
247
|
+
// Leaving them undefined loses nothing: `ClassifierRouter.metaFor` and
|
|
248
|
+
// `enrichCandidate` both resolve straight back to the registry by model id.
|
|
249
|
+
return capped.map((model) => ({
|
|
250
|
+
provider: model.provider,
|
|
251
|
+
model: model.id,
|
|
252
|
+
id: `${model.provider}/${model.id}`,
|
|
253
|
+
description: model.description,
|
|
254
|
+
capabilities: capabilityTags(model),
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Enrich a pool member into a classifier candidate, pulling registry metadata
|
|
259
|
+
* the member did not declare.
|
|
260
|
+
*
|
|
261
|
+
* Works for any pool, catalogue-derived or hand-declared: a host that listed
|
|
262
|
+
* `{ provider: "openai", model: "gpt-4o" }` gets the same context window and
|
|
263
|
+
* pricing as a catalogue entry, because both resolve through the registry.
|
|
264
|
+
* Members the registry does not know (LiteLLM, self-hosted) keep exactly what
|
|
265
|
+
* the host declared and are still rankable — `tierScore` falls back to
|
|
266
|
+
* neutral values rather than dropping them.
|
|
267
|
+
*/
|
|
268
|
+
export function enrichCandidate(id, member, difficulty, registry) {
|
|
269
|
+
const info = member.model ? registry?.get(member.model) : undefined;
|
|
270
|
+
const candidate = {
|
|
271
|
+
id,
|
|
272
|
+
provider: member.provider,
|
|
273
|
+
model: member.model,
|
|
274
|
+
description: member.description ?? info?.description,
|
|
275
|
+
tiers: member.tiers,
|
|
276
|
+
capabilities: member.capabilities ?? (info ? capabilityTags(info) : undefined),
|
|
277
|
+
contextWindow: info?.limits.maxContextTokens,
|
|
278
|
+
// Registry pricing ONLY. `member.cost` is documented as a *relative*
|
|
279
|
+
// scale ("lower = cheaper"), so a host writing `cost: 20` to mean "the
|
|
280
|
+
// expensive one" would otherwise be rendered to the model as $20 per 1K
|
|
281
|
+
// input tokens — a number three orders of magnitude off, and one it would
|
|
282
|
+
// rightly refuse to pick. The relative value still drives the
|
|
283
|
+
// deterministic score below, where it is compared only against other
|
|
284
|
+
// relative values.
|
|
285
|
+
inputCostPer1K: info?.pricing.inputCostPer1K,
|
|
286
|
+
outputCostPer1K: info?.pricing.outputCostPer1K,
|
|
287
|
+
speed: info?.performance.speed,
|
|
288
|
+
quality: info?.performance.quality,
|
|
289
|
+
useCases: info?.useCases,
|
|
290
|
+
relativeCost: member.cost,
|
|
291
|
+
relativeQuality: member.quality,
|
|
292
|
+
};
|
|
293
|
+
candidate.score = info
|
|
294
|
+
? tierScore(info, difficulty)
|
|
295
|
+
: declaredScore(member, difficulty);
|
|
296
|
+
return candidate;
|
|
297
|
+
}
|
|
298
|
+
/** Index the registry by model id once per build, for `enrichCandidate`. */
|
|
299
|
+
export function buildRegistryIndex() {
|
|
300
|
+
const index = new Map();
|
|
301
|
+
for (const model of getAllModels()) {
|
|
302
|
+
index.set(model.id, model);
|
|
303
|
+
for (const alias of model.aliases) {
|
|
304
|
+
if (!index.has(alias)) {
|
|
305
|
+
index.set(alias, model);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return index;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Render one candidate as a single criteria line for the decision model.
|
|
313
|
+
*
|
|
314
|
+
* Terse on purpose: this text is multiplied by the candidate count inside a
|
|
315
|
+
* single question, and that question competes with the request for the
|
|
316
|
+
* ~33K ceiling. Numbers are rendered in units a reader can compare at a
|
|
317
|
+
* glance (K tokens, ¢ per 1K) because a decision model reads digits as text
|
|
318
|
+
* and does better with "128K" than with "131072".
|
|
319
|
+
*/
|
|
320
|
+
export function renderCandidate(candidate) {
|
|
321
|
+
const parts = [];
|
|
322
|
+
if (candidate.description) {
|
|
323
|
+
parts.push(candidate.description);
|
|
324
|
+
}
|
|
325
|
+
// The host's declared tier eligibility is the most direct statement it can
|
|
326
|
+
// make about where a model belongs, so it goes in early and is never
|
|
327
|
+
// suppressed.
|
|
328
|
+
if (candidate.tiers?.length) {
|
|
329
|
+
parts.push(`intended for ${candidate.tiers.join("/")} tasks`);
|
|
330
|
+
}
|
|
331
|
+
// Objective facts, safe to state regardless of who declared what.
|
|
332
|
+
if (candidate.contextWindow) {
|
|
333
|
+
parts.push(`${Math.round(candidate.contextWindow / 1000)}K context`);
|
|
334
|
+
}
|
|
335
|
+
// Precedence, and the reason for it. When a host declares `cost`/`quality`
|
|
336
|
+
// it is ranking the models *in its own pool* — and the registry frequently
|
|
337
|
+
// disagrees, because it rates a model on general benchmarks rather than on
|
|
338
|
+
// the job this host uses it for. Rendering both put five registry clauses
|
|
339
|
+
// ("high quality", "strong at reasoning", a low price) against one line of
|
|
340
|
+
// host prose, and the host lost every time: a pool that explicitly marked
|
|
341
|
+
// a cheap model `quality: 2` still had hard tasks routed to it.
|
|
342
|
+
//
|
|
343
|
+
// So: declared metadata replaces the registry's opinion rather than
|
|
344
|
+
// sitting beside it. This matches `ClassifierRouter.metaFor`, which has
|
|
345
|
+
// always resolved declared values first and filled only the gaps.
|
|
346
|
+
const hasDeclaredRanking = candidate.relativeQuality !== undefined ||
|
|
347
|
+
candidate.relativeCost !== undefined;
|
|
348
|
+
if (hasDeclaredRanking) {
|
|
349
|
+
if (candidate.relativeQuality !== undefined) {
|
|
350
|
+
parts.push(`capability ${candidate.relativeQuality} (higher is more capable)`);
|
|
351
|
+
}
|
|
352
|
+
if (candidate.relativeCost !== undefined) {
|
|
353
|
+
parts.push(`relative cost ${candidate.relativeCost} (lower is cheaper)`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
if (candidate.inputCostPer1K !== undefined) {
|
|
358
|
+
parts.push(`${(candidate.inputCostPer1K * 100).toFixed(2)}c per 1K in`);
|
|
359
|
+
}
|
|
360
|
+
if (candidate.speed) {
|
|
361
|
+
parts.push(`${candidate.speed} speed`);
|
|
362
|
+
}
|
|
363
|
+
if (candidate.quality) {
|
|
364
|
+
parts.push(`${candidate.quality} quality`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Capability FLAGS are facts (a model either accepts images or does not),
|
|
368
|
+
// so they always render. Use-case SCORES are the registry's quality
|
|
369
|
+
// opinion, so they are suppressed when the host has given its own.
|
|
370
|
+
if (candidate.capabilities?.length) {
|
|
371
|
+
parts.push(`supports ${candidate.capabilities.join(", ")}`);
|
|
372
|
+
}
|
|
373
|
+
if (candidate.useCases && !hasDeclaredRanking) {
|
|
374
|
+
const best = Object.entries(candidate.useCases)
|
|
375
|
+
.filter(([, score]) => score >= 8)
|
|
376
|
+
.map(([name]) => name);
|
|
377
|
+
if (best.length > 0) {
|
|
378
|
+
parts.push(`strong at ${best.join(", ")}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return parts.join("; ");
|
|
382
|
+
}
|
|
@@ -21,6 +21,7 @@ export declare const tracers: {
|
|
|
21
21
|
readonly sdk: import("@opentelemetry/api").Tracer;
|
|
22
22
|
readonly provider: import("@opentelemetry/api").Tracer;
|
|
23
23
|
readonly generation: import("@opentelemetry/api").Tracer;
|
|
24
|
+
readonly decision: import("@opentelemetry/api").Tracer;
|
|
24
25
|
readonly stream: import("@opentelemetry/api").Tracer;
|
|
25
26
|
readonly http: import("@opentelemetry/api").Tracer;
|
|
26
27
|
readonly mcp: import("@opentelemetry/api").Tracer;
|
|
@@ -22,6 +22,7 @@ export const tracers = {
|
|
|
22
22
|
sdk: trace.getTracer("neurolink"),
|
|
23
23
|
provider: trace.getTracer("neurolink.provider"),
|
|
24
24
|
generation: trace.getTracer("neurolink.generation"),
|
|
25
|
+
decision: trace.getTracer("neurolink.decision"),
|
|
25
26
|
stream: trace.getTracer("neurolink.stream"),
|
|
26
27
|
http: trace.getTracer("neurolink.http"),
|
|
27
28
|
mcp: trace.getTracer("neurolink.mcp"),
|