@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.
- package/CHANGELOG.md +2 -2
- 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/proxy/proxyHistoryTruncation.js +15 -10
- 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,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
|
+
}
|