@devjonaed/typesafe-ai 0.1.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 ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release.
6
+
7
+ - `ask(state, questions)` — answers named questions in one call and returns them unwrapped: a
8
+ probability for a Noul, the selected label for a Choice, the expected score for a Score.
9
+ - Shorthand specs: a string is a yes/no question, an array of labels is a choice. `choice()`,
10
+ `score()` and `noul()` are re-exported for questions that need descriptions.
11
+ - `$` carries the full SDK result — answers, probabilities, confidence, usage, model and request ID.
12
+ - `configure()` and `client()` for the client, which is otherwise built from `TYPESAFE_API_KEY` on
13
+ first use.
14
+ - Bad input fails before the request: non-object question maps, unusable question values, choices
15
+ with fewer than two distinct string labels, a blank model override, a blank API key, and `$` as a
16
+ question name.
17
+ - Bad responses fail loudly: a missing answer, or an answer whose type does not match the question
18
+ asked, throws instead of yielding `undefined` or a plausible-looking wrong value.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Md Jonaed Hasan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,353 @@
1
+ # @devjonaed/typesafe-ai
2
+
3
+ [![CI](https://github.com/jonaed1230/typesafe-ai/actions/workflows/ci.yml/badge.svg)](https://github.com/jonaed1230/typesafe-ai/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/@devjonaed/typesafe-ai)](https://www.npmjs.com/package/@devjonaed/typesafe-ai)
5
+
6
+ > **Unofficial.** A community wrapper around TypeSafe AI's official SDK, not built or endorsed by
7
+ > TypeSafe. Bugs here belong in
8
+ > [this repo's issues](https://github.com/jonaed1230/typesafe-ai/issues), not TypeSafe's. For the
9
+ > official client, use [`@typesafe-ai/sdk`](https://www.npmjs.com/package/@typesafe-ai/sdk) directly.
10
+
11
+ A thin wrapper around [`@typesafe-ai/sdk`](https://www.npmjs.com/package/@typesafe-ai/sdk). Ask typed
12
+ questions about some state, get the answers back already unwrapped.
13
+
14
+ ```ts
15
+ // the SDK
16
+ const client = new TypeSafeClient();
17
+ const r = await client.systemOne({
18
+ state,
19
+ questions: {
20
+ category: choice("...", { billing: null, technical: null, other: null }),
21
+ urgent: noul("Is the customer angry?"),
22
+ },
23
+ });
24
+ r.answers.category.choice;
25
+ r.answers.urgent.noul;
26
+
27
+ // this package
28
+ const a = await ask(state, {
29
+ category: ["billing", "technical", "other"],
30
+ urgent: "Is the customer angry?",
31
+ });
32
+ a.category;
33
+ a.urgent;
34
+ ```
35
+
36
+ No client to construct, shorthand for the common questions, and answers that are values rather than
37
+ envelopes. Everything the SDK returns is still on `a.$`, and `client()` hands you the SDK itself.
38
+
39
+ ```sh
40
+ npm install @devjonaed/typesafe-ai
41
+ ```
42
+
43
+ Set `TYPESAFE_API_KEY` in your environment. That is the whole setup — there is no client to construct.
44
+
45
+ ```ts
46
+ import { ask } from "@devjonaed/typesafe-ai";
47
+
48
+ const a = await ask("I was charged twice. Please fix this ASAP.", {
49
+ category: ["billing", "technical", "other"],
50
+ urgent: "Is the customer angry?",
51
+ });
52
+
53
+ a.category; // "billing" — typed as "billing" | "technical" | "other"
54
+ a.urgent; // 0.81 — probability from 0 to 1
55
+ ```
56
+
57
+ ## Writing questions
58
+
59
+ | You write | You get | Underneath |
60
+ | --- | --- | --- |
61
+ | `"Is the customer angry?"` | a probability from 0 to 1 | a Noul question |
62
+ | `["billing", "technical"]` | the selected label, as a narrow union | a Choice question |
63
+ | `choice(instructions, criteria)` | the selected label | a Choice question with descriptions |
64
+ | `score(instructions, criteria)` | the expected score | a Score question |
65
+ | `noul(instructions, criteria)` | a probability from 0 to 1 | a Noul question with descriptions |
66
+
67
+ All three helpers take optional descriptions, including `noul`:
68
+
69
+ ```ts
70
+ noul("Is the customer angry?", { true: "Furious or threatening to leave.", false: "Calm." });
71
+ ```
72
+
73
+ `choice`, `score` and `noul` are re-exported from the SDK, so one import covers everything:
74
+
75
+ ```ts
76
+ import { ask, choice, score } from "@devjonaed/typesafe-ai";
77
+
78
+ const a = await ask(ticket, {
79
+ team: choice("Who should handle this?", {
80
+ billing: "Charges, refunds, invoices.",
81
+ platform: "Outages, errors, latency.",
82
+ }),
83
+ severity: score("How badly is the customer blocked?", [
84
+ "Not blocked.",
85
+ "Inconvenienced.",
86
+ "Cannot work.",
87
+ ]),
88
+ });
89
+
90
+ a.team; // "billing" | "platform"
91
+ a.severity; // 0.83 — the expected score, from 0 to 2 here
92
+ ```
93
+
94
+ Every question is answered in one API call, in parallel, and extra questions do not slow the call
95
+ down. That makes it cheap to ask everything a branch might need up front and let your code decide
96
+ what matters:
97
+
98
+ ```ts
99
+ const a = await ask(ticket, {
100
+ category: ["billing", "technical", "other"],
101
+ refundRequested: "Is the customer asking for money back?",
102
+ angry: "Is the customer angry?",
103
+ mentionsLegal: "Does the customer mention lawyers or legal action?",
104
+ });
105
+
106
+ if (a.mentionsLegal > 0.8) return escalate(ticket);
107
+ if (a.category === "billing" && a.refundRequested > 0.6) return refundQueue(ticket);
108
+ ```
109
+
110
+ Keep each question narrow. One question per decision beats one question that tries to decide
111
+ everything — see [Patterns](https://docs.typesafe.ai/patterns) for the shapes that work.
112
+
113
+ An array is the shortest form, but it sends no instructions — the labels alone have to carry the
114
+ question. Reach for `choice()` as soon as a label needs explaining. It needs at least two distinct
115
+ labels; anything less is rejected before the request goes out.
116
+
117
+ A score is answered on `0` to `levels - 1`, and the answer may fall between levels. The rubric comes
118
+ back on `$`, so you never have to keep the level list in sync by hand:
119
+
120
+ ```ts
121
+ a.severity; // 0.83
122
+ a.$.answers.severity.legend; // { 0: "Not blocked.", 1: "Inconvenienced.", 2: "Cannot work." }
123
+ a.$.answers.severity.probabilities; // { 0: 0.17, 1: 0.82, 2: 0.01 }
124
+ a.$.answers.severity.confidence; // 0.73
125
+ ```
126
+
127
+ ### Structured instructions
128
+
129
+ Instructions and criteria are not limited to strings — any JSON object or array works, which keeps a
130
+ long rubric readable:
131
+
132
+ ```ts
133
+ const a = await ask(ticket, {
134
+ team: choice(
135
+ { question: "Who handles this?", rules: ["route by topic", "prefer specialists"] },
136
+ { billing: { owns: ["charges", "refunds"] }, platform: { owns: ["outages"] }, other: null },
137
+ ),
138
+ });
139
+ ```
140
+
141
+ `null` as a description leaves that label undescribed, which is exactly what the array shorthand
142
+ sends for every label.
143
+
144
+ ## Confidence, probabilities and usage
145
+
146
+ Unwrapped answers cover the common case. The full SDK result is always on `$`, typed per question:
147
+
148
+ ```ts
149
+ a.$.answers.category.confidence; // 1
150
+ a.$.answers.category.probabilities; // { billing: 1, technical: 0, other: 0 }
151
+ a.$.usage; // { input_tokens: 415, output_tokens: 96 }
152
+ a.$.requestId; // "req_01a0ca7f5a747639aaac05b46c7dc7db"
153
+ a.$.model; // "jev-1.13.0"
154
+ ```
155
+
156
+ Gate on confidence when a wrong answer is expensive:
157
+
158
+ ```ts
159
+ const { category, $ } = await ask(ticket, { category: ["billing", "technical", "other"] });
160
+ if ($.answers.category.confidence < 0.7) return escalateToHuman(ticket);
161
+ ```
162
+
163
+ **A Noul answer has no confidence and no probabilities.** Its probability *is* the answer, so the
164
+ full shape is just `{ type: "noul", noul: 0.91 }`. Only Choice and Score report confidence — for a
165
+ yes/no question, read how far `a.urgent` sits from `0.5`.
166
+
167
+ Because `$` is reserved, `ask` throws a `TypeSafeError` if you name a question `$`.
168
+
169
+ ## State
170
+
171
+ State can be text, or any JSON object or array:
172
+
173
+ ```ts
174
+ await ask(
175
+ { ticket: { body, customer: { plan: "enterprise", tenureMonths: 14 } } },
176
+ { churnRisk: "Is this customer likely to cancel?" },
177
+ );
178
+ ```
179
+
180
+ State is required. The SDK's types also accept `null`, but the API rejects it with
181
+ `UnprocessableEntityError: 422 state: Field required`, so pass something.
182
+
183
+ ## Options
184
+
185
+ Per-call options are the SDK's, plus `model`:
186
+
187
+ ```ts
188
+ await ask(state, questions, {
189
+ model: "jev-preview",
190
+ timeout: 5000,
191
+ signal: controller.signal,
192
+ retry: { maxRetries: 4 },
193
+ headers: { "x-request-source": "support-inbox" },
194
+ });
195
+ ```
196
+
197
+ The client is created from the environment on first use. To configure it yourself, call `configure`
198
+ once at startup:
199
+
200
+ ```ts
201
+ import { configure } from "@devjonaed/typesafe-ai";
202
+
203
+ configure({ apiKey: process.env.MY_KEY, defaultModel: "jev-preview", timeout: 30_000 });
204
+ ```
205
+
206
+ `configure` takes the SDK's `TypeSafeClientConfig` and returns the client. `client()` returns the
207
+ current one, so the full SDK surface — `client().models.list()`, `client().systemOne(...)` — stays
208
+ reachable when you need it.
209
+
210
+ ## Configuration
211
+
212
+ Explicit options beat environment variables, which beat the defaults:
213
+
214
+ | Variable | `configure` option | Default |
215
+ | --- | --- | --- |
216
+ | `TYPESAFE_API_KEY` | `apiKey` | required |
217
+ | `TYPESAFE_BASE_URL` | `baseURL` | `https://api.typesafe.ai` |
218
+ | `TYPESAFE_DEFAULT_MODEL` | `defaultModel` | `jev-latest` |
219
+ | `TYPESAFE_LOG_LEVEL` | `logLevel` | `warn` |
220
+
221
+ Other defaults: `timeout` 10000 ms per attempt, `retry.maxRetries` 2. Available models today are
222
+ `jev-latest` and `jev-preview` — `client().models.list()` is the current answer.
223
+
224
+ Set `logLevel: "info"` for request summaries, or `"debug"` to log headers and bodies. Credential
225
+ headers are redacted; **bodies are not**, so keep `debug` away from production if your state
226
+ contains personal data.
227
+
228
+ The client refuses to run in a browser, because that would ship your API key to every visitor. Call
229
+ it from your server. `dangerouslyAllowBrowser: true` overrides that, and the name is the warning.
230
+
231
+ Three more `configure` options are worth knowing:
232
+
233
+ ```ts
234
+ configure({
235
+ logger: myPinoLikeLogger, // anything with debug/info/warn/error; defaults to console
236
+ defaultHeaders: { "x-app": "support-inbox" }, // sent on every request
237
+ fetch: myFetch, // swap the transport — this is the seam the test suite uses
238
+ });
239
+ ```
240
+
241
+ ## Request IDs
242
+
243
+ Every call's request ID is on `$`, ready to quote to support:
244
+
245
+ ```ts
246
+ const a = await ask(ticket, { category: ["billing", "technical", "other"] });
247
+ a.$.requestId; // "req_01a0ca7f5a747639aaac05b46c7dc7db"
248
+ ```
249
+
250
+ It is `undefined` when the server sends no `x-typesafe-request-id` header. Failures carry it too —
251
+ every `APIError` has a `requestId`. For the raw `Response` itself, use the SDK promise underneath:
252
+ `client().systemOne(...).withResponse()`, which also offers `asResponse()` and `map()`.
253
+
254
+ ## Types
255
+
256
+ `ask` infers everything from the spec you pass, so you rarely need these, but they are exported for
257
+ signatures of your own:
258
+
259
+ ```ts
260
+ import type { Answer, Answers, Meta, Spec, SpecMap, QuestionOf } from "@devjonaed/typesafe-ai";
261
+
262
+ type Routing = SpecMap & { category: readonly ["billing", "technical"] };
263
+ type Category = Answer<readonly ["billing", "technical"]>; // "billing" | "technical"
264
+ type Result = Answers<Routing>; // { category: ... } & { $: Meta<Routing> }
265
+ ```
266
+
267
+ `EntryType`, `Question` and `SystemOneResult` are re-exported from the SDK for the same reason.
268
+
269
+ ## Errors
270
+
271
+ Errors come from the SDK unchanged: `AuthenticationError` (401), `BadRequestError` (400),
272
+ `PermissionDeniedError` (403), `UnprocessableEntityError` (422), `RateLimitError` (429, with
273
+ `retryAfterMs`), `InternalServerError` (5xx), `APIConnectionError`, `APITimeoutError` and
274
+ `APIUserAbortError` — all extending `TypeSafeError`. Import them from `@typesafe-ai/sdk`.
275
+
276
+ Failed requests are retried twice by default, with backoff on 408, 429 and 5xx. Override per call
277
+ with `retry`, or for every call through `configure`.
278
+
279
+ Bad input is caught before any request goes out, always as `TypeSafeError`:
280
+
281
+ ```
282
+ Questions must be an object keyed by answer name; got an array.
283
+ "$" is reserved for the full result; rename that question.
284
+ Question "category" must be a string, an array of at least two labels, or a question from choice(), score() or noul(); got number.
285
+ Choice question "category" has 1 label; at least two are required.
286
+ Choice question "category" repeats a label; every label must be distinct.
287
+ Choice question "category" has a non-string label (1); labels must be strings.
288
+ The model override is blank. Pass a model name, or omit `model`.
289
+ The API key is blank. Pass a real key, or omit `apiKey` to read TYPESAFE_API_KEY from the environment.
290
+ At least one question is required. (from the SDK)
291
+ Score question "severity" has 1 criteria; at least two scores are required. (from the SDK)
292
+ ```
293
+
294
+ A rejected `configure` leaves any previous client untouched. Every failure in this package is a
295
+ `TypeSafeError`, so one check covers input mistakes, API errors and transport errors alike:
296
+
297
+ ```ts
298
+ import { APIError, TypeSafeError } from "@typesafe-ai/sdk";
299
+
300
+ try {
301
+ const a = await ask(ticket, { category: ["billing", "technical", "other"] });
302
+ } catch (e) {
303
+ if (e instanceof APIError) console.error(e.status, e.requestId);
304
+ else if (e instanceof TypeSafeError) console.error("bad request shape:", e.message);
305
+ else throw e;
306
+ }
307
+ ```
308
+
309
+ A bad *response* is an error too, rather than a value that looks plausible:
310
+
311
+ ```
312
+ The API returned no answer for question "category". Request ID: req_01a0ca7f5a747639aaac05b46c7dc7db.
313
+ The API answered question "category" with a "noul" answer, but a "choice" question was asked.
314
+ ```
315
+
316
+ The second one matters: without it, asking a Choice and receiving a Noul would hand you `0.5` where
317
+ you expected a label. If a future model adds a question type this version does not know, you get
318
+ that error instead of `undefined`.
319
+
320
+
321
+ ## Tests
322
+
323
+ ```sh
324
+ npm test # build, then 34 offline tests
325
+ TYPESAFE_API_KEY=... npm test # adds 8 tests against the real API
326
+ npm run check:docs # type-checks every snippet in this file
327
+ ```
328
+
329
+ CI runs all of it on Node 20, 22 and 24 for every push and pull request. The live tests skip
330
+ themselves when no key is present, so forks get a meaningful green run without secrets.
331
+
332
+ The offline tests stub `fetch`, so they assert the exact request body that goes out — including that
333
+ the array shorthand produces a byte-identical body to hand-written SDK questions. The live tests are
334
+ skipped without a key.
335
+
336
+ ## Learn more
337
+
338
+ This README documents the wrapper. For the model itself — when to reach for Choice over Score, how
339
+ to decompose a judgment into atomic questions, and how confidence is calibrated — see the TypeSafe
340
+ docs:
341
+
342
+ - [Primitives](https://docs.typesafe.ai/primitives) — Choice, Score and Noul in depth
343
+ - [Confidence](https://docs.typesafe.ai/confidence) — what it measures and how to gate on it
344
+ - [Patterns](https://docs.typesafe.ai/patterns) — fan-out, confidence routing, composite scoring
345
+ - [How to build with TypeSafe](https://docs.typesafe.ai/concepts/how-to-build-with-system-one)
346
+
347
+ ## Requirements
348
+
349
+ Node 20 or newer. ESM only — there is no CommonJS build.
350
+
351
+ ## License
352
+
353
+ MIT. Copyright (c) 2026 Md Jonaed Hasan. See [LICENSE](LICENSE).
@@ -0,0 +1,56 @@
1
+ import { TypeSafeClient, type ChoiceQuestion, type EntryType, type NoulQuestion, type Question, type Questions, type RequestOptions, type ScoreQuestion, type SystemOneResult, type TypeSafeClientConfig } from "@typesafe-ai/sdk";
2
+ export { choice, noul, score } from "@typesafe-ai/sdk";
3
+ export type { EntryType, Question, SystemOneResult } from "@typesafe-ai/sdk";
4
+ /** A question written the short way, or a full SDK question object. */
5
+ export type Spec = string | readonly string[] | Question;
6
+ /** Questions keyed by the name their answer is returned under. */
7
+ export type SpecMap = Record<string, Spec>;
8
+ /** The unwrapped answer for one spec: a probability, a label, or a score. */
9
+ export type Answer<S> = S extends string ? number : S extends readonly (infer L)[] ? L extends string ? L : never : S extends ChoiceQuestion<infer C> ? keyof C & string : S extends ScoreQuestion<infer _> ? number : S extends NoulQuestion ? number : never;
10
+ /** The SDK question a spec expands to, so `$` keeps its precise answer types. */
11
+ export type QuestionOf<S> = S extends string ? NoulQuestion : S extends readonly (infer L)[] ? [L] extends [string] ? ChoiceQuestion<{
12
+ [Label in L]: null;
13
+ }> : never : S extends Question ? S : never;
14
+ /** The full SDK result, plus the request ID for the call that produced it. */
15
+ export type Meta<S extends SpecMap> = SystemOneResult<{
16
+ [K in keyof S]: QuestionOf<S[K]>;
17
+ } & Questions> & {
18
+ /** Request ID from `x-typesafe-request-id`, or `undefined` when absent. Quote it to support. */
19
+ readonly requestId: string | undefined;
20
+ };
21
+ /** Unwrapped answers by name, plus `$` holding the full SDK result. */
22
+ export type Answers<S extends SpecMap> = {
23
+ [K in keyof S]: Answer<S[K]>;
24
+ } & {
25
+ $: Meta<S>;
26
+ };
27
+ /**
28
+ * Replace the client `ask` uses. Call once at startup, or not at all.
29
+ *
30
+ * @throws {TypeSafeError} `apiKey` is present but blank. The SDK would accept it and fail
31
+ * later with a 401; failing here points at the unset variable that actually caused it.
32
+ */
33
+ export declare const configure: (config?: TypeSafeClientConfig) => TypeSafeClient;
34
+ /** The client `ask` uses, created from the environment on first use. */
35
+ export declare const client: () => TypeSafeClient;
36
+ /**
37
+ * Ask named questions about some state and get the answers back unwrapped.
38
+ *
39
+ * A string is a yes/no question and answers with a probability from 0 to 1.
40
+ * An array of labels is a choice and answers with the selected label.
41
+ * `choice()`, `score()` and `noul()` cover the cases that need descriptions.
42
+ *
43
+ * @example
44
+ * const a = await ask("I was charged twice. Fix this ASAP.", {
45
+ * category: ["billing", "technical", "other"],
46
+ * urgent: "Is the customer angry?",
47
+ * });
48
+ * a.category; // "billing"
49
+ * a.urgent; // 0.93
50
+ * a.$.answers.category.confidence; // 0.88
51
+ * a.$.requestId; // "req_01a0ca7f5a747639aaac05b46c7dc7db"
52
+ */
53
+ export declare function ask<const S extends SpecMap>(state: EntryType, spec: S, options?: RequestOptions & {
54
+ model?: string;
55
+ }): Promise<Answers<S>>;
56
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EAId,KAAK,cAAc,EACnB,KAAK,SAAS,EACd,KAAK,YAAY,EACjB,KAAK,QAAQ,EACb,KAAK,SAAS,EAEd,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,eAAe,EACpB,KAAK,oBAAoB,EAC1B,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AACvD,YAAY,EAAE,SAAS,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAE7E,uEAAuE;AACvE,MAAM,MAAM,IAAI,GAAG,MAAM,GAAG,SAAS,MAAM,EAAE,GAAG,QAAQ,CAAC;AAEzD,kEAAkE;AAClE,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;AAE3C,6EAA6E;AAC7E,MAAM,MAAM,MAAM,CAAC,CAAC,IAAI,CAAC,SAAS,MAAM,GACpC,MAAM,GACN,CAAC,SAAS,SAAS,CAAC,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,SAAS,MAAM,GACd,CAAC,GACD,KAAK,GACP,CAAC,SAAS,cAAc,CAAC,MAAM,CAAC,CAAC,GAC/B,MAAM,CAAC,GAAG,MAAM,GAChB,CAAC,SAAS,aAAa,CAAC,MAAM,CAAC,CAAC,GAC9B,MAAM,GACN,CAAC,SAAS,YAAY,GACpB,MAAM,GACN,KAAK,CAAC;AAElB,iFAAiF;AACjF,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI,CAAC,SAAS,MAAM,GACxC,YAAY,GACZ,CAAC,SAAS,SAAS,CAAC,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,GAClB,cAAc,CAAC;KAAG,KAAK,IAAI,CAAC,GAAG,IAAI;CAAE,CAAC,GACtC,KAAK,GACP,CAAC,SAAS,QAAQ,GAChB,CAAC,GACD,KAAK,CAAC;AAEd,8EAA8E;AAC9E,MAAM,MAAM,IAAI,CAAC,CAAC,SAAS,OAAO,IAAI,eAAe,CACnD;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CAAE,GAAG,SAAS,CACjD,GAAG;IACF,gGAAgG;IAChG,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;CACxC,CAAC;AAEF,uEAAuE;AACvE,MAAM,MAAM,OAAO,CAAC,CAAC,SAAS,OAAO,IAAI;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CAAE,GAAG;IAC1E,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC;CACZ,CAAC;AAuEF;;;;;GAKG;AACH,eAAO,MAAM,SAAS,GAAI,SAAQ,oBAAyB,KAAG,cAM7D,CAAC;AAEF,wEAAwE;AACxE,eAAO,MAAM,MAAM,QAAO,cAAmD,CAAC;AAE9E;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,GAAG,CAAC,KAAK,CAAC,CAAC,SAAS,OAAO,EAC/C,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE,CAAC,EACP,OAAO,GAAE,cAAc,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,GAChD,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAiCrB"}
package/dist/index.js ADDED
@@ -0,0 +1,102 @@
1
+ import { TypeSafeClient, TypeSafeError, choice, noul, } from "@typesafe-ai/sdk";
2
+ export { choice, noul, score } from "@typesafe-ai/sdk";
3
+ /** The question types this version understands. */
4
+ const TYPES = new Set(["noul", "choice", "score"]);
5
+ const toQuestion = (name, spec) => {
6
+ if (typeof spec === "string")
7
+ return noul(spec);
8
+ if (Array.isArray(spec)) {
9
+ const bad = spec.find((label) => typeof label !== "string");
10
+ if (bad !== undefined)
11
+ throw new TypeSafeError(`Choice question "${name}" has a non-string label (${JSON.stringify(bad)}); labels must be strings.`);
12
+ // Mirror the SDK's own rule for score rubrics: a question nobody can answer is a
13
+ // mistake worth catching here, not a 422 from the server.
14
+ const labels = new Set(spec);
15
+ if (labels.size !== spec.length)
16
+ throw new TypeSafeError(`Choice question "${name}" repeats a label; every label must be distinct.`);
17
+ if (labels.size < 2)
18
+ throw new TypeSafeError(`Choice question "${name}" has ${labels.size} label${labels.size === 1 ? "" : "s"}; at least two are required.`);
19
+ return choice(null, Object.fromEntries([...labels].map((label) => [label, null])));
20
+ }
21
+ // Anything else has to be a question object, or the request would be silently malformed
22
+ // and the API would answer a question nobody asked.
23
+ if (spec === null || typeof spec !== "object" || !TYPES.has(spec.type))
24
+ throw new TypeSafeError(`Question "${name}" must be a string, an array of at least two labels, or a question from choice(), score() or noul(); got ${describe(spec)}.`);
25
+ return spec;
26
+ };
27
+ const describe = (value) => value === null ? "null" : Array.isArray(value) ? "an array" : typeof value;
28
+ /**
29
+ * Pull the one value a caller wants out of an answer.
30
+ *
31
+ * The answer has to match the question that was asked. A mismatch means the API and this
32
+ * package disagree about the request, and returning the wrong field would look like a
33
+ * plausible answer rather than a bug.
34
+ */
35
+ const unwrap = (name, asked, answer) => {
36
+ if (answer === null || typeof answer !== "object")
37
+ throw new TypeSafeError(`The API answered question "${name}" with ${describe(answer)} instead of an answer.`);
38
+ if (answer.type !== asked.type)
39
+ throw new TypeSafeError(`The API answered question "${name}" with a ${JSON.stringify(answer.type)} answer, but a ${JSON.stringify(asked.type)} question was asked.`);
40
+ switch (answer.type) {
41
+ case "noul":
42
+ return answer.noul;
43
+ case "choice":
44
+ return answer.choice;
45
+ case "score":
46
+ return answer.score;
47
+ }
48
+ };
49
+ let shared;
50
+ /**
51
+ * Replace the client `ask` uses. Call once at startup, or not at all.
52
+ *
53
+ * @throws {TypeSafeError} `apiKey` is present but blank. The SDK would accept it and fail
54
+ * later with a 401; failing here points at the unset variable that actually caused it.
55
+ */
56
+ export const configure = (config = {}) => {
57
+ if (config.apiKey !== undefined && config.apiKey.trim() === "")
58
+ throw new TypeSafeError("The API key is blank. Pass a real key, or omit `apiKey` to read TYPESAFE_API_KEY from the environment.");
59
+ return (shared = new TypeSafeClient(config));
60
+ };
61
+ /** The client `ask` uses, created from the environment on first use. */
62
+ export const client = () => (shared ??= new TypeSafeClient());
63
+ /**
64
+ * Ask named questions about some state and get the answers back unwrapped.
65
+ *
66
+ * A string is a yes/no question and answers with a probability from 0 to 1.
67
+ * An array of labels is a choice and answers with the selected label.
68
+ * `choice()`, `score()` and `noul()` cover the cases that need descriptions.
69
+ *
70
+ * @example
71
+ * const a = await ask("I was charged twice. Fix this ASAP.", {
72
+ * category: ["billing", "technical", "other"],
73
+ * urgent: "Is the customer angry?",
74
+ * });
75
+ * a.category; // "billing"
76
+ * a.urgent; // 0.93
77
+ * a.$.answers.category.confidence; // 0.88
78
+ * a.$.requestId; // "req_01a0ca7f5a747639aaac05b46c7dc7db"
79
+ */
80
+ export async function ask(state, spec, options = {}) {
81
+ if (spec === null || typeof spec !== "object" || Array.isArray(spec))
82
+ throw new TypeSafeError(`Questions must be an object keyed by answer name; got ${describe(spec)}.`);
83
+ if ("$" in spec)
84
+ throw new TypeSafeError('"$" is reserved for the full result; rename that question.');
85
+ const { model, ...request } = options;
86
+ if (model !== undefined && model.trim() === "")
87
+ throw new TypeSafeError("The model override is blank. Pass a model name, or omit `model`.");
88
+ const questions = Object.fromEntries(Object.entries(spec).map(([name, value]) => [name, toQuestion(name, value)]));
89
+ const { data, requestId } = await client()
90
+ .systemOne({ state, questions, model }, request)
91
+ .withResponse();
92
+ // Object.fromEntries defines own properties, so a question named "__proto__" lands as
93
+ // data rather than silently reassigning the prototype.
94
+ const answers = Object.fromEntries(Object.keys(spec).map((name) => {
95
+ const answer = data.answers[name];
96
+ if (answer === undefined)
97
+ throw new TypeSafeError(`The API returned no answer for question "${name}". Request ID: ${requestId ?? "unknown"}.`);
98
+ return [name, unwrap(name, questions[name], answer)];
99
+ }));
100
+ return { ...answers, $: { ...data, requestId } };
101
+ }
102
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@devjonaed/typesafe-ai",
3
+ "version": "0.1.0",
4
+ "description": "Unofficial two-line wrapper around the TypeSafe AI SDK: ask typed questions, get unwrapped answers.",
5
+ "keywords": [
6
+ "typesafe",
7
+ "jev",
8
+ "system-one",
9
+ "llm",
10
+ "classification",
11
+ "typescript",
12
+ "unofficial"
13
+ ],
14
+ "license": "MIT",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "author": "Md Jonaed Hasan",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/jonaed1230/typesafe-ai.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/jonaed1230/typesafe-ai/issues"
25
+ },
26
+ "homepage": "https://github.com/jonaed1230/typesafe-ai#readme",
27
+ "type": "module",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "src",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc",
44
+ "test": "npm run build && node --test",
45
+ "check:docs": "node scripts/check-readme.mjs",
46
+ "prepublishOnly": "npm test && npm run check:docs"
47
+ },
48
+ "dependencies": {
49
+ "@typesafe-ai/sdk": "^0.6.0"
50
+ },
51
+ "devDependencies": {
52
+ "typescript": "^5.9.0"
53
+ }
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,206 @@
1
+ import {
2
+ TypeSafeClient,
3
+ TypeSafeError,
4
+ choice,
5
+ noul,
6
+ type ChoiceQuestion,
7
+ type EntryType,
8
+ type NoulQuestion,
9
+ type Question,
10
+ type Questions,
11
+ type ResultFor,
12
+ type RequestOptions,
13
+ type ScoreQuestion,
14
+ type SystemOneResult,
15
+ type TypeSafeClientConfig,
16
+ } from "@typesafe-ai/sdk";
17
+
18
+ export { choice, noul, score } from "@typesafe-ai/sdk";
19
+ export type { EntryType, Question, SystemOneResult } from "@typesafe-ai/sdk";
20
+
21
+ /** A question written the short way, or a full SDK question object. */
22
+ export type Spec = string | readonly string[] | Question;
23
+
24
+ /** Questions keyed by the name their answer is returned under. */
25
+ export type SpecMap = Record<string, Spec>;
26
+
27
+ /** The unwrapped answer for one spec: a probability, a label, or a score. */
28
+ export type Answer<S> = S extends string
29
+ ? number
30
+ : S extends readonly (infer L)[]
31
+ ? L extends string
32
+ ? L
33
+ : never
34
+ : S extends ChoiceQuestion<infer C>
35
+ ? keyof C & string
36
+ : S extends ScoreQuestion<infer _>
37
+ ? number
38
+ : S extends NoulQuestion
39
+ ? number
40
+ : never;
41
+
42
+ /** The SDK question a spec expands to, so `$` keeps its precise answer types. */
43
+ export type QuestionOf<S> = S extends string
44
+ ? NoulQuestion
45
+ : S extends readonly (infer L)[]
46
+ ? [L] extends [string]
47
+ ? ChoiceQuestion<{ [Label in L]: null }>
48
+ : never
49
+ : S extends Question
50
+ ? S
51
+ : never;
52
+
53
+ /** The full SDK result, plus the request ID for the call that produced it. */
54
+ export type Meta<S extends SpecMap> = SystemOneResult<
55
+ { [K in keyof S]: QuestionOf<S[K]> } & Questions
56
+ > & {
57
+ /** Request ID from `x-typesafe-request-id`, or `undefined` when absent. Quote it to support. */
58
+ readonly requestId: string | undefined;
59
+ };
60
+
61
+ /** Unwrapped answers by name, plus `$` holding the full SDK result. */
62
+ export type Answers<S extends SpecMap> = { [K in keyof S]: Answer<S[K]> } & {
63
+ $: Meta<S>;
64
+ };
65
+
66
+ /** The question types this version understands. */
67
+ const TYPES = new Set(["noul", "choice", "score"]);
68
+
69
+ const toQuestion = (name: string, spec: Spec): Question => {
70
+ if (typeof spec === "string") return noul(spec);
71
+
72
+ if (Array.isArray(spec)) {
73
+ const bad = spec.find((label) => typeof label !== "string");
74
+ if (bad !== undefined)
75
+ throw new TypeSafeError(
76
+ `Choice question "${name}" has a non-string label (${JSON.stringify(bad)}); labels must be strings.`,
77
+ );
78
+
79
+ // Mirror the SDK's own rule for score rubrics: a question nobody can answer is a
80
+ // mistake worth catching here, not a 422 from the server.
81
+ const labels = new Set<string>(spec);
82
+ if (labels.size !== spec.length)
83
+ throw new TypeSafeError(
84
+ `Choice question "${name}" repeats a label; every label must be distinct.`,
85
+ );
86
+ if (labels.size < 2)
87
+ throw new TypeSafeError(
88
+ `Choice question "${name}" has ${labels.size} label${labels.size === 1 ? "" : "s"}; at least two are required.`,
89
+ );
90
+
91
+ return choice(null, Object.fromEntries([...labels].map((label) => [label, null])));
92
+ }
93
+
94
+ // Anything else has to be a question object, or the request would be silently malformed
95
+ // and the API would answer a question nobody asked.
96
+ if (spec === null || typeof spec !== "object" || !TYPES.has((spec as Question).type))
97
+ throw new TypeSafeError(
98
+ `Question "${name}" must be a string, an array of at least two labels, or a question from choice(), score() or noul(); got ${describe(spec)}.`,
99
+ );
100
+ return spec as Question;
101
+ };
102
+
103
+ const describe = (value: unknown): string =>
104
+ value === null ? "null" : Array.isArray(value) ? "an array" : typeof value;
105
+
106
+ /**
107
+ * Pull the one value a caller wants out of an answer.
108
+ *
109
+ * The answer has to match the question that was asked. A mismatch means the API and this
110
+ * package disagree about the request, and returning the wrong field would look like a
111
+ * plausible answer rather than a bug.
112
+ */
113
+ const unwrap = (name: string, asked: Question, answer: ResultFor<Question> | undefined) => {
114
+ if (answer === null || typeof answer !== "object")
115
+ throw new TypeSafeError(
116
+ `The API answered question "${name}" with ${describe(answer)} instead of an answer.`,
117
+ );
118
+ if (answer.type !== asked.type)
119
+ throw new TypeSafeError(
120
+ `The API answered question "${name}" with a ${JSON.stringify(answer.type)} answer, but a ${JSON.stringify(asked.type)} question was asked.`,
121
+ );
122
+
123
+ switch (answer.type) {
124
+ case "noul":
125
+ return answer.noul;
126
+ case "choice":
127
+ return answer.choice;
128
+ case "score":
129
+ return answer.score;
130
+ }
131
+ };
132
+
133
+ let shared: TypeSafeClient | undefined;
134
+
135
+ /**
136
+ * Replace the client `ask` uses. Call once at startup, or not at all.
137
+ *
138
+ * @throws {TypeSafeError} `apiKey` is present but blank. The SDK would accept it and fail
139
+ * later with a 401; failing here points at the unset variable that actually caused it.
140
+ */
141
+ export const configure = (config: TypeSafeClientConfig = {}): TypeSafeClient => {
142
+ if (config.apiKey !== undefined && config.apiKey.trim() === "")
143
+ throw new TypeSafeError(
144
+ "The API key is blank. Pass a real key, or omit `apiKey` to read TYPESAFE_API_KEY from the environment.",
145
+ );
146
+ return (shared = new TypeSafeClient(config));
147
+ };
148
+
149
+ /** The client `ask` uses, created from the environment on first use. */
150
+ export const client = (): TypeSafeClient => (shared ??= new TypeSafeClient());
151
+
152
+ /**
153
+ * Ask named questions about some state and get the answers back unwrapped.
154
+ *
155
+ * A string is a yes/no question and answers with a probability from 0 to 1.
156
+ * An array of labels is a choice and answers with the selected label.
157
+ * `choice()`, `score()` and `noul()` cover the cases that need descriptions.
158
+ *
159
+ * @example
160
+ * const a = await ask("I was charged twice. Fix this ASAP.", {
161
+ * category: ["billing", "technical", "other"],
162
+ * urgent: "Is the customer angry?",
163
+ * });
164
+ * a.category; // "billing"
165
+ * a.urgent; // 0.93
166
+ * a.$.answers.category.confidence; // 0.88
167
+ * a.$.requestId; // "req_01a0ca7f5a747639aaac05b46c7dc7db"
168
+ */
169
+ export async function ask<const S extends SpecMap>(
170
+ state: EntryType,
171
+ spec: S,
172
+ options: RequestOptions & { model?: string } = {},
173
+ ): Promise<Answers<S>> {
174
+ if (spec === null || typeof spec !== "object" || Array.isArray(spec))
175
+ throw new TypeSafeError(
176
+ `Questions must be an object keyed by answer name; got ${describe(spec)}.`,
177
+ );
178
+ if ("$" in spec)
179
+ throw new TypeSafeError(
180
+ '"$" is reserved for the full result; rename that question.',
181
+ );
182
+
183
+ const { model, ...request } = options;
184
+ if (model !== undefined && model.trim() === "")
185
+ throw new TypeSafeError("The model override is blank. Pass a model name, or omit `model`.");
186
+ const questions = Object.fromEntries(
187
+ Object.entries(spec).map(([name, value]) => [name, toQuestion(name, value)]),
188
+ ) as Questions;
189
+ const { data, requestId } = await client()
190
+ .systemOne({ state, questions, model }, request)
191
+ .withResponse();
192
+
193
+ // Object.fromEntries defines own properties, so a question named "__proto__" lands as
194
+ // data rather than silently reassigning the prototype.
195
+ const answers = Object.fromEntries(
196
+ Object.keys(spec).map((name) => {
197
+ const answer = data.answers[name];
198
+ if (answer === undefined)
199
+ throw new TypeSafeError(
200
+ `The API returned no answer for question "${name}". Request ID: ${requestId ?? "unknown"}.`,
201
+ );
202
+ return [name, unwrap(name, questions[name]!, answer)];
203
+ }),
204
+ );
205
+ return { ...answers, $: { ...data, requestId } } as Answers<S>;
206
+ }