@nola-lang/providers 0.1.8 → 0.1.9

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/dist/index.d.ts CHANGED
@@ -2,12 +2,14 @@ import { anthropic } from "./anthropic.js";
2
2
  import { google } from "./google.js";
3
3
  import { mockProvider } from "./mock.js";
4
4
  import { openai } from "./openai.js";
5
+ import { typesafe } from "./typesafe.js";
5
6
  export { type AnthropicOptions, anthropic } from "./anthropic.js";
6
7
  export { constant, exponential, fallback, isDefinitiveProviderError, type RetryPolicy, roundRobin, withRetry, } from "./combinators.js";
7
8
  export { type GoogleOptions, google } from "./google.js";
8
9
  export { type MockRequest, mockProvider } from "./mock.js";
9
10
  export { type OpenAiOptions, openai } from "./openai.js";
10
11
  export { record, replay } from "./record-replay.js";
12
+ export { type TypesafeOptions, typesafe } from "./typesafe.js";
11
13
  /**
12
14
  * Every bring-your-own provider factory, keyed by the name its provider
13
15
  * reports. The platform model is not here: `nola.infer()` lives in
@@ -17,6 +19,7 @@ export declare const providers: {
17
19
  readonly anthropic: typeof anthropic;
18
20
  readonly google: typeof google;
19
21
  readonly openai: typeof openai;
22
+ readonly typesafe: typeof typesafe;
20
23
  readonly mock: typeof mockProvider;
21
24
  };
22
25
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -2,12 +2,14 @@ import { anthropic } from "./anthropic.js";
2
2
  import { google } from "./google.js";
3
3
  import { mockProvider } from "./mock.js";
4
4
  import { openai } from "./openai.js";
5
+ import { typesafe } from "./typesafe.js";
5
6
  export { anthropic } from "./anthropic.js";
6
7
  export { constant, exponential, fallback, isDefinitiveProviderError, roundRobin, withRetry, } from "./combinators.js";
7
8
  export { google } from "./google.js";
8
9
  export { mockProvider } from "./mock.js";
9
10
  export { openai } from "./openai.js";
10
11
  export { record, replay } from "./record-replay.js";
12
+ export { typesafe } from "./typesafe.js";
11
13
  /**
12
14
  * Every bring-your-own provider factory, keyed by the name its provider
13
15
  * reports. The platform model is not here: `nola.infer()` lives in
@@ -17,6 +19,7 @@ export const providers = {
17
19
  anthropic,
18
20
  google,
19
21
  openai,
22
+ typesafe,
20
23
  mock: mockProvider,
21
24
  };
22
25
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,36 @@
1
+ import type { JsonSchema } from "@nola-lang/core";
2
+ /** One question as `POST /v1/systemone` takes it. Only the two primitives Nola output types can express. */
3
+ export type TypesafeQuestion = {
4
+ type: "choice";
5
+ instructions: string;
6
+ criteria: Record<string, string>;
7
+ } | {
8
+ type: "noul";
9
+ instructions: string;
10
+ };
11
+ export type DecodeResult = {
12
+ ok: true;
13
+ value: unknown;
14
+ } | {
15
+ ok: false;
16
+ reason: string;
17
+ };
18
+ /** Turns the wire answer for one question into the JSON value the ask expects. */
19
+ export type Decoder = (answer: unknown) => DecodeResult;
20
+ export interface QuestionPlan {
21
+ /** keyed by property name, or "value" for a scalar root */
22
+ questions: Record<string, TypesafeQuestion>;
23
+ decode: Record<string, Decoder>;
24
+ /** true ⇔ the reply is the bare "value" answer, not an object of answers */
25
+ scalar: boolean;
26
+ }
27
+ export type MappingResult = {
28
+ ok: true;
29
+ plan: QuestionPlan;
30
+ } | {
31
+ ok: false;
32
+ reason: string;
33
+ };
34
+ /** The questions and decoders for an ask's output schema, or why Jev cannot serve it. */
35
+ export declare function questionsFor(schema: JsonSchema | undefined): MappingResult;
36
+ //# sourceMappingURL=typesafe-questions.d.ts.map
@@ -0,0 +1,124 @@
1
+ const SERVES = "typesafe() serves only literal unions and booleans";
2
+ const SCALAR_INSTRUCTIONS = "Determine the value the request asks for.";
3
+ function fail(reason) {
4
+ return { ok: false, reason };
5
+ }
6
+ /** `labels` maps each wire label to the JSON value it stands for (the literal itself, or its number). */
7
+ function choiceQuestion(key, instructions, labels) {
8
+ const criteria = {};
9
+ for (const label of labels.keys())
10
+ criteria[label] = label;
11
+ const decode = (answer) => {
12
+ if (answer === undefined || answer === null)
13
+ return fail(`answer "${key}" is missing from the reply`);
14
+ const choice = answer.choice;
15
+ if (typeof choice !== "string" || !labels.has(choice)) {
16
+ return fail(`answer "${key}" chose ${JSON.stringify(choice)}, which is not one of the options sent`);
17
+ }
18
+ return { ok: true, value: labels.get(choice) };
19
+ };
20
+ return { ok: true, question: { type: "choice", instructions, criteria }, decode };
21
+ }
22
+ function noulQuestion(key, instructions) {
23
+ const decode = (answer) => {
24
+ if (answer === undefined || answer === null)
25
+ return fail(`answer "${key}" is missing from the reply`);
26
+ const noul = answer.noul;
27
+ if (typeof noul !== "number" || Number.isNaN(noul))
28
+ return fail(`answer "${key}" has no numeric noul`);
29
+ return { ok: true, value: noul >= 0.5 };
30
+ };
31
+ return { ok: true, question: { type: "noul", instructions }, decode };
32
+ }
33
+ /** Follow a `$ref` chain through the root `$defs`; the FIRST description seen along the chain wins. */
34
+ function resolve(node, defs, path) {
35
+ let current = node;
36
+ let description = node.description;
37
+ const seen = new Set();
38
+ while ("$ref" in current) {
39
+ const ref = current.$ref;
40
+ if (seen.has(ref))
41
+ return fail(`${path} is a cyclic reference ${JSON.stringify(ref)}; ${SERVES}`);
42
+ seen.add(ref);
43
+ const name = /^#\/\$defs\/(.+)$/.exec(ref)?.[1];
44
+ const next = name ? defs?.[name] : undefined;
45
+ if (!next)
46
+ return fail(`${path} is an unresolved reference ${JSON.stringify(ref)}; ${SERVES}`);
47
+ current = next;
48
+ description ??= current.description;
49
+ }
50
+ return description === undefined ? { ok: true, node: current } : { ok: true, node: current, description };
51
+ }
52
+ /** What an unsupported node is, in the words of the failure reason. */
53
+ function kindOf(node) {
54
+ if ("const" in node)
55
+ return "a single literal";
56
+ if ("anyOf" in node)
57
+ return "a union that is not all string literals or all number literals";
58
+ if ("type" in node) {
59
+ switch (node.type) {
60
+ case "string":
61
+ return node.format === "date-time" ? "a date-time string" : "a free-form string";
62
+ case "number":
63
+ case "integer":
64
+ return "a number";
65
+ case "array":
66
+ return "an array";
67
+ case "object":
68
+ return "properties" in node ? "a nested object" : "a record";
69
+ case "null":
70
+ return "null";
71
+ }
72
+ }
73
+ return "an unsupported shape";
74
+ }
75
+ /** Map one schema node to a question. `path` names the node in failure reasons; `key` is the answer key. */
76
+ function questionFor(raw, defs, path, key) {
77
+ const resolved = resolve(raw, defs, path);
78
+ if (!resolved.ok)
79
+ return resolved;
80
+ const { node, description } = resolved;
81
+ const instructions = description ?? (key === "value" ? SCALAR_INSTRUCTIONS : `Determine "${key}".`);
82
+ if ("type" in node && node.type === "boolean")
83
+ return noulQuestion(key, instructions);
84
+ if ("type" in node && node.type === "string" && node.enum) {
85
+ return choiceQuestion(key, instructions, new Map(node.enum.map((label) => [label, label])));
86
+ }
87
+ if ("anyOf" in node && node.anyOf.length >= 2) {
88
+ const consts = node.anyOf.map((branch) => ("const" in branch ? branch.const : undefined));
89
+ if (consts.every((c) => typeof c === "string")) {
90
+ return choiceQuestion(key, instructions, new Map(consts.map((c) => [c, c])));
91
+ }
92
+ if (consts.every((c) => typeof c === "number")) {
93
+ return choiceQuestion(key, instructions, new Map(consts.map((c) => [String(c), c])));
94
+ }
95
+ }
96
+ return fail(`${path} is ${kindOf(node)}; ${SERVES}`);
97
+ }
98
+ /** The questions and decoders for an ask's output schema, or why Jev cannot serve it. */
99
+ export function questionsFor(schema) {
100
+ if (!schema)
101
+ return fail(`the ask has no output schema (free text); ${SERVES}`);
102
+ const defs = "$defs" in schema ? schema.$defs : undefined;
103
+ const root = resolve(schema, defs, "the output type");
104
+ if (!root.ok)
105
+ return root;
106
+ const node = root.node;
107
+ if ("type" in node && node.type === "object" && "properties" in node) {
108
+ const questions = {};
109
+ const decode = {};
110
+ for (const [name, prop] of Object.entries(node.properties)) {
111
+ const mapped = questionFor(prop, defs, `output property ${JSON.stringify(name)}`, name);
112
+ if (!mapped.ok)
113
+ return mapped;
114
+ questions[name] = mapped.question;
115
+ decode[name] = mapped.decode;
116
+ }
117
+ return { ok: true, plan: { questions, decode, scalar: false } };
118
+ }
119
+ const mapped = questionFor(schema, defs, "the output type", "value");
120
+ if (!mapped.ok)
121
+ return mapped;
122
+ return { ok: true, plan: { questions: { value: mapped.question }, decode: { value: mapped.decode }, scalar: true } };
123
+ }
124
+ //# sourceMappingURL=typesafe-questions.js.map
@@ -0,0 +1,21 @@
1
+ import type { LanguageModel } from "@nola-lang/core";
2
+ export interface TypesafeOptions {
3
+ apiKey?: string;
4
+ /** Env var name holding the key. Default: "TYPESAFE_API_KEY". Value is read lazily at the first request. */
5
+ apiKeyEnv?: string;
6
+ /** Default: "jev-latest" — this vendor ships one model, so a bare `typesafe()` is the documented form. */
7
+ model?: string;
8
+ /** Default: "https://api.typesafe.ai" */
9
+ baseUrl?: string;
10
+ fetch?: typeof globalThis.fetch;
11
+ }
12
+ /**
13
+ * typesafe.ai's System One API (model Jev): not a chat model. One request
14
+ * answers named choice / noul questions about a `state`, so this factory
15
+ * serves exactly the asks whose output type is literal unions and
16
+ * booleans (see `questionsFor`) and fails definitively — before the
17
+ * network — on anything else, which is what lets `fallback([typesafe(),
18
+ * openai("…")])` escalate. A bare string is shorthand for `{ model }`.
19
+ */
20
+ export declare function typesafe(optionsOrModel?: TypesafeOptions | string): LanguageModel;
21
+ //# sourceMappingURL=typesafe.d.ts.map
@@ -0,0 +1,71 @@
1
+ import { joinBlocks, NolaProviderError, parseRetryAfter } from "@nola-lang/core";
2
+ import { questionsFor } from "./typesafe-questions.js";
3
+ const DEFAULT_MODEL = "jev-latest";
4
+ const DEFAULT_BASE_URL = "https://api.typesafe.ai";
5
+ /**
6
+ * typesafe.ai's System One API (model Jev): not a chat model. One request
7
+ * answers named choice / noul questions about a `state`, so this factory
8
+ * serves exactly the asks whose output type is literal unions and
9
+ * booleans (see `questionsFor`) and fails definitively — before the
10
+ * network — on anything else, which is what lets `fallback([typesafe(),
11
+ * openai("…")])` escalate. A bare string is shorthand for `{ model }`.
12
+ */
13
+ export function typesafe(optionsOrModel) {
14
+ const options = typeof optionsOrModel === "string" ? { model: optionsOrModel } : (optionsOrModel ?? {});
15
+ const doFetch = options.fetch ?? globalThis.fetch;
16
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
17
+ const model = options.model ?? DEFAULT_MODEL;
18
+ return {
19
+ name: "typesafe",
20
+ async complete(req) {
21
+ const requestedAt = Date.now();
22
+ const envName = options.apiKeyEnv ?? "TYPESAFE_API_KEY";
23
+ const apiKey = options.apiKey ?? process.env[envName];
24
+ if (!apiKey) {
25
+ throw new NolaProviderError(`TypeSafe API key not found: environment variable ${envName} is not set (checked process.env, including the project .env applied by the Nola loader) and no \`apiKey\` was passed to typesafe(). Fix: set ${envName}, or pass typesafe({ apiKeyEnv: "MY_VAR" }) or typesafe({ apiKey }) in nola.config.ts.`, { definitive: true });
26
+ }
27
+ const { system, messages, output } = req.payload;
28
+ const mapped = questionsFor(output.syntax === "json" ? output.schema : undefined);
29
+ if (!mapped.ok) {
30
+ throw new NolaProviderError(`TypeSafe cannot serve this ask: ${mapped.reason}`, { definitive: true });
31
+ }
32
+ const { plan } = mapped;
33
+ // Jev has no conversation: the state is the rendering's system text and
34
+ // its first user turn. A correction turn cannot occur in practice (the
35
+ // synthesized reply is schema-valid by construction), so any further
36
+ // messages are ignored and the ask is re-answered from the first turn.
37
+ const state = joinBlocks(system, messages[0]?.content ?? "");
38
+ const res = await doFetch(`${baseUrl}/v1/systemone`, {
39
+ method: "POST",
40
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
41
+ body: JSON.stringify({ model, state, questions: plan.questions }),
42
+ signal: req.signal ?? null,
43
+ });
44
+ if (!res.ok) {
45
+ const errorBody = await res.text();
46
+ throw new NolaProviderError(`TypeSafe request failed: ${res.status} ${res.statusText} — ${errorBody.slice(0, 500)}`, { status: res.status, retryAfterMs: parseRetryAfter(res.headers.get("retry-after")) });
47
+ }
48
+ let data;
49
+ try {
50
+ data = (await res.json());
51
+ }
52
+ catch (e) {
53
+ throw new NolaProviderError("TypeSafe reply is not JSON.", { definitive: true, cause: e });
54
+ }
55
+ const answers = data.answers;
56
+ if (answers === null || typeof answers !== "object" || Array.isArray(answers)) {
57
+ throw new NolaProviderError("TypeSafe reply is malformed: no answers object", { definitive: true });
58
+ }
59
+ const value = {};
60
+ for (const [key, decode] of Object.entries(plan.decode)) {
61
+ const decoded = decode(answers[key]);
62
+ if (!decoded.ok)
63
+ throw new NolaProviderError(`TypeSafe reply is malformed: ${decoded.reason}`, { definitive: true });
64
+ value[key] = decoded.value;
65
+ }
66
+ const durationMs = Date.now() - requestedAt;
67
+ return { text: JSON.stringify(plan.scalar ? value.value : value), durationMs };
68
+ },
69
+ };
70
+ }
71
+ //# sourceMappingURL=typesafe.js.map
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@nola-lang/providers",
3
- "version": "0.1.8",
4
- "description": "Nola bring-your-own LLM providers: openai, anthropic, google, mock, resilience combinators, record/replay",
3
+ "version": "0.1.9",
4
+ "description": "Nola bring-your-own LLM providers: openai, anthropic, google, typesafe, mock, resilience combinators, record/replay",
5
5
  "keywords": [
6
6
  "nola",
7
7
  "llm",
8
8
  "ai",
9
9
  "openai",
10
+ "typesafe",
10
11
  "providers"
11
12
  ],
12
13
  "license": "Apache-2.0",
@@ -36,8 +37,8 @@
36
37
  "!dist/**/*.map"
37
38
  ],
38
39
  "dependencies": {
39
- "@nola-lang/ast": "0.1.8",
40
- "@nola-lang/core": "0.1.8"
40
+ "@nola-lang/ast": "0.1.9",
41
+ "@nola-lang/core": "0.1.9"
41
42
  },
42
43
  "engines": {
43
44
  "node": ">=22"