@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +3 -3
  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/rag/pipeline/RAGPipeline.d.ts +3 -1
  40. package/dist/rag/pipeline/RAGPipeline.js +65 -4
  41. package/dist/rag/retrieval/searchDecision.d.ts +27 -0
  42. package/dist/rag/retrieval/searchDecision.js +124 -0
  43. package/dist/routing/classifierRouter.d.ts +74 -2
  44. package/dist/routing/classifierRouter.js +232 -32
  45. package/dist/routing/classifierStrategies.d.ts +33 -1
  46. package/dist/routing/classifierStrategies.js +237 -0
  47. package/dist/routing/index.d.ts +1 -1
  48. package/dist/routing/index.js +1 -1
  49. package/dist/routing/modelCatalog.d.ts +79 -0
  50. package/dist/routing/modelCatalog.js +382 -0
  51. package/dist/telemetry/tracers.d.ts +1 -0
  52. package/dist/telemetry/tracers.js +1 -0
  53. package/dist/types/classifierRouter.d.ts +160 -4
  54. package/dist/types/cli.d.ts +17 -1
  55. package/dist/types/config.d.ts +12 -0
  56. package/dist/types/context.d.ts +55 -1
  57. package/dist/types/decision.d.ts +200 -0
  58. package/dist/types/decision.js +21 -0
  59. package/dist/types/generate.d.ts +20 -0
  60. package/dist/types/index.d.ts +1 -0
  61. package/dist/types/index.js +2 -0
  62. package/dist/types/providers.d.ts +59 -1
  63. package/dist/types/providers.js +5 -0
  64. package/dist/types/rag.d.ts +48 -0
  65. package/dist/types/span.d.ts +11 -0
  66. package/dist/types/span.js +11 -0
  67. package/dist/types/stream.d.ts +13 -0
  68. package/dist/types/toolRouting.d.ts +56 -0
  69. package/dist/utils/decisionAnswers.d.ts +56 -0
  70. package/dist/utils/decisionAnswers.js +95 -0
  71. package/dist/utils/modelChoices.js +13 -1
  72. package/dist/utils/pricing.js +11 -0
  73. package/dist/utils/providerConfig.d.ts +4 -0
  74. package/dist/utils/providerConfig.js +17 -0
  75. package/docs-site/static/search-index.json +1 -1
  76. package/package.json +16 -3
