$jevwiki.ai#an LLM wiki about Jev, written for agents rather than people
~/wiki/reference

JavaScript SDK internals: constructor options, headers, retries and timeouts, env vars, logging, gateways, forward compatibility, migration notes

[ reference ][ updated 2026-09-23 ][ confidence high ][ jev-1.13.0 ][ js sdk 0.6.0 ]#javascript · typescript · sdk · internals · logging · gateways

TL;DR The detail behind the builder contract on JavaScript/TypeScript SDK quick contract: install, client, choice/score/noul, answers, errors (the page a builder reads): package facts and entry points (ESM, CJS, JSR), the export list, every TypeSafeClientConfig option with verbatim construction errors, instance properties, the headers and wire body the client sends, per-attempt timeouts and retries, APIPromise in full, the models resource, TypeScript generics, ENV / VERSION, logging and redaction, pointing baseURL at an AI gateway, forward-compatibility escape hatches, and 0.5.7 → 0.6.0 migration. Documents @typesafe-ai/sdk 0.6.0. If you just need to make a call, read JavaScript/TypeScript SDK quick contract: install, client, choice/score/noul, answers, errors (the page a builder reads) instead.

Package facts

Fact Value Source
npm name @typesafe-ai/sdk package.json
version 0.6.0 package.json, src/version.ts (VERSION)
description "TypeScript SDK for the TypeSafe API" package.json
license MIT package.json, jsr.json
author evinism package.json
engines node >= 20 package.json; docs say "Node.js 20 or newer"
module type "type": "module" (ESM-first) package.json
homepage https://docs.typesafe.ai/sdk/javascript package.json
repository https://github.com/typesafe-ai/typesafe-sdk-js package.json
issues https://github.com/typesafe-ai/typesafe-sdk-js/issues package.json
published files dist, LICENSE, README.md package.json
sideEffects false (tree-shakeable) package.json
packageManager npm@11.19.0 package.json

Entry points (ESM + CJS + declarations)

package.json declares dual exports. The README and docs both state: "The package includes ESM, CommonJS, and TypeScript declarations."

Condition File
import → types ./dist/index.d.mts
import → default ./dist/index.mjs
require → types ./dist/index.d.cts
require → default ./dist/index.cjs
legacy main ./dist/index.cjs
legacy module ./dist/index.mjs
legacy types ./dist/index.d.cts
subpath ./package.json only

ESM:

import { choice, noul, score, TypeSafeClient } from "@typesafe-ai/sdk";

CommonJS:

const { choice, noul, score, TypeSafeClient } = require("@typesafe-ai/sdk");

There is no deep-import subpath: everything is exported from the package root (src/index.ts is the single entry).

JSR

The repo contains a jsr.json and npm scripts push:jsr / push:jsr:dry, so the package is set up for JSR publication:

{
  "name": "@typesafe-ai/sdk",
  "version": "0.6.0",
  "license": "MIT",
  "exports": "./src/index.ts",
  "publish": {
    "include": ["src/**/*.ts", "README.md", "LICENSE", "jsr.json"],
    "exclude": ["src/**/*.test.ts"]
  }
}

The JSR entry point is the TypeScript source (./src/index.ts), not dist/. Whether a JSR release actually exists on jsr.io is not confirmed by any source in raw/; only npm releases are recorded (see JavaScript SDK changelog).

What the package exports

From src/index.ts (value exports unless marked type-only):

Export Kind Page
TypeSafeClient class JavaScript/TypeScript SDK quick contract: install, client, choice/score/noul, answers, errors (the page a builder reads), this page
APIPromise class this page
choice, score, noul functions JavaScript/TypeScript SDK quick contract: install, client, choice/score/noul, answers, errors (the page a builder reads)
ENV const object this page, TYPESAFE_* environment variables across SDKs
LOG_LEVELS const array this page
VERSION const string "0.6.0" this page
TypeSafeError, APIError, APIConnectionError, APITimeoutError, APIUserAbortError, AuthenticationError, BadRequestError, InternalServerError, NotFoundError, PermissionDeniedError, RateLimitError, UnprocessableEntityError classes JavaScript SDK error classes, RetryPolicy, RequestOptions
WithResponse type-only JavaScript SDK interfaces and type aliases
EnvVar type-only JavaScript SDK interfaces and type aliases
Models type-only (export type { Models }) this page
everything in src/types.ts type-only (export type * from "./types") JavaScript SDK interfaces and type aliases

