@juspay/neurolink 12.18.0 → 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.
Files changed (77) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/README.md +277 -75
  3. package/dist/browser/neurolink.min.js +396 -396
  4. package/dist/cli/commands/setup.js +2 -1
  5. package/dist/cli/factories/commandFactory.d.ts +22 -2
  6. package/dist/cli/factories/commandFactory.js +14 -2
  7. package/dist/cli/loop/optionsSchema.js +4 -0
  8. package/dist/cli/utils/classifierRouterFlags.js +39 -1
  9. package/dist/constants/enums.d.ts +13 -0
  10. package/dist/constants/enums.js +14 -0
  11. package/dist/context/budgetChecker.d.ts +3 -1
  12. package/dist/context/budgetChecker.js +20 -4
  13. package/dist/context/contextCompactor.d.ts +43 -3
  14. package/dist/context/contextCompactor.js +157 -41
  15. package/dist/context/contextDecision.d.ts +56 -0
  16. package/dist/context/contextDecision.js +234 -0
  17. package/dist/core/baseProvider.d.ts +27 -1
  18. package/dist/core/baseProvider.js +39 -0
  19. package/dist/core/toolRouting.d.ts +8 -0
  20. package/dist/core/toolRouting.js +52 -1
  21. package/dist/core/toolRoutingDecision.d.ts +35 -0
  22. package/dist/core/toolRoutingDecision.js +194 -0
  23. package/dist/factories/providerDescriptors.d.ts +15 -0
  24. package/dist/factories/providerDescriptors.js +57 -1
  25. package/dist/factories/providerRegistry.js +9 -1
  26. package/dist/index.d.ts +57 -1
  27. package/dist/index.js +60 -1
  28. package/dist/models/manifestRegistry.js +2 -0
  29. package/dist/models/manifests/typesafe.d.ts +16 -0
  30. package/dist/models/manifests/typesafe.js +42 -0
  31. package/dist/neurolink.d.ts +57 -1
  32. package/dist/neurolink.js +254 -10
  33. package/dist/observability/exporters/laminarExporter.js +3 -0
  34. package/dist/observability/exporters/posthogExporter.js +3 -0
  35. package/dist/observability/utils/spanSerializer.js +4 -0
  36. package/dist/providers/catalog/schema.d.ts +1 -1
  37. package/dist/providers/typesafe.d.ts +112 -0
  38. package/dist/providers/typesafe.js +654 -0
  39. package/dist/proxy/proxyHistoryTruncation.js +15 -10
  40. package/dist/rag/pipeline/RAGPipeline.d.ts +3 -1
  41. package/dist/rag/pipeline/RAGPipeline.js +65 -4
  42. package/dist/rag/retrieval/searchDecision.d.ts +27 -0
  43. package/dist/rag/retrieval/searchDecision.js +124 -0
  44. package/dist/routing/classifierRouter.d.ts +74 -2
  45. package/dist/routing/classifierRouter.js +232 -32
  46. package/dist/routing/classifierStrategies.d.ts +33 -1
  47. package/dist/routing/classifierStrategies.js +237 -0
  48. package/dist/routing/index.d.ts +1 -1
  49. package/dist/routing/index.js +1 -1
  50. package/dist/routing/modelCatalog.d.ts +79 -0
  51. package/dist/routing/modelCatalog.js +382 -0
  52. package/dist/telemetry/tracers.d.ts +1 -0
  53. package/dist/telemetry/tracers.js +1 -0
  54. package/dist/types/classifierRouter.d.ts +160 -4
  55. package/dist/types/cli.d.ts +17 -1
  56. package/dist/types/config.d.ts +12 -0
  57. package/dist/types/context.d.ts +55 -1
  58. package/dist/types/decision.d.ts +200 -0
  59. package/dist/types/decision.js +21 -0
  60. package/dist/types/generate.d.ts +20 -0
  61. package/dist/types/index.d.ts +1 -0
  62. package/dist/types/index.js +2 -0
  63. package/dist/types/providers.d.ts +59 -1
  64. package/dist/types/providers.js +5 -0
  65. package/dist/types/rag.d.ts +48 -0
  66. package/dist/types/span.d.ts +11 -0
  67. package/dist/types/span.js +11 -0
  68. package/dist/types/stream.d.ts +13 -0
  69. package/dist/types/toolRouting.d.ts +56 -0
  70. package/dist/utils/decisionAnswers.d.ts +56 -0
  71. package/dist/utils/decisionAnswers.js +95 -0
  72. package/dist/utils/modelChoices.js +13 -1
  73. package/dist/utils/pricing.js +11 -0
  74. package/dist/utils/providerConfig.d.ts +4 -0
  75. package/dist/utils/providerConfig.js +17 -0
  76. package/docs-site/static/search-index.json +1 -1
  77. 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"),