JavaScript SDK internals: constructor options, headers, retries and timeouts, env vars, logging, gateways, forward compatibility, migration notes
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
TypeSafeClientConfigoption with verbatim construction errors, instance properties, the headers and wire body the client sends, per-attempt timeouts and retries,APIPromisein full, themodelsresource, TypeScript generics,ENV/VERSION, logging and redaction, pointingbaseURLat an AI gateway, forward-compatibility escape hatches, and 0.5.7 → 0.6.0 migration. Documents@typesafe-ai/sdk0.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):
- Browser detected and
dangerouslyAllowBrowsernot set: "TypeSafeClient is running in a browser, which would expose your API key to anyone using the page. Call the API from a server instead, or passdangerouslyAllowBrowser: trueif you understand the risk." Detection (isBrowser()insrc/runtime.ts) checks forwindow.documentandnavigator. - No key: "No API key was provided. Pass
apiKeyto the TypeSafeClient constructor or set the TYPESAFE_API_KEY environment variable." - No global fetch and no
fetchoption: "No globalfetchis available in this runtime. Pass afetchimplementation to the TypeSafeClient constructor." - Invalid
timeout: "timeoutmust be a positive number of milliseconds, got X."
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):
choice()given an array: "Choice criteria must be a map of labels to descriptions, not a list."score()given an object: "Score criteria must be a list of descriptions indexed by score from zero, not a map."
systemOne() calls validateQuestions before sending. It throws TypeSafeError for:
- an empty object: "At least one question is required."
- a
scorequestion whosecriteriais not an array:Score question "<name>" has criteria that are not a list; score criteria must be a list of descriptions indexed by score from zero. - a
scorequestion with fewer than two criteria:Score question "<name>" has N criteria; at least two scores are required.
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.
timeoutis per attempt, not per call. WithmaxRetries: 2andtimeout: 10000, worst-case wall time is roughly three attempts plus backoff.- The body is fully buffered before the promise resolves, under the same timeout, so a slow body counts against the attempt.
- A caller
signalcancels the in-flight attempt and any pending retry wait; both surface asAPIUserAbortError. - Per-call
retryinherits unset fields from the client's resolved policy; per-calltimeoutmust also be a positive finite number.
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
- This page describes
@typesafe-ai/sdk0.6.0 (repo commit66880ccded6cb642dc1809620c2b108c33730214, 2026-09-15). - Doc-vs-source discrepancy: the published API reference lists
Modelsunder Interfaces (# Interface: Models), butsrc/resources/models.tsdeclaresexport class Modelswith a constructor taking an internalTransport. Becausesrc/index.tsre-exports it withexport type { Models }, only the type is importable: the docs' classification is accurate from a consumer's point of view, but the source is a class. - The docs'
ChoiceCriteriaindex signature renders as[label: string]: EntryType; source writes[label: string]: Description, andDescription = EntryType, so they are the same type. - 2026-09-23: this page was split out of JavaScript/TypeScript SDK quick contract: install, client, choice/score/noul, answers, errors (the page a builder reads), which now carries only the builder contract.
Related
- JavaScript/TypeScript SDK quick contract: install, client, choice/score/noul, answers, errors (the page a builder reads) — the builder contract: install, client,
systemOne(), builders, answers, errors - JavaScript SDK interfaces and type aliases — every interface and type alias in detail
- JavaScript SDK error classes, RetryPolicy, RequestOptions — error classes,
RetryPolicy,RequestOptions - JavaScript SDK changelog — release history and the 0.6.0 migration code
- TYPESAFE_* environment variables across SDKs —
TYPESAFE_*across SDKs - HTTP API: POST /v1/systemone and GET /v1/models — the wire contract behind
systemOne()andmodels.list() - Python SDK internals: headers, key validation, gateways and base URLs, logging, forward compatibility, dependencies, migration notes — the Python equivalent, with the documented gateway samples
Sources
- raw/docs/sdk__javascript.md (https://docs.typesafe.ai/sdk/javascript)
- raw/docs/sdk__javascript__api.md (https://docs.typesafe.ai/sdk/javascript/api)
- raw/docs/sdk__javascript__api__classes__TypeSafeClient.md (https://docs.typesafe.ai/sdk/javascript/api/classes/TypeSafeClient)
- raw/docs/sdk__javascript__api__classes__APIPromise.md (https://docs.typesafe.ai/sdk/javascript/api/classes/APIPromise)
- raw/docs/sdk__javascript__api__interfaces__Models.md, __Logger.md, __SystemOneRequest.md, __RequestOptions.md
- raw/docs/sdk__javascript__api__variables__VERSION.md, __LOG_LEVELS.md, __ENV.md
- raw/docs/sdk__javascript__changelog.md (https://docs.typesafe.ai/sdk/javascript/changelog)
- raw/docs/sdk__python__usage.md (https://docs.typesafe.ai/sdk/python/usage.md) — gateway values
- raw/github/typesafe-sdk-js (https://github.com/typesafe-ai/typesafe-sdk-js, commit 66880ccded6cb642dc1809620c2b108c33730214): README.md, package.json, jsr.json, examples/demo.ts, src/index.ts, src/client.ts, src/questions.ts, src/api-promise.ts, src/retry.ts, src/logging.ts, src/env.ts, src/runtime.ts, src/version.ts, src/resources/models.ts, test/client.test.ts