Release v0.6.0

Co-authored-by: Allie Laabs <allie@typesafe.ai>
Co-authored-by: Evin Sellin <evinism@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-09-15 18:16:33 +00:00
1 parent 04b2ad750c
commit 66880ccded
13 files changed
+104 -178

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Changelog
## v0.6.0 (2026-09-15)
### Breaking changes
- accept `Score.criteria` as an ordered sequence instead of a dictionary keyed by integers
## v0.5.7 (2026-09-11)
This is the initial public release of TypeSafe JavaScript and TypeScript SDK. Learn more in the [documentation](https://docs.typesafe.ai/sdk/javascript).
+5 -5
View File
@@ -22,11 +22,11 @@ try {
angry: null,
}),
urgency: score("How urgent is this ticket?", ["can wait", "this week", "today", "right now"]),
refundRisk: score("How likely is the customer to demand a refund?", {
0: "unlikely",
1: "possible",
2: "likely",
}),
refundRisk: score("How likely is the customer to demand a refund?", [
"unlikely",
"possible",
"likely",
]),
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@typesafe-ai/sdk",
"version": "0.5.7",
"version": "0.6.0",
"license": "MIT",
"exports": "./src/index.ts",
"publish": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@typesafe-ai/sdk",
"version": "0.5.7",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@typesafe-ai/sdk",
"version": "0.5.7",
"version": "0.6.0",
"license": "MIT",
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@typesafe-ai/sdk",
"version": "0.5.7",
"version": "0.6.0",
"description": "TypeScript SDK for the TypeSafe API",
"license": "MIT",
"author": "evinism",
+3 -3
View File
@@ -14,7 +14,7 @@ import {
redactHeaders,
withLevel,
} from "./logging";
import { toWireQuestions } from "./questions";
import { validateQuestions } from "./questions";
import { Models } from "./resources/models";
import {
DEFAULT_RETRY_POLICY,
@@ -294,7 +294,7 @@ export class TypeSafeClient {
* @param request - State, questions, and an optional model override.
* @param options - Per-call timeout, retry, headers, and cancellation settings.
* @returns Answers typed by question name and criteria, with model and token usage.
* @throws {TypeSafeError} Questions or score criteria are empty, or score keys are invalid.
* @throws {TypeSafeError} Questions are empty, or score criteria are not a list of at least two entries.
* @throws {APIError} The server returns a non-2xx response after retries.
* @throws {APIConnectionError} The request cannot connect or times out after retries.
* @throws {APIUserAbortError} The caller aborts the request.
@@ -312,10 +312,10 @@ export class TypeSafeClient {
request: SystemOneRequest<Q>,
options: RequestOptions = {},
): APIPromise<SystemOneResult<Q>> {
validateQuestions(request.questions);
const body = {
...request,
model: request.model ?? this.defaultModel,
questions: toWireQuestions(request.questions),
} satisfies SystemOneRequestPayload;
return this.#request<SystemOneResult<Q>>("POST", "/v1/systemone", {
+24 -52
View File
@@ -6,8 +6,6 @@ import type {
NoulQuestion,
Questions,
ScoreCriteria,
ScoreList,
ScoreMap,
ScoreQuestion,
} from "./types";
@@ -34,16 +32,19 @@ export const noul = (
* Create a score question using an ordered rubric.
*
* @param instructions - The question as text, a JSON object or array, or `null`.
* @param criteria - A nonempty array or map indexed from zero with no gaps; descriptions may be `null`.
* @param criteria - At least two descriptions indexed by score from zero; entries may be `null`.
*/
export const score = <const T extends ScoreCriteria>(
instructions: EntryType,
criteria: T,
): ScoreQuestion<T> => ({
type: "score",
instructions,
criteria,
});
): ScoreQuestion<T> => {
if (!Array.isArray(criteria)) {
throw new TypeSafeError(
"Score criteria must be a list of descriptions indexed by score from zero, not a map.",
);
}
return { type: "score", instructions, criteria };
};
/**
* Create a question that selects between named alternatives.
@@ -62,56 +63,27 @@ export const choice = <const T extends ChoiceCriteria>(
};
// ---------------------------------------------------------------------------
// Wire normalization
// Validation
// ---------------------------------------------------------------------------
const isScoreList = (criteria: ScoreCriteria): criteria is ScoreList => Array.isArray(criteria);
/** Validate score keys and convert the map to a nonempty array indexed from zero. */
export const scoreMapToList = (map: ScoreMap, name: string): ScoreList => {
const keys = Object.keys(map)
.map((key) => {
const n = Number(key);
if (!Number.isInteger(n) || n < 0) {
throw new TypeSafeError(
`Score question "${name}" has criteria key "${key}"; keys must be non-negative integers.`,
);
}
return n;
})
.sort((a, b) => a - b);
if (keys.length === 0) throw noScores(name);
const expected = keys.map((_, i) => i);
if (keys.some((k, i) => k !== expected[i])) {
throw new TypeSafeError(
`Score question "${name}" defines scores ${keys.join(", ")}, but scores must run from 0 ` +
`with no gaps (expected ${expected.join(", ")}).`,
);
}
return keys.map((k) => map[k]) as unknown as ScoreList;
};
const noScores = (name: string): TypeSafeError =>
new TypeSafeError(`Score question "${name}" has no criteria; at least one score is required.`);
/** Validate nonempty questions and score criteria, converting score maps to arrays. */
export const toWireQuestions = <Q extends Questions>(questions: Q): Q => {
/** Reject empty question sets and score questions without a list of at least two criteria. */
export const validateQuestions = (questions: Questions): void => {
if (Object.keys(questions).length === 0) {
throw new TypeSafeError("At least one question is required.");
}
let changed = false;
const wire: Questions = Object.create(null);
for (const [name, question] of Object.entries(questions)) {
if (question.type !== "score") {
wire[name] = question;
} else if (isScoreList(question.criteria)) {
if (question.criteria.length === 0) throw noScores(name);
wire[name] = question;
} else {
wire[name] = { ...question, criteria: scoreMapToList(question.criteria, name) };
changed = true;
if (question.type !== "score") continue;
if (!Array.isArray(question.criteria)) {
throw new TypeSafeError(
`Score question "${name}" has criteria that are not a list; ` +
"score criteria must be a list of descriptions indexed by score from zero.",
);
}
if (question.criteria.length < 2) {
throw new TypeSafeError(
`Score question "${name}" has ${question.criteria.length} criteria; ` +
"at least two scores are required.",
);
}
}
return changed ? (wire as Q) : questions;
};
+6 -17
View File
@@ -45,16 +45,8 @@ export interface ChoiceQuestion<T extends ChoiceCriteria = ChoiceCriteria> {
criteria: T;
}
/** A nonempty array indexed by score from zero; `null` leaves a score undescribed. */
export type ScoreList = readonly [EntryType, ...EntryType[]];
/** Score descriptions keyed from zero with no gaps; `null` leaves a score undescribed. */
export type ScoreMap = {
readonly [score: number]: EntryType;
};
/** An ordered rubric expressed as an array or score map. */
export type ScoreCriteria = ScoreList | ScoreMap;
/** At least two descriptions indexed by score from zero; `null` leaves a score undescribed. */
export type ScoreCriteria = readonly [EntryType, EntryType, ...EntryType[]];
/** A question that assigns a score using an ordered rubric. */
export interface ScoreQuestion<T extends ScoreCriteria = ScoreCriteria> {
@@ -97,12 +89,10 @@ export interface ChoiceResponse<T extends ChoiceCriteria = ChoiceCriteria> {
};
}
/** Score keys inferred from the rubric; tuple keys are numeric strings, map keys are numbers. */
export type ScoreOf<T extends ScoreCriteria> = T extends readonly unknown[]
? number extends T["length"]
? number
: Extract<keyof T, `${number}`>
: Extract<keyof T, number>;
/** Score keys inferred from the rubric; a fixed-length tuple yields its indices, otherwise `number`. */
export type ScoreOf<T extends ScoreCriteria> = number extends T["length"]
? number
: Extract<keyof T, `${number}`>;
/** Rubric descriptions keyed by score. */
export type ScoreLegend<T extends ScoreCriteria> = {
@@ -166,7 +156,6 @@ export interface ModelCard {
* State and named questions for `systemOne`.
*
* Additional properties on a request variable are forwarded, including `null` values.
* Score maps are converted to arrays before sending.
*/
export interface SystemOneRequest<Q extends Questions = Questions> {
/** Text, a JSON object or array, or `null` to evaluate. */
+1 -1
View File
@@ -1,2 +1,2 @@
// Keep in sync with package.json "version". Checked by `npm run check:version`.
export const VERSION = "0.5.7";
export const VERSION = "0.6.0";
+22 -53
View File
@@ -312,32 +312,26 @@ describe("question builders", () => {
expect(r.answers.q.choice).toBe("b");
});
it("score keeps whichever shape it was given", () => {
it("score keeps the list it was given and rejects maps", () => {
expect(score("q", ["bad", "good"]).criteria).toEqual(["bad", "good"]);
expect(score("q", { 0: "bad", 1: "good" }).criteria).toEqual({ 0: "bad", 1: "good" });
// biome-ignore lint/suspicious/noExplicitAny: deliberately malformed, as a JS caller might send
expect(() => score("q", { 0: "bad", 1: "good" } as any)).toThrow(
"Score criteria must be a list of descriptions indexed by score from zero, not a map.",
);
});
});
describe("wire format", () => {
it("preserves null state, instructions, and criteria values during normalization", async () => {
it("preserves null state, instructions, and criteria values", async () => {
const { fetch, requests } = mockFetch(() => json(SYSTEM_ONE_RESPONSE));
const questions = {
noul: noul(null, { true: null, false: null }),
noCriteria: noul(null, null),
choice: choice(null, { yes: null, no: null }),
list: score(null, [null, "high"]),
map: score(null, { 1: "high", 0: null }),
score: score(null, [null, "high"]),
};
await new TypeSafeClient({ apiKey: "k", fetch }).systemOne({ state: null, questions });
expect(requests[0]?.body).toEqual({
model: DEFAULT_MODEL,
state: null,
questions: {
...questions,
map: { type: "score", instructions: null, criteria: [null, "high"] },
},
});
expect(questions.map.criteria).toEqual({ 0: null, 1: "high" });
expect(requests[0]?.body).toEqual({ model: DEFAULT_MODEL, state: null, questions });
});
it("allows omitted instructions and preserves JSON arrays", async () => {
@@ -345,7 +339,7 @@ describe("wire format", () => {
const questions = {
noul: { type: "noul", criteria: null },
choice: { type: "choice", criteria: { yes: [null, { example: true }] } },
score: { type: "score", criteria: { 0: [null, "low"], 1: null } },
score: { type: "score", criteria: [[null, "low"], null] },
arrayInstructions: noul([null, { examples: [1, false] }], { true: ["yes", null] }),
defaultInstructions: noul(),
} satisfies Questions;
@@ -356,7 +350,6 @@ describe("wire format", () => {
state,
questions: {
...questions,
score: { type: "score", criteria: [[null, "low"], null] },
defaultInstructions: { type: "noul", instructions: null },
},
});
@@ -381,44 +374,25 @@ describe("wire format", () => {
expect(wire.q?.criteria).toEqual(["bad", "ok", "great"]);
});
it("converts a well-formed score map to a list", async () => {
const wire = await send({ q: score("q", { 0: "bad", 1: "ok", 2: "great" }) });
expect(wire.q?.criteria).toEqual(["bad", "ok", "great"]);
// Key order in the source object does not matter.
const shuffled = await send({ q: score("q", { 2: "great", 0: "bad", 1: "ok" }) });
expect(shuffled.q?.criteria).toEqual(["bad", "ok", "great"]);
});
it("rejects score maps with gaps or a non-zero start, naming the question", async () => {
await expect(send({ q: score("q", { 0: "bad", 2: "great" }) })).rejects.toThrow(
'Score question "q" defines scores 0, 2, but scores must run from 0 with no gaps (expected 0, 1)',
);
await expect(send({ urgency: score("q", { 1: "low", 2: "high" }) })).rejects.toThrow(
'Score question "urgency" defines scores 1, 2',
);
});
it("rejects non-integer score keys, empty criteria, and empty question sets before sending", async () => {
it("rejects non-list score criteria, fewer than two criteria, and empty question sets before sending", async () => {
const { fetch, requests } = mockFetch(() => json(SYSTEM_ONE_RESPONSE));
const client = new TypeSafeClient({ apiKey: "k", fetch });
// biome-ignore lint/suspicious/noExplicitAny: deliberately malformed, as a JS caller might send
const bad = (criteria: any) =>
client.systemOne({ state: "s", questions: { q: score("q", criteria) } });
expect(() => bad({ 1.5: "x" })).toThrow(TypeSafeError);
expect(() => bad({ "-1": "x" })).toThrow("non-negative integers");
expect(() => bad({})).toThrow("at least one score");
expect(() => bad([])).toThrow("at least one score");
client.systemOne({ state: "s", questions: { q: { type: "score", criteria } } });
expect(() => bad({ 0: "bad", 1: "ok" })).toThrow(TypeSafeError);
expect(() => bad({ 0: "bad", 1: "ok" })).toThrow(
'Score question "q" has criteria that are not a list',
);
expect(() => bad([])).toThrow(
'Score question "q" has 0 criteria; at least two scores are required.',
);
expect(() => bad(["only"])).toThrow("at least two scores");
expect(() => client.systemOne({ state: "s", questions: {} })).toThrow(
"At least one question is required",
);
expect(requests).toHaveLength(0);
});
it("does not mutate the caller's questions", async () => {
const questions = { q: score("q", { 0: "bad", 1: "ok" }) };
await send(questions);
expect(questions.q.criteria).toEqual({ 0: "bad", 1: "ok" });
});
});
describe("1.0 primitive contract", () => {
@@ -436,22 +410,17 @@ describe("1.0 primitive contract", () => {
expect(requests).toHaveLength(1);
});
it("forwards extra fields and null without losing score normalization", async () => {
it("forwards extra fields and null", async () => {
const { fetch, requests } = mockFetch(() => json(SYSTEM_ONE_RESPONSE));
const client = new TypeSafeClient({ apiKey: "k", fetch });
const request = {
state: "s",
questions: { q: score("?", { 0: "low", 1: "high" }) },
questions: { q: score("?", ["low", "high"]) },
future_option: null,
nested: { enabled: true },
};
await client.systemOne(request);
expect(requests[0]?.body).toEqual({
...request,
model: "jev-latest",
questions: { q: { type: "score", instructions: "?", criteria: ["low", "high"] } },
});
expect(request.questions.q.criteria).toEqual({ 0: "low", 1: "high" });
expect(requests[0]?.body).toEqual({ ...request, model: "jev-latest" });
await client.systemOne({ state: "s", questions: { q: noul("?") } });
expect(requests[1]?.body).not.toHaveProperty("future_option");
});
+6 -21
View File
@@ -84,23 +84,6 @@ describeLive("live API", () => {
expect(sum(urgency.probabilities)).toBeCloseTo(1, 1);
});
it("accepts a score map, which is sent as a list", async () => {
const data = await client.systemOne({
state: ticket,
questions: {
refundRisk: score("How likely is a refund demand?", {
0: "unlikely",
1: "possible",
2: "likely",
}),
},
});
show("score map", data.answers.refundRisk);
expect(data.answers.refundRisk.legend).toEqual({ 0: "unlikely", 1: "possible", 2: "likely" });
expect(data.answers.refundRisk.score).toBeGreaterThanOrEqual(0);
expect(data.answers.refundRisk.score).toBeLessThanOrEqual(2);
});
it("accepts rich descriptions and one-sided noul criteria", async () => {
const data = await client.systemOne({
state: ticket,
@@ -145,7 +128,7 @@ describeLive("live API", () => {
it("surfaces server-side validation errors readably", async () => {
// A description type the SDK does not validate but the API rejects, to see how a 422 renders.
const malformed = { q: score("?", [123 as unknown as string]) };
const malformed = { q: score("?", [123 as unknown as string, "ok"]) };
const err = await client
.systemOne({ state: "x", questions: malformed }, { retry: { maxRetries: 0 } })
.catch((e: unknown) => e);
@@ -156,8 +139,10 @@ describeLive("live API", () => {
it("catches shapes the API would reject before sending", () => {
expect(() => client.systemOne({ state: "x", questions: {} })).toThrow(TypeSafeError);
expect(() =>
client.systemOne({ state: "x", questions: { q: score("?", { 0: "a", 2: "c" }) } }),
).toThrow(TypeSafeError);
// biome-ignore lint/suspicious/noExplicitAny: deliberately malformed, as a JS caller might send
const empty: any = [];
expect(() => client.systemOne({ state: "x", questions: { q: score("?", empty) } })).toThrow(
TypeSafeError,
);
});
});
+2 -2
View File
@@ -69,9 +69,9 @@ describe("release regressions", () => {
expect(headers.has("x-typesafe-retry-count")).toBe(false);
});
it("preserves own __proto__ questions through score-map normalization without mutation", async () => {
it("preserves own __proto__ questions without mutation", async () => {
const questions = JSON.parse('{"__proto__":{"type":"noul","instructions":"?"}}');
questions.score = score("?", { 0: "no", 1: "yes" });
questions.score = score("?", ["no", "yes"]);
const original = JSON.stringify(questions);
const { fetch, requests } = mockFetch(() => json({}));
await new TypeSafeClient({ apiKey: "k", fetch }).systemOne({ state: "s", questions });
+25 -20
View File
@@ -25,7 +25,7 @@ describe("systemOne result inference", () => {
questions: {
a: noul("x"),
b: choice("y", { yes: null, no: "desc" }),
c: score("z", { 0: "bad", 1: "ok" }),
c: score("z", ["bad", "ok"]),
},
});
@@ -41,9 +41,7 @@ describe("systemOne result inference", () => {
readonly no: number;
}>();
expectTypeOf(r.answers.c).toEqualTypeOf<
ScoreResponse<{ readonly 0: "bad"; readonly 1: "ok" }>
>();
expectTypeOf(r.answers.c).toEqualTypeOf<ScoreResponse<readonly ["bad", "ok"]>>();
expectTypeOf(r.answers.c.score).toEqualTypeOf<number>();
expectTypeOf(r.answers.c.legend).toEqualTypeOf<{ readonly 0: "bad"; readonly 1: "ok" }>();
expectTypeOf(r.answers.c.probabilities).toEqualTypeOf<{
@@ -86,7 +84,7 @@ describe("systemOne result inference", () => {
});
it("degrades to numeric indexing for non-literal score lists", async () => {
const list: [string, ...string[]] = ["a", "b"];
const list: [string, string, ...string[]] = ["a", "b"];
const r = await client.systemOne({ state: "s", questions: { c: score("z", list) } });
expectTypeOf(r.answers.c.probabilities).toEqualTypeOf<{ readonly [score: number]: number }>();
expectTypeOf(r.answers.c.legend).toEqualTypeOf<{ readonly [score: number]: string }>();
@@ -138,8 +136,8 @@ describe("systemOne result inference", () => {
});
});
describe("criteria maps", () => {
it("rejects label-list shorthand", () => {
describe("criteria shapes", () => {
it("rejects label-list shorthand for choice", () => {
// @ts-expect-error criteria must be a map
choice("q", ["a", "b"]);
// @ts-expect-error empty label lists are not maps
@@ -147,6 +145,18 @@ describe("criteria maps", () => {
const labels: string[] = ["a", "b"];
// @ts-expect-error dynamic label lists are not maps
choice("q", labels);
});
it("rejects numeric-keyed maps for score", () => {
// @ts-expect-error score criteria must be a list
score("q", { 0: "bad", 1: "ok" });
// @ts-expect-error empty lists are not valid rubrics
score("q", []);
// @ts-expect-error a rubric needs at least two levels
score("q", ["only"]);
const dynamic: string[] = ["bad", "ok"];
// @ts-expect-error a plain string[] may be empty
score("q", dynamic);
// @ts-expect-error inference is a client method, not a resource
client.systemOne.run({ state: "s", questions: { q: noul("?") } });
});
@@ -157,21 +167,14 @@ describe("question helpers", () => {
// @ts-expect-error numbers are not valid descriptions
choice("q", { yes: 1 });
// @ts-expect-error numbers are not valid descriptions
score("q", { 0: 1 });
// @ts-expect-error numbers are not valid descriptions
score("q", [1, 2]);
score("q", ["ok", null]);
score("q", { 0: null, 1: "ok" });
noul(null, { true: null, false: null });
noul(null, null);
noul();
choice(null, { yes: null });
score(null, [null]);
// An empty list is not catchable at the type level (it satisfies the map shape); the client
// rejects it at runtime instead.
score("q", []);
score(null, [null, null]);
choice("q", { yes: null, no: "ok", maybe: { detail: "rich" } });
score("q", { 0: "bad", 1: "ok", 2: { detail: "rich" } });
score("q", ["bad", "ok", { detail: "rich" }]);
noul("q", { true: { detail: "rich" } });
noul("q", { false: "only one side" });
@@ -184,13 +187,16 @@ describe("question helpers", () => {
noul: { type: "noul" },
choice: { type: "choice", criteria: { yes: null, no: [null, "example"] } },
list: score(null, [null, "high"]),
map: { type: "score", criteria: { 0: null, 1: "high" } },
literal: { type: "score", criteria: [null, "high"] },
},
});
expectTypeOf(r.answers.noul).toEqualTypeOf<NoulResponse>();
expectTypeOf(r.answers.choice.choice).toEqualTypeOf<"yes" | "no">();
expectTypeOf(r.answers.list.legend).toEqualTypeOf<{ readonly 0: null; readonly 1: "high" }>();
expectTypeOf(r.answers.map.legend).toEqualTypeOf<{ readonly 0: null; readonly 1: "high" }>();
expectTypeOf(r.answers.literal.legend).toEqualTypeOf<{
readonly 0: null;
readonly 1: "high";
}>();
expectTypeOf(r.answers.list.probabilities).toEqualTypeOf<{
readonly 0: number;
readonly 1: number;
@@ -200,7 +206,7 @@ describe("question helpers", () => {
questions: {
q: noul([null, "instructions"], { true: [null, "criterion"] }),
c: choice(["instructions"], { yes: [null, "criterion"] }),
s: score(["instructions"], [[null, "criterion"]]),
s: score(["instructions"], [[null, "criterion"], null]),
},
});
});
@@ -209,8 +215,7 @@ describe("question helpers", () => {
expectTypeOf(noul("x")).toMatchTypeOf<Question>();
expectTypeOf(choice("x", { a: null })).toMatchTypeOf<Question>();
expectTypeOf(choice("x", { a: null, b: null })).toMatchTypeOf<Question>();
expectTypeOf(score("x", { 0: "a" })).toMatchTypeOf<Question>();
expectTypeOf(score("x", ["a"])).toMatchTypeOf<Question>();
expectTypeOf(score("x", ["a", "b"])).toMatchTypeOf<Question>();
});
});