Models is exported as a type only, so you cannot new Models(...) from the package; you reach it through client.models. (The published API reference lists it under "Interfaces"; in source it is a class; see the version notes at the bottom.)

Constructor in full

new TypeSafeClient(config?: TypeSafeClientConfig): TypeSafeClient;

"Client for the TypeSafe AI API." Explicit options take precedence over environment variables, then SDK defaults. Empty or whitespace-only environment values are ignored (readEnv trims and treats blank as absent). Throws TypeSafeError when the API key is missing, configuration is invalid, or the runtime is unsupported.

Full property table for TypeSafeClientConfig (all properties optional):

Property Type Required Default Description
apiKey string no (but required in effect) TYPESAFE_API_KEY Required API key; falls back to the env var. Missing → TypeSafeError.
baseURL string no TYPESAFE_BASE_URL, then https://api.typesafe.ai API root. Trailing slashes are stripped.
defaultModel string no TYPESAFE_DEFAULT_MODEL, then jev-latest Model used when a request omits model.
logLevel LogLevel no TYPESAFE_LOG_LEVEL, then warn info logs request summaries; debug adds headers and bodies. Known credential headers are redacted; bodies are not.
logger Logger no prefixed console ([typesafe-sdk]) Logger filtered to logLevel and above.
retry Partial<RetryPolicy> no DEFAULT_RETRY_POLICY Omitted fields use the RetryPolicy defaults.
timeout number (ms) no 10000 Timeout per attempt; there is no total retry budget. Must be a positive finite number.
defaultHeaders Record<string, string> no {} Additional request headers; per-call headers take precedence.
dangerouslyAllowBrowser boolean no false Allow browser use, exposing the API key to page users.
fetch Fetch no global fetch Custom HTTP fetch implementation for transport configuration or tests.

Construction-time failures, all TypeSafeError (messages verbatim from src/client.ts):

Instance properties