@@ -0,0 +1,654 @@
1
+ import { TypeSafeModels } from "../constants/enums.js";
2
+ import { BaseProvider } from "../core/baseProvider.js";
3
+ import { isNeuroLink } from "../neurolink.js";
4
+ import { createProxyFetch } from "../proxy/proxyFetch.js";
5
+ import { ProviderError } from "../types/index.js";
6
+ import { logger } from "../utils/logger.js";
7
+ import { getProviderModel } from "../utils/providerConfig.js";
8
+ const TYPESAFE_DEFAULT_BASE_URL = "https://api.typesafe.ai";
9
+ /**
10
+ * Vercel AI Gateway's evaluation-model endpoint.
11
+ *
12
+ * Vercel's AI SDK models a decision model as `EvaluationModelV4`, separate
13
+ * from `LanguageModelV4` — the same conclusion this codebase reached with
14
+ * `inferenceKinds`, arrived at independently. The gateway therefore has its
15
+ * own route rather than reusing the chat one.
16
+ */
17
+ const GATEWAY_URL = "https://ai-gateway.vercel.sh/v4/ai/evaluation-model";
18
+ /** Gateway model id for Jev. The gateway carries it in a header, not the body. */
19
+ const GATEWAY_MODEL_ID = "typesafe-ai/jev";
20
+ /**
21
+ * Protocol version header the gateway requires. Omitting it fails the whole
22
+ * request with a 400 ("Unsupported gateway protocol version") rather than
23
+ * defaulting, so it is not optional.
24
+ */
25
+ const GATEWAY_PROTOCOL_VERSION = "0.0.1";
26
+ /** Evaluation-model spec version the request body conforms to. */
27
+ const GATEWAY_SPEC_VERSION = "4";
28
+ /**
29
+ * Generous enough for a cold start (measured at 2.0–2.7s after idle) while
30
+ * still bounded. Steady-state p50 is ~400ms, so this only bites on the first
31
+ * call or a genuine stall — and every internal caller is fail-open anyway.
32
+ */
33
+ const DEFAULT_TIMEOUT_MS = 5000;
34
+ const DEFAULT_MAX_RETRIES = 1;
35
+ /**
36
+ * The binding limit in practice: `state` plus the SINGLE LONGEST question.
37
+ * Measured by bisection at single-character resolution — 33,002 reported
38
+ * input tokens accepted, 33,003 not.
39
+ */
40
+ export const TYPESAFE_MAX_STATE_TOKENS = 33_000;
41
+ /**
42
+ * The separate, larger ceiling on `state` plus ALL questions combined. A
43
+ * request can carry a near-ceiling state *and* 400 extra questions (37,679
44
+ * tokens total, verified) — questions do not compete with state for the
45
+ * 33K budget, only for this one.
46
+ */
47
+ export const TYPESAFE_MAX_REQUEST_TOKENS = 64_000;
48
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
49
+ const asNumber = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
50
+ const asNumberMap = (v) => {
51
+ if (!isRecord(v)) {
52
+ return undefined;
53
+ }
54
+ const out = {};
55
+ for (const [k, raw] of Object.entries(v)) {
56
+ const n = asNumber(raw);
57
+ if (n === undefined) {
58
+ return undefined;
59
+ }
60
+ out[k] = n;
61
+ }
62
+ return out;
63
+ };
64
+ const asStringMap = (v) => {
65
+ if (!isRecord(v)) {
66
+ return undefined;
67
+ }
68
+ const out = {};
69
+ for (const [k, raw] of Object.entries(v)) {
70
+ if (typeof raw !== "string") {
71
+ return undefined;
72
+ }
73
+ out[k] = raw;
74
+ }
75
+ return out;
76
+ };
77
+ /**
78
+ * Translate the provider-neutral question vocabulary onto TypeSafe's wire.
79
+ *
80
+ * The only difference is the name of the yes/no primitive: TypeSafe calls it
81
+ * `noul`, and everyone exposing it — the Vercel AI SDK, Pydantic AI, and this
82
+ * codebase — calls it `boolean`. Vercel's AI Gateway fronts the same model
83
+ * and uses the neutral name, so the gateway transport needs no translation
84
+ * at all.
85
+ */
86
+ function toWireQuestion(question, transport) {
87
+ // The gateway already speaks the neutral vocabulary this codebase uses, so
88
+ // only the direct transport needs the rename.
89
+ if (question.type === "boolean" && transport === "direct") {
90
+ return {
91
+ type: "noul",
92
+ instructions: question.instructions,
93
+ ...(question.criteria ? { criteria: question.criteria } : {}),
94
+ };
95
+ }
96
+ return question;
97
+ }
98
+ /**
99
+ * Validate one answer off the wire. Returns null rather than throwing so a
100
+ * single malformed answer degrades to "unanswered" instead of failing the
101
+ * whole batch — the batch may hold hundreds of usable answers.
102
+ */
103
+ function parseAnswer(raw, reportedConfidence) {
104
+ if (!isRecord(raw)) {
105
+ return null;
106
+ }
107
+ switch (raw.type) {
108
+ // "noul" is TypeSafe's own spelling; "boolean" is the gateway's, which
109
+ // renames both the type and the field. Accepting both keeps one parser
110
+ // for two transports — and keeps the direct path working if TypeSafe
111
+ // ever adopts the neutral name itself.
112
+ case "noul":
113
+ case "boolean": {
114
+ const probability = asNumber(raw.noul) ?? asNumber(raw.probability);
115
+ return probability === undefined
116
+ ? null
117
+ : { type: "boolean", probability };
118
+ }
119
+ case "choice": {
120
+ const probabilities = asNumberMap(raw.probabilities);
121
+ if (typeof raw.choice !== "string" || !probabilities) {
122
+ return null;
123
+ }
124
+ return {
125
+ type: "choice",
126
+ choice: raw.choice,
127
+ confidence: asNumber(raw.confidence) ??
128
+ reportedConfidence ??
129
+ deriveConfidence(probabilities),
130
+ probabilities,
131
+ };
132
+ }
133
+ case "score": {
134
+ const score = asNumber(raw.score);
135
+ const legend = asStringMap(raw.legend);
136
+ const probabilities = asNumberMap(raw.probabilities);
137
+ if (score === undefined || !legend || !probabilities) {
138
+ return null;
139
+ }
140
+ return {
141
+ type: "score",
142
+ score,
143
+ confidence: asNumber(raw.confidence) ??
144
+ reportedConfidence ??
145
+ deriveConfidence(probabilities),
146
+ legend,
147
+ probabilities,
148
+ };
149
+ }
150
+ default:
151
+ return null;
152
+ }
153
+ }
154
+ /**
155
+ * Per-question confidence the gateway reports out-of-band, keyed by question
156
+ * id, under `providerMetadata.typesafe.confidence`. Absent on the direct
157
+ * transport, which puts confidence on each answer instead.
158
+ */
159
+ function readReportedConfidence(payload) {
160
+ if (!isRecord(payload) || !isRecord(payload.providerMetadata)) {
161
+ return {};
162
+ }
163
+ const vendor = payload.providerMetadata.typesafe;
164
+ if (!isRecord(vendor) || !isRecord(vendor.confidence)) {
165
+ return {};
166
+ }
167
+ const out = {};
168
+ for (const [id, value] of Object.entries(vendor.confidence)) {
169
+ const n = asNumber(value);
170
+ if (n !== undefined) {
171
+ out[id] = n;
172
+ }
173
+ }
174
+ return out;
175
+ }
176
+ /**
177
+ * Last-resort confidence, when no transport reported one.
178
+ *
179
+ * The gateway leaves `confidence` off every answer object, so this was once
180
+ * the only value a gateway caller ever saw. It is not: the gateway reports the
181
+ * vendor's calibrated figure under `providerMetadata` (see
182
+ * {@link readReportedConfidence}), which is preferred wherever it is present.
183
+ * This remains the fallback for an answer neither source covers.
184
+ *
185
+ * The peak probability is what a calibrated confidence approximates, but it is
186
+ * NOT the same number, so a threshold tuned against the direct transport does
187
+ * not transfer unexamined; an even distribution lands near 1/N rather than 0.
188
+ */
189
+ function deriveConfidence(probabilities) {
190
+ const values = Object.values(probabilities);
191
+ return values.length > 0 ? Math.max(...values) : 0;
192
+ }
193
+ /**
194
+ * Gateway error `type` → our kind. The gateway's own slugs are the only
195
+ * reliable discriminator: it returns 403 for both a credential that cannot
196
+ * reach AI Gateway and an account with no card on file, and those want very
197
+ * different words in front of a reader.
198
+ */
199
+ const GATEWAY_ERROR_KIND = {
200
+ authentication_error: "authentication",
201
+ customer_verification_required: "authentication",
202
+ invalid_request_error: "invalid_request",
203
+ rate_limit_exceeded: "rate_limit",
204
+ internal_server_error: "server",
205
+ };
206
+ function parseGatewayError(status, error, requestId, retryable) {
207
+ const type = typeof error.type === "string" ? error.type : undefined;
208
+ const kind = (type ? GATEWAY_ERROR_KIND[type] : undefined) ??
209
+ (status === 401 || status === 403
210
+ ? "authentication"
211
+ : status === 429
212
+ ? "rate_limit"
213
+ : status >= 500
214
+ ? "server"
215
+ : "invalid_request");
216
+ const message = typeof error.message === "string" && error.message.length > 0
217
+ ? error.message
218
+ : (type ?? `AI Gateway request failed with HTTP ${status}`);
219
+ return { kind, message, status, requestId, retryable };
220
+ }
221
+ /**
222
+ * Normalise the three error envelopes into one shape.
223
+ *
224
+ * Shape A (direct, application): `{"detail":{"error_type":"...","message":"..."}}`
225
+ * Shape B (direct, validation): `{"detail":[{"type":"missing","loc":[...],...}]}`
226
+ * Shape C (gateway): `{"error":{"message":"...","type":"...","code":400}}`
227
+ *
228
+ * Shape B echoes the offending `input` back, which can contain the caller's
229
+ * state, so only `loc`/`msg` are surfaced — never `input`.
230
+ *
231
+ * Shape C is a different vendor's envelope reached through the same method,
232
+ * and it was invisible until the gateway was exercised live: every gateway
233
+ * failure fell through to `HTTP <status>` with the real cause discarded. The
234
+ * worst case was a 403 `customer_verification_required` — a billing state,
235
+ * not a bad key — reported as a flat 403 and then tripping the auth circuit
236
+ * breaker under the message "API key rejected", which points the reader at
237
+ * the one thing that is not wrong.
238
+ */
239
+ function parseError(status, body, requestId) {
240
+ const retryable = status === 429 || status === 529 || status >= 500;
241
+ const gateway = isRecord(body) && isRecord(body.error) ? body.error : undefined;
242
+ if (gateway) {
243
+ return parseGatewayError(status, gateway, requestId, retryable);
244
+ }
245
+ const detail = isRecord(body) ? body.detail : undefined;
246
+ if (Array.isArray(detail)) {
247
+ const fields = detail
248
+ .filter(isRecord)
249
+ .map((d) => {
250
+ const loc = Array.isArray(d.loc)
251
+ ? d.loc
252
+ .filter((p) => typeof p === "string" || typeof p === "number")
253
+ .join(".")
254
+ : "";
255
+ const msg = typeof d.msg === "string" ? d.msg : "invalid";
256
+ return loc ? `${loc}: ${msg}` : msg;
257
+ })
258
+ .slice(0, 5);
259
+ return {
260
+ kind: "invalid_request",
261
+ message: `Request failed validation — ${fields.join("; ") || "unspecified field"}`,
262
+ status,
263
+ requestId,
264
+ retryable: false,
265
+ };
266
+ }
267
+ const errorType = isRecord(detail) && typeof detail.error_type === "string"
268
+ ? detail.error_type
269
+ : undefined;
270
+ // 403 for a MISSING Authorization header and 401 for an INVALID key —
271
+ // inverted from the usual convention, and from TypeSafe's own docs, which
272
+ // collapse both into one 401 row. Verified live.
273
+ const kind = errorType === "max_tokens_exceeded"
274
+ ? "max_tokens_exceeded"
275
+ : status === 401 || status === 403
276
+ ? "authentication"
277
+ : status === 429
278
+ ? "rate_limit"
279
+ : status === 529
280
+ ? "overloaded"
281
+ : status >= 500
282
+ ? "server"
283
+ : "invalid_request";
284
+ // `max_tokens_exceeded` is returned with no `message` at all, and it is the
285
+ // one error a long-context caller is most likely to hit — so the fallback
286
+ // has to be a real sentence rather than the bare error_type slug.
287
+ const fallback = kind === "max_tokens_exceeded"
288
+ ? `State plus questions exceeded the model's input limit: keep state plus the longest single question under ~${TYPESAFE_MAX_STATE_TOKENS} tokens, and state plus all questions under ~${TYPESAFE_MAX_REQUEST_TOKENS}.`
289
+ : (errorType ?? `TypeSafe request failed with HTTP ${status}`);
290
+ const message = isRecord(detail) && typeof detail.message === "string"
291
+ ? detail.message
292
+ : fallback;
293
+ return { kind, message, status, requestId, retryable };
294
+ }
295
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
296
+ /**
297
+ * TypeSafe Provider — the `decide` inference type only.
298
+ *
299
+ * Jev is a "System One" model: it takes one `state` plus a map of typed
300
+ * questions and returns one typed answer each, evaluated in a single
301
+ * parallel pass. It emits no text, so `generate()` / `stream()` are not
302
+ * available and `getAISDKModel()` throws — the same shape Voyage and Jina
303
+ * already use for embedding-only providers.
304
+ *
305
+ * Its descriptor declares `inferenceKinds: ["decide"]`, which keeps it out
306
+ * of auto-select and the health sweep, so those throws should be unreachable
307
+ * in practice.
308
+ *
309
+ * Two measured properties shape the API: latency is flat in question count
310
+ * (1 question 393ms, 400 questions 465ms) while concurrent requests queue.
311
+ * Callers therefore batch every question into one `decide()` call.
312
+ *
313
+ * Two transports reach the same model. The direct one is TypeSafe's own API,
314
+ * billed against a TypeSafe key. The gateway one is Vercel's AI Gateway
315
+ * evaluation-model route, billed against a Vercel account — useful to a host
316
+ * that already has one and does not want a second vendor relationship. They
317
+ * differ on the wire (vocabulary, where the model is named, and whether
318
+ * `confidence` is reported at all) and not at all in `DecisionResult`; the
319
+ * one thing to know is that gateway confidence is DERIVED from the
320
+ * probability distribution rather than being the vendor's calibrated figure,
321
+ * so a threshold tuned on one transport should be re-checked on the other.
322
+ *
323
+ * @see https://docs.typesafe.ai/api
324
+ */
325
+ export class TypeSafeProvider extends BaseProvider {
326
+ apiKey;
327
+ baseURL;
328
+ proxyFetch;
329
+ maxRetries;
330
+ /** Which wire this instance speaks. Resolved once, in the constructor. */
331
+ transport;
332
+ /**
333
+ * Set once the key has been rejected. A bad key cannot fix itself, and
334
+ * without this every later request would pay a full round trip (~265ms
335
+ * measured) to be told so again — a permanent tax on the hot path for a
336
+ * misconfiguration.
337
+ */
338
+ authFailed = false;
339
+ constructor(modelName, sdk, _region, credentials) {
340
+ const validatedNeurolink = isNeuroLink(sdk) ? sdk : undefined;
341
+ super(modelName, "typesafe", validatedNeurolink);
342
+ const directKey = credentials?.apiKey?.trim() ||
343
+ (process.env.TYPESAFE_API_KEY?.trim() ?? "");
344
+ const gatewayKey = credentials?.gatewayApiKey?.trim() ||
345
+ (process.env.AI_GATEWAY_API_KEY?.trim() ?? "");
346
+ // Explicit choice wins; otherwise the gateway is used only when it is the
347
+ // ONLY key available. A host holding both keeps the direct transport,
348
+ // because that is the one whose confidence figures are the vendor's own
349
+ // calibrated values rather than derived from the distribution.
350
+ const requested = credentials?.transport ??
351
+ process.env.TYPESAFE_TRANSPORT?.trim();
352
+ this.transport =
353
+ requested === "gateway" || requested === "direct"
354
+ ? requested
355
+ : !directKey && gatewayKey
356
+ ? "gateway"
357
+ : "direct";
358
+ this.apiKey = this.transport === "gateway" ? gatewayKey : directKey;
359
+ this.baseURL = (credentials?.baseURL ??
360
+ process.env.TYPESAFE_BASE_URL ??
361
+ TYPESAFE_DEFAULT_BASE_URL).replace(/\/+$/, "");
362
+ this.proxyFetch = createProxyFetch();
363
+ this.maxRetries = DEFAULT_MAX_RETRIES;
364
+ logger.debug("TypeSafe Provider initialized (decide only)", {
365
+ modelName: this.modelName,
366
+ transport: this.transport,
367
+ baseURL: this.transport === "gateway" ? GATEWAY_URL : this.baseURL,
368
+ });
369
+ }
370
+ // ===== Required abstract overrides =====
371
+ getProviderName() {
372
+ return this.providerName;
373
+ }
374
+ getDefaultModel() {
375
+ return getProviderModel("TYPESAFE_MODEL", TypeSafeModels.JEV_LATEST);
376
+ }
377
+ getDefaultDecisionModel() {
378
+ return this.getDefaultModel();
379
+ }
380
+ supportsTools() {
381
+ return false;
382
+ }
383
+ /**
384
+ * Jev produces no text, so there is no chat model behind this provider.
385
+ * Unreachable while the descriptor declares `inferenceKinds: ["decide"]`;
386
+ * kept actionable for the case where something routes here anyway.
387
+ */
388
+ getAISDKModel() {
389
+ throw new ProviderError("TypeSafe (Jev) is a decision-only provider; it generates no text, so chat completions are not available. Use `decide()` instead, or pick a different provider for `generate()` / `stream()`.", "typesafe");
390
+ }
391
+ async executeStream(_options, _analysisSchema) {
392
+ throw new ProviderError("TypeSafe (Jev) is a decision-only provider; streaming is not available. Use `decide()`, or pick another provider for `stream()`.", "typesafe");
393
+ }
394
+ /**
395
+ * Must RETURN the error, never throw it (CLAUDE.md rule 6).
396
+ */
397
+ formatProviderError(error) {
398
+ if (error instanceof ProviderError) {
399
+ return error;
400
+ }
401
+ const message = error instanceof Error ? error.message : String(error);
402
+ return new ProviderError(`TypeSafe request failed: ${message}`, "typesafe");
403
+ }
404
+ // ===== The decide inference type =====
405
+ /**
406
+ * Evaluate a state against a batch of typed questions.
407
+ *
408
+ * Throws a {@link ProviderError} carrying a normalised {@link DecisionError}
409
+ * in `cause`. Internal hot paths should prefer `NeuroLink.tryDecide()`,
410
+ * which returns null instead.
411
+ */
412
+ async decide(request) {
413
+ if (!this.apiKey) {
414
+ throw this.decisionError({
415
+ kind: "authentication",
416
+ message: this.transport === "gateway"
417
+ ? "The TypeSafe gateway transport requires a Vercel AI Gateway key. Set AI_GATEWAY_API_KEY or pass credentials.typesafe.gatewayApiKey."
418
+ : "TypeSafe requires an API key. Set TYPESAFE_API_KEY or pass credentials.typesafe.apiKey.",
419
+ retryable: false,
420
+ });
421
+ }
422
+ if (this.authFailed) {
423
+ throw this.decisionError({
424
+ kind: "authentication",
425
+ message: "TypeSafe rejected this API key earlier; not retrying. Fix the key and construct a new provider.",
426
+ retryable: false,
427
+ });
428
+ }
429
+ const questionEntries = Object.entries(request.questions);
430
+ if (questionEntries.length === 0) {
431
+ throw this.decisionError({
432
+ kind: "invalid_request",
433
+ message: "At least one question is required.",
434
+ retryable: false,
435
+ });
436
+ }
437
+ const wireQuestions = Object.fromEntries(questionEntries.map(([id, question]) => [
438
+ id,
439
+ toWireQuestion(question, this.transport),
440
+ ]));
441
+ const resolvedModel = request.model ?? this.modelName ?? this.getDefaultModel();
442
+ // The gateway names the model in a header and rejects a body that carries
443
+ // one; the direct API requires it in the body.
444
+ const body = JSON.stringify(this.transport === "gateway"
445
+ ? { state: request.state, questions: wireQuestions }
446
+ : {
447
+ model: resolvedModel,
448
+ state: request.state,
449
+ questions: wireQuestions,
450
+ });
451
+ const timeoutMs = request.timeoutMs ??
452
+ this.getDescriptorDecideMs() ??
453
+ this.defaultTimeout ??
454
+ DEFAULT_TIMEOUT_MS;
455
+ let lastError;
456
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
457
+ const startedAt = Date.now();
458
+ try {
459
+ const timeout = AbortSignal.timeout(timeoutMs);
460
+ const signal = request.signal
461
+ ? AbortSignal.any([request.signal, timeout])
462
+ : timeout;
463
+ const response = await this.proxyFetch(this.endpoint(), {
464
+ method: "POST",
465
+ headers: this.headers(),
466
+ body,
467
+ signal,
468
+ });
469
+ const latencyMs = Date.now() - startedAt;
470
+ const requestId = response.headers.get("x-typesafe-request-id") ?? undefined;
471
+ const upstreamMs = Number(response.headers.get("x-envoy-upstream-service-time")) ||
472
+ undefined;
473
+ const payload = await response.json().catch(() => null);
474
+ if (!response.ok) {
475
+ lastError = parseError(response.status, payload, requestId);
476
+ if (lastError.kind === "authentication") {
477
+ this.authFailed = true;
478
+ // The breaker is right to trip — none of these clear themselves
479
+ // inside one process — but it must not assert WHICH credential
480
+ // problem it was. A gateway 403 with a valid key and no card on
481
+ // file is not a rejected key, and saying so sends the reader off
482
+ // to regenerate the one thing that works.
483
+ logger.warn(`TypeSafe: credential not accepted — disabling this provider instance. ${lastError.message}`, {
484
+ status: lastError.status,
485
+ transport: this.transport,
486
+ requestId,
487
+ });
488
+ }
489
+ if (!lastError.retryable || attempt === this.maxRetries) {
490
+ throw this.decisionError(lastError);
491
+ }
492
+ await sleep(2 ** attempt * 250 + Math.random() * 250);
493
+ continue;
494
+ }
495
+ if (!isRecord(payload) || !isRecord(payload.answers)) {
496
+ throw this.decisionError({
497
+ kind: "server",
498
+ message: "TypeSafe returned a response without an answers map.",
499
+ status: response.status,
500
+ requestId,
501
+ retryable: false,
502
+ });
503
+ }
504
+ // The gateway does not omit confidence after all — it RELOCATES it,
505
+ // to `providerMetadata.typesafe.confidence.<questionId>`, and leaves
506
+ // the answer objects themselves without one. Preferring it over the
507
+ // derived peak matters because the two disagree on any distribution
508
+ // that is not already near-certain, and the router's asymmetric bars
509
+ // are tuned against the vendor's calibrated figure, not against
510
+ // max(probabilities).
511
+ const reported = readReportedConfidence(payload);
512
+ const answers = {};
513
+ for (const [id, raw] of Object.entries(payload.answers)) {
514
+ const parsed = parseAnswer(raw, reported[id]);
515
+ if (parsed) {
516
+ answers[id] = parsed;
517
+ }
518
+ else {
519
+ logger.warn(`TypeSafe: dropped unparseable answer "${id}"`, {
520
+ requestId,
521
+ });
522
+ }
523
+ }
524
+ const usage = isRecord(payload.usage) ? payload.usage : {};
525
+ return {
526
+ // `resolvedModel`, not `this.modelName`: when the caller pinned a
527
+ // model for this one request and the response omits its own, the
528
+ // instance default would be reported instead of the model actually
529
+ // asked for.
530
+ model: typeof payload.model === "string" ? payload.model : resolvedModel,
531
+ provider: this.providerName,
532
+ answers,
533
+ // Two spellings for one field. The direct API sends `input_tokens`;
534
+ // the gateway sends `inputTokens`. Reading only the first meant every
535
+ // gateway decision reported zero tokens — and since decisions are
536
+ // priced on input alone, every gateway decision was also costed at
537
+ // exactly $0, silently, in both the span attributes and the cost
538
+ // aggregate. Nothing failed; the number was simply always zero.
539
+ usage: {
540
+ inputTokens: asNumber(usage.input_tokens) ?? asNumber(usage.inputTokens) ?? 0,
541
+ outputTokens: asNumber(usage.output_tokens) ??
542
+ asNumber(usage.outputTokens) ??
543
+ 0,
544
+ },
545
+ requestId,
546
+ latencyMs,
547
+ upstreamMs,
548
+ };
549
+ }
550
+ catch (error) {
551
+ if (error instanceof ProviderError) {
552
+ throw error;
553
+ }
554
+ // An abort from the caller's own signal must not be retried — the
555
+ // turn is already being torn down.
556
+ const aborted = request.signal?.aborted === true;
557
+ const isTimeout = error instanceof Error && error.name === "TimeoutError";
558
+ lastError = {
559
+ kind: aborted ? "network" : isTimeout ? "timeout" : "network",
560
+ message: error instanceof Error ? error.message : String(error),
561
+ retryable: !aborted,
562
+ };
563
+ if (aborted || attempt === this.maxRetries) {
564
+ throw this.decisionError(lastError);
565
+ }
566
+ await sleep(2 ** attempt * 250 + Math.random() * 250);
567
+ }
568
+ }
569
+ throw this.decisionError(lastError ?? {
570
+ kind: "network",
571
+ message: "TypeSafe request failed.",
572
+ retryable: false,
573
+ });
574
+ }
575
+ /** Where a decision request goes for this instance's transport. */
576
+ endpoint() {
577
+ return this.transport === "gateway"
578
+ ? GATEWAY_URL
579
+ : `${this.baseURL}/v1/systemone`;
580
+ }
581
+ /**
582
+ * Headers for this instance's transport.
583
+ *
584
+ * The gateway's three `ai-*` headers are all load-bearing:
585
+ * `ai-gateway-protocol-version` omitted fails the request outright rather
586
+ * than defaulting, `ai-model-id` is how the model is named at all (the body
587
+ * carries no `model`), and `ai-evaluation-model-specification-version`
588
+ * selects the request/response shape this provider parses.
589
+ */
590
+ headers() {
591
+ const base = {
592
+ Authorization: `Bearer ${this.apiKey}`,
593
+ "Content-Type": "application/json",
594
+ };
595
+ return this.transport === "gateway"
596
+ ? {
597
+ ...base,
598
+ "ai-gateway-auth-method": "api-key",
599
+ "ai-gateway-protocol-version": GATEWAY_PROTOCOL_VERSION,
600
+ "ai-model-id": GATEWAY_MODEL_ID,
601
+ "ai-evaluation-model-specification-version": GATEWAY_SPEC_VERSION,
602
+ }
603
+ : base;
604
+ }
605
+ /** Which transport this instance resolved to. Exposed for diagnostics. */
606
+ getDecisionTransport() {
607
+ return this.transport;
608
+ }
609
+ /**
610
+ * The model aliases this key may send in `model`.
611
+ *
612
+ * Direct transport only: the gateway names the model in a header and
613
+ * publishes its own catalogue, so there is no equivalent listing behind
614
+ * this key.
615
+ */
616
+ async listDecisionModels() {
617
+ if (this.transport === "gateway") {
618
+ throw this.decisionError({
619
+ kind: "invalid_request",
620
+ message: "Model listing is a TypeSafe API operation; the gateway transport has no equivalent endpoint.",
621
+ retryable: false,
622
+ });
623
+ }
624
+ const response = await this.proxyFetch(`${this.baseURL}/v1/models`, {
625
+ headers: { Authorization: `Bearer ${this.apiKey}` },
626
+ signal: AbortSignal.timeout(this.defaultTimeout ?? DEFAULT_TIMEOUT_MS),
627
+ });
628
+ const payload = await response.json().catch(() => null);
629
+ if (!response.ok) {
630
+ throw this.decisionError(parseError(response.status, payload, response.headers.get("x-typesafe-request-id") ?? undefined));
631
+ }
632
+ const models = isRecord(payload) && Array.isArray(payload.models) ? payload.models : [];
633
+ return models.filter(isRecord).map((m) => ({
634
+ name: typeof m.name === "string" ? m.name : "",
635
+ description: typeof m.description === "string" ? m.description : "",
636
+ releaseDate: typeof m.release_date === "string" ? m.release_date : "",
637
+ }));
638
+ }
639
+ /**
640
+ * Wrap a normalised decision failure in the provider error type, keeping
641
+ * the structured detail reachable via `cause` for callers that branch on
642
+ * `kind` (authentication vs rate_limit vs max_tokens_exceeded).
643
+ */
644
+ decisionError(detail) {
645
+ const error = new ProviderError(detail.message, "typesafe");
646
+ Object.defineProperty(error, "cause", {
647
+ value: detail,
648
+ enumerable: false,
649
+ writable: true,
650
+ configurable: true,
651
+ });
652
+ return error;
653
+ }
654
+ }
@@ -25,7 +25,7 @@
25
25
  * console.log(response.answer, response.sources);
26
26
  * ```
27
27
  */
28
- import type { RAGPipelineConfig, IngestOptions, QueryOptions, RAGResponse, PipelineStats, MultiModalRAGConfig, MultiModalQuery, MultiModalSearchResult } from "../../types/index.js";
28
+ import type { RAGPipelineConfig, IngestOptions, QueryOptions, RAGResponse, PipelineStats, MultiModalRAGConfig, MultiModalQuery, MultiModalSearchResult, DecisionCallerFn } from "../../types/index.js";
29
29
  import { MDocument } from "../document/MDocument.js";
30
30
  export declare class RAGPipeline {
31
31
  private id;
@@ -155,4 +155,6 @@ export declare function createRAGPipeline(options: {
155
155
  enableHybrid?: boolean;
156
156
  enableGraph?: boolean;
157
157
  multiModal?: MultiModalRAGConfig;
158
+ /** Fail-open decision caller for per-query retrieval planning. */
159
+ decide?: DecisionCallerFn;
158
160
  }): RAGPipeline;