UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

333 lines (332 loc) • 15 kB
import { TypeSafeModels } from "../constants/enums.js"; import { logger } from "../utils/logger.js"; import { redactUrlForError } from "../utils/logSanitize.js"; import { getProviderModel } from "../utils/providerConfig.js"; import { asNumber, DEFAULT_DECISION_TIMEOUT_MS, describeValidationErrors, isRecord, SystemOneDecisionProvider, } from "./systemOneDecision.js"; const TYPESAFE_DEFAULT_BASE_URL = "https://api.typesafe.ai"; /** * Vercel AI Gateway's evaluation-model endpoint. * * Vercel's AI SDK models a decision model as `EvaluationModelV4`, separate * from `LanguageModelV4` — the same conclusion this codebase reached with * `inferenceKinds`, arrived at independently. The gateway therefore has its * own route rather than reusing the chat one. */ const TYPESAFE_DEFAULT_GATEWAY_URL = "https://ai-gateway.vercel.sh/v4/ai/evaluation-model"; /** Gateway model id for Jev. The gateway carries it in a header, not the body. */ const GATEWAY_MODEL_ID = "typesafe-ai/jev"; /** * Protocol version header the gateway requires. Omitting it fails the whole * request with a 400 ("Unsupported gateway protocol version") rather than * defaulting, so it is not optional. */ const GATEWAY_PROTOCOL_VERSION = "0.0.1"; /** Evaluation-model spec version the request body conforms to. */ const GATEWAY_SPEC_VERSION = "4"; /** * The binding limit in practice: `state` plus the SINGLE LONGEST question. * Measured by bisection at single-character resolution — 33,002 reported * input tokens accepted, 33,003 not. */ export const TYPESAFE_MAX_STATE_TOKENS = 33_000; /** * The separate, larger ceiling on `state` plus ALL questions combined. A * request can carry a near-ceiling state *and* 400 extra questions (37,679 * tokens total, verified) — questions do not compete with state for the * 33K budget, only for this one. */ export const TYPESAFE_MAX_REQUEST_TOKENS = 64_000; /** * Per-question confidence the gateway reports out-of-band, keyed by question * id, under `providerMetadata.typesafe.confidence`. Absent on the direct * transport, which puts confidence on each answer instead. */ function readReportedConfidence(payload) { if (!isRecord(payload) || !isRecord(payload.providerMetadata)) { return {}; } const vendor = payload.providerMetadata.typesafe; if (!isRecord(vendor) || !isRecord(vendor.confidence)) { return {}; } const out = {}; for (const [id, value] of Object.entries(vendor.confidence)) { const n = asNumber(value); if (n !== undefined) { out[id] = n; } } return out; } /** * Gateway error `type` → our kind. The gateway's own slugs are the only * reliable discriminator: it returns 403 for both a credential that cannot * reach AI Gateway and an account with no card on file, and those want very * different words in front of a reader. */ const GATEWAY_ERROR_KIND = { authentication_error: "authentication", customer_verification_required: "authentication", invalid_request_error: "invalid_request", rate_limit_exceeded: "rate_limit", internal_server_error: "server", }; function parseGatewayError(status, error, requestId, retryable) { const type = typeof error.type === "string" ? error.type : undefined; const kind = (type ? GATEWAY_ERROR_KIND[type] : undefined) ?? (status === 401 || status === 403 ? "authentication" : status === 429 ? "rate_limit" : status >= 500 ? "server" : "invalid_request"); const message = typeof error.message === "string" && error.message.length > 0 ? error.message : (type ?? `AI Gateway request failed with HTTP ${status}`); return { kind, message, status, requestId, retryable }; } /** * Normalise the three error envelopes into one shape. * * Shape A (direct, application): `{"detail":{"error_type":"...","message":"..."}}` * Shape B (direct, validation): `{"detail":[{"type":"missing","loc":[...],...}]}` * Shape C (gateway): `{"error":{"message":"...","type":"...","code":400}}` * * Shape B echoes the offending `input` back, which can contain the caller's * state, so only `loc`/`msg` are surfaced — never `input`. * * Shape C is a different vendor's envelope reached through the same method, * and it was invisible until the gateway was exercised live: every gateway * failure fell through to `HTTP <status>` with the real cause discarded. The * worst case was a 403 `customer_verification_required` — a billing state, * not a bad key — reported as a flat 403 and then tripping the auth circuit * breaker under the message "API key rejected", which points the reader at * the one thing that is not wrong. */ function parseTypeSafeError(status, body, requestId) { const retryable = status === 429 || status === 529 || status >= 500; const gateway = isRecord(body) && isRecord(body.error) ? body.error : undefined; if (gateway) { return parseGatewayError(status, gateway, requestId, retryable); } const detail = isRecord(body) ? body.detail : undefined; if (Array.isArray(detail)) { return { kind: "invalid_request", message: `Request failed validation — ${describeValidationErrors(detail)}`, status, requestId, retryable: false, }; } const errorType = isRecord(detail) && typeof detail.error_type === "string" ? detail.error_type : undefined; // 403 for a MISSING Authorization header and 401 for an INVALID key — // inverted from the usual convention, and from TypeSafe's own docs, which // collapse both into one 401 row. Verified live. const kind = errorType === "max_tokens_exceeded" ? "max_tokens_exceeded" : status === 401 || status === 403 ? "authentication" : status === 429 ? "rate_limit" : status === 529 ? "overloaded" : status >= 500 ? "server" : "invalid_request"; // `max_tokens_exceeded` is returned with no `message` at all, and it is the // one error a long-context caller is most likely to hit — so the fallback // has to be a real sentence rather than the bare error_type slug. const fallback = kind === "max_tokens_exceeded" ? `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}.` : (errorType ?? `TypeSafe request failed with HTTP ${status}`); const message = isRecord(detail) && typeof detail.message === "string" ? detail.message : fallback; return { kind, message, status, requestId, retryable }; } /** * TypeSafe Provider — the `decide` inference type only. * * Jev is a "System One" model: it takes one `state` plus a map of typed * questions and returns one typed answer each, evaluated in a single * parallel pass. The request loop, retries, auth circuit breaker and answer * parsing are shared with every decision provider in * {@link SystemOneDecisionProvider}; this class holds only what is TypeSafe's. * * Its descriptor declares `inferenceKinds: ["decide"]`, which keeps it out * of auto-select and the health sweep. * * Two measured properties shape the API: latency is flat in question count * (1 question 393ms, 400 questions 465ms) while concurrent requests queue. * Callers therefore batch every question into one `decide()` call. * * Two transports reach the same model. The direct one is TypeSafe's own API, * billed against a TypeSafe key. The gateway one is Vercel's AI Gateway * evaluation-model route, billed against a Vercel account — useful to a host * that already has one and does not want a second vendor relationship. They * differ on the wire (vocabulary, where the model is named, and where * `confidence` is reported) and not at all in `DecisionResult`. * * @see https://docs.typesafe.ai/api */ export class TypeSafeProvider extends SystemOneDecisionProvider { apiKey; baseURL; gatewayURL; /** Which wire this instance speaks. Resolved once, in the constructor. */ transport; constructor(modelName, sdk, _region, credentials) { super(modelName, "typesafe", sdk); const directKey = credentials?.apiKey?.trim() || (process.env.TYPESAFE_API_KEY?.trim() ?? ""); const gatewayKey = credentials?.gatewayApiKey?.trim() || (process.env.AI_GATEWAY_API_KEY?.trim() ?? ""); // Explicit choice wins; otherwise the gateway is used only when it is the // ONLY key available. A host holding both keeps the direct transport, // because that is the one whose confidence figures are the vendor's own // calibrated values rather than derived from the distribution. const requested = credentials?.transport ?? process.env.TYPESAFE_TRANSPORT?.trim(); this.transport = requested === "gateway" || requested === "direct" ? requested : !directKey && gatewayKey ? "gateway" : "direct"; this.apiKey = this.transport === "gateway" ? gatewayKey : directKey; this.baseURL = (credentials?.baseURL ?? process.env.TYPESAFE_BASE_URL ?? TYPESAFE_DEFAULT_BASE_URL).replace(/\/+$/, ""); this.gatewayURL = (credentials?.gatewayURL?.trim() || process.env.TYPESAFE_GATEWAY_URL?.trim() || TYPESAFE_DEFAULT_GATEWAY_URL).replace(/\/+$/, ""); logger.debug("TypeSafe Provider initialized (decide only)", { modelName: this.modelName, transport: this.transport, baseURL: redactUrlForError(this.transport === "gateway" ? this.gatewayURL : this.baseURL), }); } getDefaultModel() { return getProviderModel("TYPESAFE_MODEL", TypeSafeModels.JEV_LATEST); } vendorLabel() { return "TypeSafe"; } vendorDisplayName() { return "TypeSafe (Jev)"; } decisionApiKey() { return this.apiKey; } missingKeyMessage() { return this.transport === "gateway" ? "The TypeSafe gateway transport requires a Vercel AI Gateway key. Set AI_GATEWAY_API_KEY or pass credentials.typesafe.gatewayApiKey." : "TypeSafe requires an API key. Set TYPESAFE_API_KEY or pass credentials.typesafe.apiKey."; } /** * The gateway already speaks the neutral vocabulary this codebase uses, so * only the direct transport needs the `noul` rename. */ encodeQuestion(question) { return this.transport === "gateway" ? question : super.encodeQuestion(question); } /** * The gateway names the model in a header and rejects a body that carries * one; the direct API requires it in the body. */ buildDecisionBody(state, questions, model) { return this.transport === "gateway" ? { state, questions } : { model, state, questions }; } parseDecisionError(status, payload, requestId) { return parseTypeSafeError(status, payload, requestId); } /** * The gateway does not omit confidence after all — it RELOCATES it, to * `providerMetadata.typesafe.confidence.<questionId>`, and leaves the answer * objects themselves without one. Preferring it over the derived peak * matters because the two disagree on any distribution that is not already * near-certain, and the router's asymmetric bars are tuned against the * vendor's calibrated figure, not against max(probabilities). */ reportedConfidence(payload) { return readReportedConfidence(payload); } readRequestId(headers) { return headers.get("x-typesafe-request-id") ?? undefined; } diagnosticContext() { return { transport: this.transport }; } /** Where a decision request goes for this instance's transport. */ decisionEndpoint() { return this.transport === "gateway" ? this.gatewayURL : `${this.baseURL}/v1/systemone`; } /** * Headers for this instance's transport. * * The gateway's three `ai-*` headers are all load-bearing: * `ai-gateway-protocol-version` omitted fails the request outright rather * than defaulting, `ai-model-id` is how the model is named at all (the body * carries no `model`), and `ai-evaluation-model-specification-version` * selects the request/response shape this provider parses. */ decisionHeaders() { const base = { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", }; return this.transport === "gateway" ? { ...base, "ai-gateway-auth-method": "api-key", "ai-gateway-protocol-version": GATEWAY_PROTOCOL_VERSION, "ai-model-id": GATEWAY_MODEL_ID, "ai-evaluation-model-specification-version": GATEWAY_SPEC_VERSION, } : base; } /** Which transport this instance resolved to. Exposed for diagnostics. */ getDecisionTransport() { return this.transport; } /** * The model aliases this key may send in `model`. * * Direct transport only: the gateway names the model in a header and * publishes its own catalogue, so there is no equivalent listing behind * this key. */ async listDecisionModels() { if (this.transport === "gateway") { throw this.decisionError({ kind: "invalid_request", message: "Model listing is a TypeSafe API operation; the gateway transport has no equivalent endpoint.", retryable: false, }); } const response = await this.proxyFetch(`${this.baseURL}/v1/models`, { headers: { Authorization: `Bearer ${this.apiKey}` }, signal: AbortSignal.timeout(this.defaultTimeout ?? DEFAULT_DECISION_TIMEOUT_MS), }); const payload = await response.json().catch(() => null); if (!response.ok) { throw this.decisionError(parseTypeSafeError(response.status, payload, response.headers.get("x-typesafe-request-id") ?? undefined)); } const models = isRecord(payload) && Array.isArray(payload.models) ? payload.models : []; return models.filter(isRecord).map((m) => ({ name: typeof m.name === "string" ? m.name : "", description: typeof m.description === "string" ? m.description : "", releaseDate: typeof m.release_date === "string" ? m.release_date : "", })); } }