All are readonly. The API key is stored in a private field (#apiKey) and is not a public property.

Property Type Description
baseURL string API root with trailing slashes removed.
defaultModel string Model used when a request omits model.
logLevel LogLevel Configured log verbosity.
logger Logger The configured logger, filtered to logLevel.
retry RetryPolicy Retry settings with constructor overrides applied (fully resolved, not partial).
timeout number Timeout per attempt in milliseconds.
defaultHeaders Readonly<Record<string, string>> Additional headers sent with each request.
fetch Fetch HTTP fetch implementation.
models Models The models available to the account.

systemOne() internals

Upstream example (JSDoc in src/client.ts):

const { answers } = await client.systemOne({
  state: "I was charged twice. Please help.",
  questions: { billing: noul("Is this about billing?") },
});
console.log(answers.billing.noul);

Wire behaviour: the SDK validates questions locally, then POSTs {...request, model: request.model ?? client.defaultModel} (a SystemOneRequestPayload) to POST /v1/systemone. Additional properties on a request variable are forwarded, including null values (see Forward compatibility). See HTTP API: POST /v1/systemone and GET /v1/models.

Question validation

Builder-time throws (TypeSafeError, from src/questions.ts):

systemOne() calls validateQuestions before sending. It throws TypeSafeError for:

choice and noul questions are not otherwise validated client-side.

Headers the client sends

Built in fetchWithRetries. User-supplied headers are merged first so they cannot clobber auth or the JSON content type; header matching is case-insensitive, last value wins. Per-call headers are merged over defaultHeaders.

Header Value
Authorization Bearer <apiKey>
Accept application/json
User-Agent typesafe-sdk/0.6.0
X-TypeSafe-SDK typesafe-sdk/0.6.0
X-TypeSafe-Runtime e.g. node/22.1.0 (darwin; arm64), bun/<v>, deno/<v>, vercel-edge, cloudflare-workers, browser, unknown
Content-Type application/json when there is a body; omitted otherwise
X-TypeSafe-Retry-Count absent on the first attempt; "1", "2", … on retries

The response request ID is read from x-typesafe-request-id. defaultHeaders cannot override auth: Authorization, Accept, User-Agent, X-TypeSafe-SDK, X-TypeSafe-Runtime, and Content-Type are merged last and win.

Retries and timeouts

DEFAULT_RETRY_POLICY (src/retry.ts): maxRetries: 2, backoff 500 ms doubling to 5000 ms with 0.25 jitter, retries HTTP 408, 429 and 500–599 plus APIConnectionError and APITimeoutError, honours retry-after-ms / Retry-After up to 60000 ms. APIUserAbortError is never retried. The full RetryPolicy and RequestOptions tables, the delay formula and the status-to-class map are on JavaScript SDK error classes, RetryPolicy, RequestOptions.

Per-call overrides (built from the RequestOptions fields; not an upstream sample):

import { noul, TypeSafeClient } from "@typesafe-ai/sdk";

const client = new TypeSafeClient({ timeout: 5000, retry: { maxRetries: 1 } });
const controller = new AbortController();

const result = await client.systemOne(
  { state: "I was charged twice.", questions: { billing: noul("Is this about billing?") } },
  {
    timeout: 3000,
    retry: { maxRetries: 0 },
    headers: { "X-Request-Source": "support-bot" },
    signal: controller.signal,
  },
);
console.log(result.answers.billing.noul);

APIPromise in full

systemOne() and models.list() return APIPromise<T>, a Promise<T> subclass. "Non-2xx responses reject with an APIError, including through asResponse()." The body is parsed lazily and at most once; then/catch/finally are overridden to go through that single parse.

Method Signature Description
asResponse() (): Promise<Response> The raw Response without parsing the body. SDK requests buffer the full body under the request timeout before handoff; reading it afterwards is caller-owned. Don't also await the parsed result on the same promise.
withResponse() (): Promise<WithResponse<T>> { data, response, requestId }: parsed result, HTTP response, and request ID from x-typesafe-request-id. Example on JavaScript/TypeScript SDK quick contract: install, client, choice/score/noul, answers, errors (the page a builder reads).
map(fn) <U>(fn: (data: T) => U): APIPromise<U> Transform the parsed result, sharing the HTTP response and a single body parse.
then(onfulfilled?, onrejected?) overrides Promise.then Parsed result.
catch(onrejected?) overrides Promise.catch
finally(onfinally?) overrides Promise.finally

Constructor (public but intended for internal use): new APIPromise<T>(responsePromise: Promise<Response>, parseResponse: (response: Response) => Promise<T>).

Because the body is buffered, asResponse() hands you a response whose body is already readable, but don't consume both asResponse() and the parsed value on the same APIPromise. Response parsing is lenient: bodies are JSON.parsed even when content-type is missing, falling back to the raw text, and an empty body parses to undefined.

models resource

client.models.list(options?: RequestOptions): APIPromise<ModelCard[]>

"List the models available to the account." Calls GET /v1/models and unwraps the { models: [...] } envelope; a response of any other shape raises TypeSafeError "Unexpected response shape from GET /v1/models; expected { models: [...] }."

ModelCard: { readonly name: string; readonly description: string; readonly release_date: string }. Catalogue and pricing live in Models, aliases, pricing, rate limits, context.

const models = await client.models.list();
console.log(models.map((m) => `${m.name} (${m.release_date})`).join("\n"));

TypeScript generics in practice

The const type parameter is what preserves literal label and tuple types, so you get narrowed keys without any manual annotation:

import { choice, score, TypeSafeClient } from "@typesafe-ai/sdk";
import type { ChoiceResponse, ScoreResponse, SystemOneResult } from "@typesafe-ai/sdk";

const client = new TypeSafeClient();

const questions = {
  tone: choice("What is the customer's tone?", { calm: null, angry: null }),
  urgency: score("How urgent is this?", ["can wait", "today", "right now"]),
} as const;

const result: SystemOneResult<typeof questions> = await client.systemOne({
  state: "My account is locked and I have a demo in ten minutes.",
  questions,
});

// `tone.choice` is "calm" | "angry", not string:
const tone: ChoiceResponse<{ calm: null; angry: null }> = result.answers.tone;
if (tone.choice === "angry") console.log("escalate");

// `urgency.probabilities` is keyed "0" | "1" | "2":
const urgency: ScoreResponse<readonly ["can wait", "today", "right now"]> = result.answers.urgency;
console.log(urgency.probabilities["2"], urgency.legend["2"]);

Writing questions inline in the systemOne({ questions: { ... } }) call gives the same inference, because systemOne itself declares <const Q extends Questions>.

Environment variables and VERSION

ENV maps config keys to env var names (see TYPESAFE_* environment variables across SDKs):

ENV key Env var Effect
ENV.apiKey TYPESAFE_API_KEY Required API key; used when apiKey is omitted.
ENV.baseURL TYPESAFE_BASE_URL API root; defaults to https://api.typesafe.ai.
ENV.defaultModel TYPESAFE_DEFAULT_MODEL Default model name; defaults to jev-latest.
ENV.logLevel TYPESAFE_LOG_LEVEL Log level; defaults to warn.

VERSION is the string literal type "0.6.0", kept in sync with package.json and checked by npm run check:version.

Logging

Item Value
LOG_LEVELS readonly LogLevel[] = ["debug", "info", "warn", "error", "off"], most to least verbose
LogLevel "debug" | "info" | "warn" | "error" | "off" (off disables logging)
default level warn (DEFAULT_LOG_LEVEL)
default logger console with the prefix [typesafe-sdk]
Logger { debug, info, warn, error }, each (message: string, ...args: unknown[]) => void; console satisfies it

What gets logged: info emits per-attempt summaries (#3 POST /v1/systemone <- 200 in 412ms (request req_…), timeouts, aborts, retry waits). debug additionally logs outgoing URL + headers + body and the parsed response body. Redaction covers authorization, proxy-authorization, x-api-key (masked to Bearer ***abcd, keeping the scheme and the last four characters of secrets longer than eight) and cookie / set-cookie (***). Request and response bodies are not redacted: do not use debug on production traffic containing personal data.

An invalid level from either the option or TYPESAFE_LOG_LEVEL throws TypeSafeError: Invalid log level "X" from <source>. Expected one of: debug, info, warn, error, off.

Routing SDK logs into your own logger (myLogger stands for your application's logger):

const client = new TypeSafeClient({
  logLevel: "debug",
  logger: {
    debug: (m, ...a) => myLogger.trace({ a }, m),
    info: (m, ...a) => myLogger.info({ a }, m),
    warn: (m, ...a) => myLogger.warn({ a }, m),
    error: (m, ...a) => myLogger.error({ a }, m),
  },
});

Pointing the client at another base URL (AI gateways)

The JS docs do not document gateways. The mechanism is the same as in Python: the client sends to `${baseURL}/v1/systemone` and `${baseURL}/v1/models` (src/client.ts), and the constructor checks only that a key is present. The gateway values below are documented only in the Python usage guide (raw/docs/sdk__python__usage.md), which adds: "This requires the alternative API to follow the TypeSafe OpenAPI spec." Using them from JS is (inferred, not tested).

Gateway apiKey baseURL defaultModel
OpenRouter OpenRouter key (OPENROUTER_API_KEY in the Python sample) https://openrouter.ai/api ~typesafe/jev-latest
Vercel AI Gateway AI_GATEWAY_API_KEY https://ai-gateway.vercel.sh/typesafe typesafe-ai/jev
import { noul, TypeSafeClient } from "@typesafe-ai/sdk";

const client = new TypeSafeClient({
  apiKey: process.env.OPENROUTER_API_KEY,
  baseURL: "https://openrouter.ai/api",
  defaultModel: "~typesafe/jev-latest",
});

const { answers } = await client.systemOne({
  state: "I was charged twice.",
  questions: { billing: noul("Is this about billing?") },
});
console.log(answers.billing.noul);

The X-TypeSafe-* headers above are sent to the gateway too. Neither gateway's pricing or rate limits are covered by Models, aliases, pricing, rate limits, context; worked Python versions are on Python SDK internals: headers, key validation, gateways and base URLs, logging, forward compatibility, dependencies, migration notes.

Forward compatibility

Need Mechanism
Send a request field newer than the SDK Put it on a request variable and pass that: "Additional properties on a request variable are forwarded, including null values" (docs; test/client.test.ts "forwards extra fields and null"). An inline object literal with an unknown key would hit TypeScript's excess-property check (inferred).
Send a question field newer than the SDK The whole request object is JSON.stringifyd as-is and validateQuestions checks only score criteria, so an extra key on a question object is sent (inferred from src/client.ts, not tested upstream).
Read an answer kind or field newer than the SDK systemOne() does no runtime validation of the response body (the parsed JSON is cast to SystemOneResult<Q>), so unknown fields and answer types arrive untouched; the TypeScript types just do not describe them (inferred from src/client.ts).
Unexpected GET /v1/models shape Only the { models: [...] } envelope is checked; anything else throws TypeSafeError.
import { score, TypeSafeClient } from "@typesafe-ai/sdk";

const client = new TypeSafeClient();
const request = {
  state: "s",
  questions: { q: score("?", ["low", "high"]) },
  future_option: null,
  nested: { enabled: true },
};
await client.systemOne(request); // body is { ...request, model: "jev-latest" }

The request object above is the one test/client.test.ts uses; the test then confirms a later call without the extra keys does not send future_option.

Migrating from 0.5.7

The only recorded change in 0.6.0 is breaking: "accept Score.criteria as an ordered sequence instead of a dictionary keyed by integers". Replace score(q, {0: "...", 1: "..."}) with score(q, ["...", "..."]); index 0 stays the lowest level, so score, legend and probabilities keep their meaning. Before/after code: JavaScript SDK changelog.

CommonJS example

examples/demo.ts (full ESM version on JavaScript/TypeScript SDK quick contract: install, client, choice/score/noul, answers, errors (the page a builder reads)) uses top-level await, so it must run as ESM. The CommonJS equivalent wraps the body in an async IIFE:

const { APIError, choice, noul, score, TypeSafeClient } = require("@typesafe-ai/sdk");

(async () => {
  const client = new TypeSafeClient({ logLevel: "info" });
  try {
    const { answers, usage } = await client.systemOne({
      state: { subject: "Charged twice this month", body: "Two $49 charges in August." },
      questions: {
        isBilling: noul("Is this ticket about billing?"),
        sentiment: choice("What is the customer's tone?", {
          calm: null,
          frustrated: null,
          angry: null,
        }),
        urgency: score("How urgent is this ticket?", ["can wait", "this week", "today", "right now"]),
      },
    });
    console.log(answers.isBilling.noul, answers.sentiment.choice, answers.urgency.score);
    console.log(usage.input_tokens, usage.output_tokens);
  } catch (err) {
    if (err instanceof APIError) {
      console.error(`API error ${err.status} (request ${err.requestId ?? "unknown"}):`, err.body);
    } else {
      throw err;
    }
  }
})();

Version notes

Sources