@nola-lang/providers 0.1.12 → 0.1.14

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.
@@ -9,6 +9,12 @@ export interface AnthropicOptions {
9
9
  fetch?: typeof globalThis.fetch;
10
10
  /** The Messages API requires max_tokens on every request. Default: 4096. */
11
11
  maxOutputTokens?: number;
12
+ /**
13
+ * Default true: the schema rides the API's structured-output field. false:
14
+ * nothing is sent on the wire and the schema is rendered into the system
15
+ * turn as a <schema> block — for a proxy or server that cannot enforce it.
16
+ */
17
+ structuredOutputs?: boolean;
12
18
  }
13
19
  /** A bare model string is shorthand for `{ model }` — every other option defaulted. */
14
20
  export declare function anthropic(optionsOrModel: AnthropicOptions | string): LanguageModel;
package/dist/anthropic.js CHANGED
@@ -1,5 +1,5 @@
1
- import { NolaProviderError, parseRetryAfter } from "@nola-lang/core";
2
- import { ENVELOPE_NOTE, envelope, resolveRootRef } from "./wire.js";
1
+ import { NolaProviderError, parseRetryAfter, renderPrompt } from "@nola-lang/core";
2
+ import { ENVELOPE_NOTE, envelope, resolveRootRef, schemaNote } from "./wire.js";
3
3
  const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
4
4
  /** A bare model string is shorthand for `{ model }` — every other option defaulted. */
5
5
  export function anthropic(optionsOrModel) {
@@ -10,26 +10,30 @@ export function anthropic(optionsOrModel) {
10
10
  const maxTokens = options.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
11
11
  return {
12
12
  name: "anthropic",
13
- async complete(req) {
13
+ async infer(req) {
14
14
  const requestedAt = Date.now();
15
15
  const envName = options.apiKeyEnv ?? "ANTHROPIC_API_KEY";
16
16
  const apiKey = options.apiKey ?? process.env[envName];
17
17
  if (!apiKey) {
18
18
  throw new NolaProviderError(`Anthropic 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 anthropic(). Fix: set ${envName}, or pass anthropic({ apiKeyEnv: "MY_VAR" }) or anthropic({ apiKey }) in nola.config.ts.`, { definitive: true });
19
19
  }
20
- const { system: baseSystem, messages, output } = req.payload;
20
+ const { system: baseSystem, messages } = renderPrompt(req.intent);
21
+ const output = req.intent.output;
21
22
  const reqSchema = output.syntax === "json" ? output.schema : undefined;
22
23
  const rootShape = reqSchema === undefined ? undefined : resolveRootRef(reqSchema);
23
24
  const enveloped = rootShape !== undefined && !("$ref" in rootShape) && !("type" in rootShape && rootShape.type === "object");
24
25
  const transport = reqSchema === undefined ? undefined : enveloped ? envelope(reqSchema) : reqSchema;
25
- const system = enveloped ? baseSystem + ENVELOPE_NOTE : baseSystem;
26
+ // structuredOutputs: false — the schema goes into the system turn instead of the wire field
27
+ const enforce = options.structuredOutputs !== false;
28
+ const system = (enveloped ? baseSystem + ENVELOPE_NOTE : baseSystem) + (!enforce && transport ? schemaNote(transport) : "");
29
+ const sent = { system, messages };
26
30
  const body = {
27
31
  model,
28
32
  max_tokens: maxTokens,
29
33
  system,
30
34
  messages,
31
35
  };
32
- if (transport) {
36
+ if (transport && enforce) {
33
37
  body.output_config = { format: { type: "json_schema", schema: transport } };
34
38
  }
35
39
  const res = await doFetch(`${baseUrl}/v1/messages`, {
@@ -52,7 +56,7 @@ export function anthropic(optionsOrModel) {
52
56
  const content = textBlock.text;
53
57
  const durationMs = Date.now() - requestedAt;
54
58
  if (!reqSchema)
55
- return { text: content, durationMs };
59
+ return { text: content, durationMs, sent };
56
60
  let parsed;
57
61
  try {
58
62
  parsed = JSON.parse(content);
@@ -61,7 +65,7 @@ export function anthropic(optionsOrModel) {
61
65
  throw new NolaProviderError("Anthropic returned non-JSON despite structured outputs.", { cause: e });
62
66
  }
63
67
  const value = enveloped ? parsed.value : parsed;
64
- return { text: JSON.stringify(value), durationMs };
68
+ return { text: JSON.stringify(value), durationMs, sent };
65
69
  },
66
70
  };
67
71
  }
@@ -25,7 +25,7 @@ export declare function isDefinitiveProviderError(error: unknown): boolean;
25
25
  * with no delay) ignores the header entirely. Distinct from the intent method
26
26
  * `.withRetry(n)`, which flat-retries the entire ask (composition, provider
27
27
  * call, parse, validation) with no backoff and no definitive-error check.
28
- * Mirrors the inner's dialect and decision brand.
28
+ * Forwards `infer` and carries the inner's decision brand.
29
29
  */
30
30
  export declare function withRetry(provider: LanguageModel, policy: RetryPolicy): LanguageModel;
31
31
  export declare function fallback(providers: LanguageModel[]): LanguageModel;
@@ -1,6 +1,7 @@
1
1
  import { Codes } from "@nola-lang/ast";
2
- import { DECISION_MODEL, isDecisionModel, isInferModel, isPlatformModel, NolaConfigError, NolaProviderError } from "@nola-lang/core";
3
- import { callModel, isDecisionRequest } from "./dialect.js";
2
+ import { DECISION_MODEL, isDecisionModel, isPlatformModel, NolaConfigError, NolaProviderError } from "@nola-lang/core";
3
+ import { isDecisionRequest } from "./decision-request.js";
4
+ import { requireInfer } from "./require-infer.js";
4
5
  export function constant(opts) {
5
6
  const delayMs = opts.delayMs ?? 0;
6
7
  return { maxRetries: opts.maxRetries, delayMs, multiplier: 1, maxDelayMs: delayMs };
@@ -45,18 +46,10 @@ function rejectPlatformModel(models, combinator) {
45
46
  function describeError(error) {
46
47
  return error instanceof Error ? error.message : String(error);
47
48
  }
48
- /**
49
- * Build the outer model of a combinator (decision types spec 2026-09-18
50
- * §6.1–6.2): infer-dialect iff any inner is (chat inners get the rendering
51
- * through callModel), branded a decision model iff any inner is. `run`
52
- * receives the request in the outer dialect.
53
- */
49
+ /** Build the outer model of a combinator: branded a decision model iff any inner is; `run` receives the request as is. */
54
50
  function outer(name, inners, run) {
55
51
  const brand = inners.some((m) => isDecisionModel(m)) ? { [DECISION_MODEL]: true } : {};
56
- const model = inners.some((m) => isInferModel(m))
57
- ? { name, ...brand, infer: (req) => run(req) }
58
- : { name, ...brand, complete: (req) => run(req) };
59
- return model;
52
+ return { name, ...brand, infer: (req) => run(req) };
60
53
  }
61
54
  /**
62
55
  * Wire-level retry: re-attempts the single provider call with backoff,
@@ -66,9 +59,10 @@ function outer(name, inners, run) {
66
59
  * with no delay) ignores the header entirely. Distinct from the intent method
67
60
  * `.withRetry(n)`, which flat-retries the entire ask (composition, provider
68
61
  * call, parse, validation) with no backoff and no definitive-error check.
69
- * Mirrors the inner's dialect and decision brand.
62
+ * Forwards `infer` and carries the inner's decision brand.
70
63
  */
71
64
  export function withRetry(provider, policy) {
65
+ requireInfer([provider], "withRetry");
72
66
  rejectPlatformModel([provider], "withRetry");
73
67
  const inner = provider;
74
68
  return outer(`retry(${provider.name})`, [inner], async (req) => {
@@ -76,7 +70,7 @@ export function withRetry(provider, policy) {
76
70
  let lastError;
77
71
  for (let attempt = 0; attempt <= policy.maxRetries; attempt++) {
78
72
  try {
79
- return await callModel(inner, req);
73
+ return await inner.infer(req);
80
74
  }
81
75
  catch (error) {
82
76
  lastError = error;
@@ -100,7 +94,7 @@ async function tryInOrder(name, ordered, req) {
100
94
  continue;
101
95
  }
102
96
  try {
103
- return await callModel(p, req);
97
+ return await p.infer(req);
104
98
  }
105
99
  catch (error) {
106
100
  failures.push(`${p.name}: ${describeError(error)}`);
@@ -110,6 +104,7 @@ async function tryInOrder(name, ordered, req) {
110
104
  }
111
105
  export function fallback(providers) {
112
106
  requireModels(providers, "fallback");
107
+ requireInfer(providers, "fallback");
113
108
  rejectPlatformModel(providers, "fallback");
114
109
  const inners = providers;
115
110
  const name = `fallback(${providers.map((p) => p.name).join(", ")})`;
@@ -117,6 +112,7 @@ export function fallback(providers) {
117
112
  }
118
113
  export function roundRobin(providers) {
119
114
  requireModels(providers, "roundRobin");
115
+ requireInfer(providers, "roundRobin");
120
116
  rejectPlatformModel(providers, "roundRobin");
121
117
  const inners = providers;
122
118
  const name = `roundRobin(${providers.map((p) => p.name).join(", ")})`;
@@ -0,0 +1,4 @@
1
+ import type { InferRequest } from "@nola-lang/core";
2
+ /** True when the request's output schema carries a decision question (Choice / Scale / Prob). */
3
+ export declare function isDecisionRequest(req: InferRequest): boolean;
4
+ //# sourceMappingURL=decision-request.d.ts.map
@@ -0,0 +1,7 @@
1
+ import { findDecisionQuestions } from "@nola-lang/core";
2
+ /** True when the request's output schema carries a decision question (Choice / Scale / Prob). */
3
+ export function isDecisionRequest(req) {
4
+ const output = req.intent.output;
5
+ return output.syntax === "json" && findDecisionQuestions(output.schema).length > 0;
6
+ }
7
+ //# sourceMappingURL=decision-request.js.map
package/dist/decisions.js CHANGED
@@ -26,12 +26,12 @@ function contextualState(model) {
26
26
  }
27
27
  return any ? state : undefined;
28
28
  }
29
- const askText = (model) => model.input.text ?? model.input.instruction;
30
- /** What the model says about the ask, outer→inner: system, each scope's text, then (optionally) the ask text. */
29
+ const askText = (model) => model.input.instruction;
30
+ /** What the model says about the ask, outer→inner: system, each scope's instruction, then (optionally) the ask text. */
31
31
  function contextText(model, includeAskText) {
32
32
  const parts = [
33
33
  model.system ?? "",
34
- ...scopesOuterFirst(model.scope).map((s) => s.text ?? s.instruction),
34
+ ...scopesOuterFirst(model.scope).map((s) => s.instruction),
35
35
  includeAskText ? askText(model) : "",
36
36
  ];
37
37
  return parts.filter((p) => p.trim() !== "").reduce((acc, p) => joinBlocks(acc, p), "");
package/dist/google.d.ts CHANGED
@@ -7,6 +7,12 @@ export interface GoogleOptions {
7
7
  model: string;
8
8
  baseUrl?: string;
9
9
  fetch?: typeof globalThis.fetch;
10
+ /**
11
+ * Default true: the schema rides the API's structured-output field. false:
12
+ * nothing is sent on the wire and the schema is rendered into the system
13
+ * turn as a <schema> block — for a proxy or server that cannot enforce it.
14
+ */
15
+ structuredOutputs?: boolean;
10
16
  }
11
17
  /** A bare model string is shorthand for `{ model }` — every other option defaulted. */
12
18
  export declare function google(optionsOrModel: GoogleOptions | string): LanguageModel;
package/dist/google.js CHANGED
@@ -1,5 +1,5 @@
1
- import { NolaProviderError, parseRetryAfter } from "@nola-lang/core";
2
- import { ENVELOPE_NOTE, envelope, resolveRootRef } from "./wire.js";
1
+ import { NolaProviderError, parseRetryAfter, renderPrompt } from "@nola-lang/core";
2
+ import { ENVELOPE_NOTE, envelope, resolveRootRef, schemaNote } from "./wire.js";
3
3
  /** A bare model string is shorthand for `{ model }` — every other option defaulted. */
4
4
  export function google(optionsOrModel) {
5
5
  const options = typeof optionsOrModel === "string" ? { model: optionsOrModel } : optionsOrModel;
@@ -8,19 +8,23 @@ export function google(optionsOrModel) {
8
8
  const model = options.model;
9
9
  return {
10
10
  name: "google",
11
- async complete(req) {
11
+ async infer(req) {
12
12
  const requestedAt = Date.now();
13
13
  const envName = options.apiKeyEnv ?? "GEMINI_API_KEY";
14
14
  const apiKey = options.apiKey ?? process.env[envName];
15
15
  if (!apiKey) {
16
16
  throw new NolaProviderError(`Google 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 google(). Fix: set ${envName}, or pass google({ apiKeyEnv: "MY_VAR" }) or google({ apiKey }) in nola.config.ts.`, { definitive: true });
17
17
  }
18
- const { system: baseSystem, messages, output } = req.payload;
18
+ const { system: baseSystem, messages } = renderPrompt(req.intent);
19
+ const output = req.intent.output;
19
20
  const reqSchema = output.syntax === "json" ? output.schema : undefined;
20
21
  const rootShape = reqSchema === undefined ? undefined : resolveRootRef(reqSchema);
21
22
  const enveloped = rootShape !== undefined && !("$ref" in rootShape) && !("type" in rootShape && rootShape.type === "object");
22
23
  const transport = reqSchema === undefined ? undefined : enveloped ? envelope(reqSchema) : reqSchema;
23
- const system = enveloped ? baseSystem + ENVELOPE_NOTE : baseSystem;
24
+ // structuredOutputs: false — the schema goes into the system turn instead of the wire field
25
+ const enforce = options.structuredOutputs !== false;
26
+ const system = (enveloped ? baseSystem + ENVELOPE_NOTE : baseSystem) + (!enforce && transport ? schemaNote(transport) : "");
27
+ const sent = { system, messages };
24
28
  const body = {
25
29
  system_instruction: { parts: [{ text: system }] },
26
30
  contents: messages.map((m) => ({
@@ -28,7 +32,7 @@ export function google(optionsOrModel) {
28
32
  parts: [{ text: m.content }],
29
33
  })),
30
34
  };
31
- if (transport) {
35
+ if (transport && enforce) {
32
36
  body.generationConfig = { responseMimeType: "application/json", responseJsonSchema: transport };
33
37
  }
34
38
  const res = await doFetch(`${baseUrl}/v1beta/models/${model}:generateContent`, {
@@ -51,7 +55,7 @@ export function google(optionsOrModel) {
51
55
  const content = part.text;
52
56
  const durationMs = Date.now() - requestedAt;
53
57
  if (!reqSchema)
54
- return { text: content, durationMs };
58
+ return { text: content, durationMs, sent };
55
59
  let parsed;
56
60
  try {
57
61
  parsed = JSON.parse(content);
@@ -60,7 +64,7 @@ export function google(optionsOrModel) {
60
64
  throw new NolaProviderError("Google returned non-JSON despite structured output.", { cause: e });
61
65
  }
62
66
  const value = enveloped ? parsed.value : parsed;
63
- return { text: JSON.stringify(value), durationMs };
67
+ return { text: JSON.stringify(value), durationMs, sent };
64
68
  },
65
69
  };
66
70
  }
package/dist/index.d.ts CHANGED
@@ -3,9 +3,10 @@ import { google } from "./google.js";
3
3
  import { mockProvider } from "./mock.js";
4
4
  import { openai } from "./openai.js";
5
5
  import { typesafe } from "./typesafe.js";
6
+ export { DEFAULT_SYSTEM, type InferRequest, type InferResult, type RenderedPrompt, renderPrompt } from "@nola-lang/core";
6
7
  export { type AnthropicOptions, anthropic } from "./anthropic.js";
7
8
  export { constant, exponential, fallback, isDefinitiveProviderError, type RetryPolicy, roundRobin, withRetry, } from "./combinators.js";
8
- export { callModel, isDecisionRequest, toClassic } from "./dialect.js";
9
+ export { isDecisionRequest } from "./decision-request.js";
9
10
  export { type GoogleOptions, google } from "./google.js";
10
11
  export { type MockOptions, type MockRequest, mockProvider } from "./mock.js";
11
12
  export { type OpenAiOptions, openai } from "./openai.js";
package/dist/index.js CHANGED
@@ -3,9 +3,10 @@ import { google } from "./google.js";
3
3
  import { mockProvider } from "./mock.js";
4
4
  import { openai } from "./openai.js";
5
5
  import { typesafe } from "./typesafe.js";
6
+ export { DEFAULT_SYSTEM, renderPrompt } from "@nola-lang/core";
6
7
  export { anthropic } from "./anthropic.js";
7
8
  export { constant, exponential, fallback, isDefinitiveProviderError, roundRobin, withRetry, } from "./combinators.js";
8
- export { callModel, isDecisionRequest, toClassic } from "./dialect.js";
9
+ export { isDecisionRequest } from "./decision-request.js";
9
10
  export { google } from "./google.js";
10
11
  export { mockProvider } from "./mock.js";
11
12
  export { openai } from "./openai.js";
package/dist/mock.d.ts CHANGED
@@ -1,6 +1,8 @@
1
- import type { LanguageModel, ProviderRequest } from "@nola-lang/core";
2
- /** What a mock callback sees: the classic request — `payload` IS the rendering (reshape 2026-09-01). */
3
- export type MockRequest = ProviderRequest;
1
+ import type { InferRequest, LanguageModel, RenderedPrompt } from "@nola-lang/core";
2
+ /** What a mock callback sees: the intent, plus its default rendering on demand. */
3
+ export type MockRequest = InferRequest & {
4
+ readonly prompt: RenderedPrompt;
5
+ };
4
6
  export interface MockOptions {
5
7
  /**
6
8
  * Brand the mock a decision model (decision types spec 2026-09-18 §6.1): a
package/dist/mock.js CHANGED
@@ -1,10 +1,18 @@
1
- import { DECISION_MODEL } from "@nola-lang/core";
1
+ import { DECISION_MODEL, renderPrompt } from "@nola-lang/core";
2
+ /** The request with a memoized `prompt` getter — rendered only when a callback reads it. */
3
+ function withPrompt(req) {
4
+ let rendered;
5
+ return Object.defineProperty({ ...req }, "prompt", {
6
+ enumerable: false,
7
+ get: () => (rendered ??= renderPrompt(req.intent)),
8
+ });
9
+ }
2
10
  export function mockProvider(source, options = {}) {
3
11
  const queue = Array.isArray(source) ? [...source] : null;
4
12
  return {
5
13
  ...(options.decisions ? { [DECISION_MODEL]: true } : {}),
6
14
  name: "mock",
7
- async complete(req) {
15
+ async infer(req) {
8
16
  let value;
9
17
  if (queue) {
10
18
  if (queue.length === 0)
@@ -12,7 +20,7 @@ export function mockProvider(source, options = {}) {
12
20
  value = queue.shift();
13
21
  }
14
22
  else {
15
- value = source(req);
23
+ value = source(withPrompt(req));
16
24
  }
17
25
  return { text: JSON.stringify(value) };
18
26
  },
package/dist/openai.d.ts CHANGED
@@ -7,6 +7,13 @@ export interface OpenAiOptions {
7
7
  model: string;
8
8
  baseUrl?: string;
9
9
  fetch?: typeof globalThis.fetch;
10
+ /**
11
+ * Default true: the schema rides the API's structured-output field. false:
12
+ * nothing is sent on the wire and the schema is rendered into the system
13
+ * turn as a <schema> block — for OpenAI-compatible servers that ignore or
14
+ * reject json_schema.
15
+ */
16
+ structuredOutputs?: boolean;
10
17
  }
11
18
  /** A bare model string is shorthand for `{ model }` — every other option defaulted. */
12
19
  export declare function openai(optionsOrModel: OpenAiOptions | string): LanguageModel;
package/dist/openai.js CHANGED
@@ -1,5 +1,23 @@
1
- import { NolaProviderError, parseRetryAfter } from "@nola-lang/core";
2
- import { ENVELOPE_NOTE, envelope, resolveRootRef } from "./wire.js";
1
+ import { NolaProviderError, parseRetryAfter, renderPrompt } from "@nola-lang/core";
2
+ import { ENVELOPE_NOTE, envelope, resolveRootRef, schemaNote } from "./wire.js";
3
+ /**
4
+ * Keywords OpenAI's strict mode accepts beside type/enum/structure (its
5
+ * "supported properties" list); anything else is dropped. `format` is
6
+ * forwarded only for the values that list names — an unknown format value is
7
+ * a 400 from the API. minLength/maxLength are NOT forwarded: unverified.
8
+ */
9
+ const STRING_KEYWORDS = ["pattern"];
10
+ const OPENAI_FORMATS = new Set(["date-time", "time", "date", "duration", "email", "hostname", "ipv4", "ipv6", "uuid"]);
11
+ const NUMBER_KEYWORDS = ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"];
12
+ const ARRAY_KEYWORDS = ["minItems", "maxItems"];
13
+ function pick(schema, keys) {
14
+ const out = {};
15
+ const s = schema;
16
+ for (const k of keys)
17
+ if (s[k] !== undefined)
18
+ out[k] = s[k];
19
+ return out;
20
+ }
3
21
  /** Strict mode requires every property required; optionals become anyOf [T, null]. */
4
22
  function toStrict(schema) {
5
23
  const out = toStrictNode(schema);
@@ -17,18 +35,22 @@ function toStrict(schema) {
17
35
  function toStrictNode(schema) {
18
36
  if ("$ref" in schema)
19
37
  return { $ref: schema.$ref };
38
+ // JSDoc descriptions ride every node: since the prompt no longer carries the
39
+ // schema text (prompt-rendering spec 2026-09-28), the wire schema is the only
40
+ // channel a property's description has to the model.
41
+ const described = "description" in schema && schema.description !== undefined ? { description: schema.description } : {};
20
42
  // emit 15 shapes: choice passes through; strict mode accepts anyOf.
21
43
  // A literal keeps its `const` but gains the `type` it implies: OpenAI-compatible
22
44
  // backends (Cerebras) reject a bare `{ const }` node as an unsupported field,
23
45
  // and every backend accepts the typed form (it is a strict subset).
24
46
  if ("anyOf" in schema)
25
- return { anyOf: schema.anyOf.map(toStrictNode) };
47
+ return { ...described, anyOf: schema.anyOf.map(toStrictNode) };
26
48
  if ("const" in schema)
27
- return { type: typeof schema.const, const: schema.const };
49
+ return { ...described, type: typeof schema.const, const: schema.const };
28
50
  switch (schema.type) {
29
51
  case "object": {
30
52
  if (!("properties" in schema)) {
31
- return { type: "object", additionalProperties: toStrictNode(schema.additionalProperties) };
53
+ return { ...described, type: "object", additionalProperties: toStrictNode(schema.additionalProperties) };
32
54
  }
33
55
  const properties = {};
34
56
  for (const [key, prop] of Object.entries(schema.properties)) {
@@ -36,6 +58,7 @@ function toStrictNode(schema) {
36
58
  properties[key] = schema.required.includes(key) ? strict : { anyOf: [strict, { type: "null" }] };
37
59
  }
38
60
  return {
61
+ ...described,
39
62
  type: "object",
40
63
  properties,
41
64
  required: Object.keys(schema.properties),
@@ -45,6 +68,7 @@ function toStrictNode(schema) {
45
68
  case "array":
46
69
  if ("prefixItems" in schema) {
47
70
  return {
71
+ ...described,
48
72
  type: "array",
49
73
  prefixItems: schema.prefixItems.map(toStrictNode),
50
74
  items: false,
@@ -52,13 +76,22 @@ function toStrictNode(schema) {
52
76
  maxItems: schema.maxItems,
53
77
  };
54
78
  }
55
- return { type: "array", items: toStrictNode(schema.items) };
79
+ return { ...described, type: "array", items: toStrictNode(schema.items), ...pick(schema, ARRAY_KEYWORDS) };
80
+ case "string":
81
+ return {
82
+ ...described,
83
+ type: "string",
84
+ ...(schema.enum ? { enum: [...schema.enum] } : {}),
85
+ ...pick(schema, STRING_KEYWORDS),
86
+ ...(schema.format !== undefined && OPENAI_FORMATS.has(schema.format) ? { format: schema.format } : {}),
87
+ };
88
+ case "number":
89
+ case "integer":
90
+ return { ...described, type: schema.type, ...pick(schema, NUMBER_KEYWORDS) };
56
91
  case "null":
57
92
  return { type: "null" };
58
93
  default:
59
- return schema.type === "string" && schema.enum
60
- ? { type: "string", enum: [...schema.enum] }
61
- : { type: schema.type };
94
+ return { ...described, type: schema.type };
62
95
  }
63
96
  }
64
97
  /**
@@ -128,24 +161,28 @@ export function openai(optionsOrModel) {
128
161
  const model = options.model;
129
162
  return {
130
163
  name: "openai",
131
- async complete(req) {
164
+ async infer(req) {
132
165
  const requestedAt = Date.now();
133
166
  const envName = options.apiKeyEnv ?? "OPENAI_API_KEY";
134
167
  const apiKey = options.apiKey ?? process.env[envName];
135
168
  if (!apiKey) {
136
169
  throw new NolaProviderError(`OpenAI 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 openai(). Fix: set ${envName}, or pass openai({ apiKeyEnv: "MY_VAR" }) or openai({ apiKey }) in nola.config.ts.`, { definitive: true });
137
170
  }
138
- const { system: baseSystem, messages, output } = req.payload;
171
+ const { system: baseSystem, messages } = renderPrompt(req.intent);
172
+ const output = req.intent.output;
139
173
  const reqSchema = output.syntax === "json" ? output.schema : undefined;
140
174
  const rootShape = reqSchema === undefined ? undefined : resolveRootRef(reqSchema);
141
175
  const enveloped = rootShape !== undefined && !("$ref" in rootShape) && !("type" in rootShape && rootShape.type === "object");
142
176
  const transport = reqSchema === undefined ? undefined : enveloped ? envelope(reqSchema) : reqSchema;
143
- const system = enveloped ? baseSystem + ENVELOPE_NOTE : baseSystem;
177
+ // structuredOutputs: false — the schema goes into the system turn instead of the wire field
178
+ const enforce = options.structuredOutputs !== false;
179
+ const system = (enveloped ? baseSystem + ENVELOPE_NOTE : baseSystem) + (!enforce && transport ? schemaNote(transport) : "");
180
+ const sent = { system, messages };
144
181
  const body = {
145
182
  model,
146
183
  messages: [{ role: "system", content: system }, ...messages],
147
184
  };
148
- if (transport) {
185
+ if (transport && enforce) {
149
186
  body.response_format = {
150
187
  type: "json_schema",
151
188
  json_schema: { name: "nola_extraction", strict: true, schema: toStrict(transport) },
@@ -162,7 +199,7 @@ export function openai(optionsOrModel) {
162
199
  if (reqSchema !== undefined) {
163
200
  const recovered = recoverFailedGeneration(errorBody, enveloped, reqSchema);
164
201
  if (recovered !== undefined)
165
- return { text: recovered };
202
+ return { text: recovered, sent };
166
203
  }
167
204
  throw new NolaProviderError(`OpenAI request failed: ${res.status} ${res.statusText} — ${errorBody.slice(0, 500)}`, {
168
205
  status: res.status,
@@ -174,7 +211,7 @@ export function openai(optionsOrModel) {
174
211
  if (typeof content !== "string")
175
212
  throw new NolaProviderError("OpenAI response had no message content.");
176
213
  if (!reqSchema)
177
- return { text: content };
214
+ return { text: content, sent };
178
215
  let parsed;
179
216
  try {
180
217
  parsed = JSON.parse(content);
@@ -184,7 +221,7 @@ export function openai(optionsOrModel) {
184
221
  }
185
222
  const value = enveloped ? parsed.value : parsed;
186
223
  const durationMs = Date.now() - requestedAt;
187
- return { text: JSON.stringify(fromStrict(value, reqSchema)), durationMs };
224
+ return { text: JSON.stringify(fromStrict(value, reqSchema)), durationMs, sent };
188
225
  },
189
226
  };
190
227
  }
@@ -3,10 +3,9 @@ import type { LanguageModel, PlatformModel } from "@nola-lang/core";
3
3
  * Pass-through provider that appends `{ fingerprint, request, response }` JSONL
4
4
  * entries. The fingerprint is computed from the RAW request (so replay-time
5
5
  * lookups match) and is never redacted; persisted content strings are. The
6
- * payload is stored as sent — the classic rendering for a chat provider, the
7
- * model for the platform model — and `record` MIRRORS the inner kind
8
- * (platform-config design 2026-09-03): a platform inner yields a branded
9
- * platform model exposing infer; record(openai()) exposes complete.
6
+ * ledger stores the INTENT (the ask as data, never a rendering), and `record`
7
+ * carries the inner's brands: a platform inner yields a branded platform
8
+ * model, a decision inner a decision model.
10
9
  */
11
10
  export declare function record(inner: PlatformModel, ledgerPath: string): PlatformModel;
12
11
  export declare function record(inner: LanguageModel, ledgerPath: string): LanguageModel;
@@ -14,17 +13,17 @@ export declare function record(inner: LanguageModel, ledgerPath: string): Langua
14
13
  * Offline provider serving recorded responses by request fingerprint. Strict:
15
14
  * an unrecorded request is a definitive error, never a silent live call.
16
15
  *
17
- * A fingerprint is computed over the classic rendering as first composed —
18
- * whichever payload shape arrives (see `fingerprintRequest`) — so a ledger
19
- * recorded through any provider replays for any other, and one ask's first
20
- * attempt and its correction retry hash identically — a recorded correction pair appends TWO
21
- * ledger lines under the SAME key. Entries are served FIFO per fingerprint:
22
- * each `complete()` shifts the next recorded response off that key's queue,
23
- * so a replayed session reproduces the correction turn instead of jumping
24
- * straight to the corrected answer. Once only one entry remains for a key it
25
- * keeps serving that last one — a single-entry key (the common case) behaves
26
- * exactly as a plain map lookup, and repeat asks beyond what was recorded
27
- * still get an answer instead of failing.
16
+ * A fingerprint is computed over the intent as first composed (see
17
+ * `fingerprintRequest`), so a ledger recorded through any provider replays
18
+ * for any other, and one ask's first attempt and its correction retry hash
19
+ * identically — a recorded correction pair appends TWO ledger lines under
20
+ * the SAME key. Entries are served FIFO per fingerprint: each `infer()`
21
+ * shifts the next recorded response off that key's queue, so a replayed
22
+ * session reproduces the correction turn instead of jumping straight to the
23
+ * corrected answer. Once only one entry remains for a key it keeps serving
24
+ * that last one — a single-entry key (the common case) behaves exactly as a
25
+ * plain map lookup, and repeat asks beyond what was recorded still get an
26
+ * answer instead of failing.
28
27
  */
29
28
  export declare function replay(ledgerPath: string): LanguageModel;
30
29
  //# sourceMappingURL=record-replay.d.ts.map
@@ -1,41 +1,21 @@
1
1
  import { appendFileSync, readFileSync } from "node:fs";
2
2
  import { Codes } from "@nola-lang/ast";
3
- import { DECISION_MODEL, fingerprintRequest, isDecisionModel, isInferModel, isPlatformModel, NolaConfigError, NolaProviderError, PLATFORM_MODEL, redactDeep, redactSecrets, } from "@nola-lang/core";
3
+ import { DECISION_MODEL, fingerprintRequest, isDecisionModel, isPlatformModel, NolaConfigError, NolaProviderError, PLATFORM_MODEL, redactDeep, redactSecrets, } from "@nola-lang/core";
4
+ import { requireInfer } from "./require-infer.js";
4
5
  export function record(inner, ledgerPath) {
5
- // the brands ride along: the platform's (its own rules) and the decision capability
6
+ requireInfer([inner], "record");
6
7
  const brands = {
7
8
  ...(isPlatformModel(inner) ? { [PLATFORM_MODEL]: true } : {}),
8
9
  ...(isDecisionModel(inner) ? { [DECISION_MODEL]: true } : {}),
9
10
  };
10
- if (isInferModel(inner)) {
11
- return {
12
- ...brands,
13
- name: `record(${inner.name})`,
14
- async infer(req) {
15
- const res = await inner.infer(req);
16
- const entry = {
17
- fingerprint: fingerprintRequest({
18
- payload: req.model,
19
- ...(req.params ? { params: req.params } : {}),
20
- ...(req.profile !== undefined ? { profile: req.profile } : {}),
21
- }),
22
- request: { payload: redactDeep(req.model), ...(req.params ? { params: req.params } : {}) },
23
- response: { text: redactSecrets(res.text) },
24
- };
25
- appendFileSync(ledgerPath, `${JSON.stringify(entry)}\n`, "utf8");
26
- return res;
27
- },
28
- };
29
- }
30
- const chat = inner;
31
11
  return {
32
12
  ...brands,
33
13
  name: `record(${inner.name})`,
34
- async complete(req) {
35
- const res = await chat.complete(req);
14
+ async infer(req) {
15
+ const res = await inner.infer(req);
36
16
  const entry = {
37
17
  fingerprint: fingerprintRequest(req),
38
- request: { payload: redactDeep(req.payload), ...(req.params ? { params: req.params } : {}) },
18
+ request: { intent: redactDeep(req.intent), ...(req.params ? { params: req.params } : {}) },
39
19
  response: { text: redactSecrets(res.text) },
40
20
  };
41
21
  appendFileSync(ledgerPath, `${JSON.stringify(entry)}\n`, "utf8");
@@ -47,17 +27,17 @@ export function record(inner, ledgerPath) {
47
27
  * Offline provider serving recorded responses by request fingerprint. Strict:
48
28
  * an unrecorded request is a definitive error, never a silent live call.
49
29
  *
50
- * A fingerprint is computed over the classic rendering as first composed —
51
- * whichever payload shape arrives (see `fingerprintRequest`) — so a ledger
52
- * recorded through any provider replays for any other, and one ask's first
53
- * attempt and its correction retry hash identically — a recorded correction pair appends TWO
54
- * ledger lines under the SAME key. Entries are served FIFO per fingerprint:
55
- * each `complete()` shifts the next recorded response off that key's queue,
56
- * so a replayed session reproduces the correction turn instead of jumping
57
- * straight to the corrected answer. Once only one entry remains for a key it
58
- * keeps serving that last one — a single-entry key (the common case) behaves
59
- * exactly as a plain map lookup, and repeat asks beyond what was recorded
60
- * still get an answer instead of failing.
30
+ * A fingerprint is computed over the intent as first composed (see
31
+ * `fingerprintRequest`), so a ledger recorded through any provider replays
32
+ * for any other, and one ask's first attempt and its correction retry hash
33
+ * identically — a recorded correction pair appends TWO ledger lines under
34
+ * the SAME key. Entries are served FIFO per fingerprint: each `infer()`
35
+ * shifts the next recorded response off that key's queue, so a replayed
36
+ * session reproduces the correction turn instead of jumping straight to the
37
+ * corrected answer. Once only one entry remains for a key it keeps serving
38
+ * that last one — a single-entry key (the common case) behaves exactly as a
39
+ * plain map lookup, and repeat asks beyond what was recorded still get an
40
+ * answer instead of failing.
61
41
  */
62
42
  export function replay(ledgerPath) {
63
43
  let raw;
@@ -93,7 +73,7 @@ export function replay(ledgerPath) {
93
73
  // a ledger serves whatever it holds — a replayed decision ask needs no live capability
94
74
  ...{ [DECISION_MODEL]: true },
95
75
  name: "replay",
96
- async complete(req) {
76
+ async infer(req) {
97
77
  const fingerprint = fingerprintRequest(req);
98
78
  const queue = entries.get(fingerprint);
99
79
  if (!queue || queue.length === 0) {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Every model a combinator (or `record`) wraps must speak the one dialect —
3
+ * `infer(req)`. A `complete`-only inner is the retired chat form and gets the
4
+ * same NOLA3003 migration message the config resolver gives a root model;
5
+ * anything else is not a model. Checked at construction so the failure is
6
+ * a config error, never a TypeError at the first ask (prompt-rendering spec
7
+ * 2026-09-28 §3.10).
8
+ */
9
+ export declare function requireInfer(models: readonly unknown[], where: string): void;
10
+ //# sourceMappingURL=require-infer.d.ts.map
@@ -0,0 +1,23 @@
1
+ import { Codes } from "@nola-lang/ast";
2
+ import { NolaConfigError } from "@nola-lang/core";
3
+ /**
4
+ * Every model a combinator (or `record`) wraps must speak the one dialect —
5
+ * `infer(req)`. A `complete`-only inner is the retired chat form and gets the
6
+ * same NOLA3003 migration message the config resolver gives a root model;
7
+ * anything else is not a model. Checked at construction so the failure is
8
+ * a config error, never a TypeError at the first ask (prompt-rendering spec
9
+ * 2026-09-28 §3.10).
10
+ */
11
+ export function requireInfer(models, where) {
12
+ for (const m of models) {
13
+ const o = m;
14
+ if (o && typeof o === "object" && typeof o.infer === "function")
15
+ continue;
16
+ const name = o && typeof o === "object" && typeof o.name === "string" ? o.name : String(m);
17
+ if (o && typeof o === "object" && typeof o.complete === "function") {
18
+ throw new NolaConfigError(`${where}(${name}) implements complete(req) — a model implements infer(req) and receives the InferenceModel; render it with renderPrompt() from @nola-lang/providers and return { text, sent }.`, Codes.ConfigInvalid);
19
+ }
20
+ throw new NolaConfigError(`${where}(${name}) is not a model (need { name: string, infer(req) }).`, Codes.ConfigInvalid);
21
+ }
22
+ }
23
+ //# sourceMappingURL=require-infer.js.map
package/dist/typesafe.js CHANGED
@@ -36,9 +36,9 @@ export function typesafe(optionsOrModel) {
36
36
  }
37
37
  const askThreshold = req.params?.providerOptions?.threshold;
38
38
  const threshold = typeof askThreshold === "number" ? askThreshold : (options.threshold ?? DEFAULT_THRESHOLD);
39
- // Jev has no conversation: a correction turn (req.model.correction) has
39
+ // Jev has no conversation: a correction turn (req.intent.correction) has
40
40
  // nothing to say to it, so the ask is re-answered from the same state.
41
- const mapped = planFor(req.model, { threshold });
41
+ const mapped = planFor(req.intent, { threshold });
42
42
  if (!mapped.ok) {
43
43
  throw new NolaProviderError(`TypeSafe cannot serve this ask: ${mapped.reason}`, { definitive: true });
44
44
  }
package/dist/wire.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { JsonSchema } from "@nola-lang/core";
2
- export declare const ENVELOPE_NOTE = " Because the response schema is wrapped, reply with a JSON object of the form {\"value\": X} where X is the value that conforms to responseSchema.";
2
+ export declare const ENVELOPE_NOTE = " Reply with a JSON object of the form {\"value\": X}, where X is the requested value.";
3
+ /** The schema rendered into the system turn for a backend that cannot enforce it (`structuredOutputs: false`). */
4
+ export declare function schemaNote(schema: JsonSchema): string;
3
5
  /** Follow root-level $ref chains so the envelope decision sees the real shape. */
4
6
  export declare function resolveRootRef(schema: JsonSchema): JsonSchema;
5
7
  /** Wrap a non-object schema in the {value} envelope, hoisting $defs to the new root. */
package/dist/wire.js CHANGED
@@ -1,8 +1,12 @@
1
1
  // When a scalar/array schema is wrapped in the {value} envelope, the schema
2
2
  // constraint alone only binds providers that do constrained decoding.
3
- // Generate-then-validate backends follow the prompt, so the prompt must ask
3
+ // Generate-then-validate backends follow the prompt, so the system turn asks
4
4
  // for the envelope too.
5
- export const ENVELOPE_NOTE = ' Because the response schema is wrapped, reply with a JSON object of the form {"value": X} where X is the value that conforms to responseSchema.';
5
+ export const ENVELOPE_NOTE = ' Reply with a JSON object of the form {"value": X}, where X is the requested value.';
6
+ /** The schema rendered into the system turn for a backend that cannot enforce it (`structuredOutputs: false`). */
7
+ export function schemaNote(schema) {
8
+ return `\n\n<schema>\n${JSON.stringify(schema)}\n</schema>\nReply with a single JSON value conforming to the schema.`;
9
+ }
6
10
  /** Follow root-level $ref chains so the envelope decision sees the real shape. */
7
11
  export function resolveRootRef(schema) {
8
12
  let current = schema;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nola-lang/providers",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Nola bring-your-own LLM providers: openai, anthropic, google, typesafe, mock, resilience combinators, record/replay",
5
5
  "keywords": [
6
6
  "nola",
@@ -37,8 +37,8 @@
37
37
  "!dist/**/*.map"
38
38
  ],
39
39
  "dependencies": {
40
- "@nola-lang/ast": "0.1.12",
41
- "@nola-lang/core": "0.1.12"
40
+ "@nola-lang/ast": "0.1.14",
41
+ "@nola-lang/core": "0.1.14"
42
42
  },
43
43
  "engines": {
44
44
  "node": ">=22"
package/dist/dialect.d.ts DELETED
@@ -1,12 +0,0 @@
1
- import type { ChatModel, InferModel, InferRequest, ProviderRequest, ProviderResponse } from "@nola-lang/core";
2
- /**
3
- * The dialect bridge (decision types spec 2026-09-18 §6.2): a combinator is
4
- * infer-dialect iff any inner is, and renders for its chat inners. `callModel`
5
- * is the one place that decides which method an inner gets.
6
- */
7
- export declare function callModel(model: ChatModel | InferModel, req: InferRequest | ProviderRequest): Promise<ProviderResponse>;
8
- /** An infer request rendered for a chat inner: the rendering replaces the model; everything else rides along. */
9
- export declare function toClassic(req: InferRequest): ProviderRequest;
10
- /** True when the request's output schema carries a decision question (either request shape). */
11
- export declare function isDecisionRequest(req: InferRequest | ProviderRequest): boolean;
12
- //# sourceMappingURL=dialect.d.ts.map
package/dist/dialect.js DELETED
@@ -1,25 +0,0 @@
1
- import { findDecisionQuestions, isInferModel, NolaProviderError, renderClassic } from "@nola-lang/core";
2
- /**
3
- * The dialect bridge (decision types spec 2026-09-18 §6.2): a combinator is
4
- * infer-dialect iff any inner is, and renders for its chat inners. `callModel`
5
- * is the one place that decides which method an inner gets.
6
- */
7
- export async function callModel(model, req) {
8
- if (isInferModel(model)) {
9
- if ("model" in req)
10
- return model.infer(req);
11
- throw new NolaProviderError(`infer-dialect model "${model.name}" received a classic request — a combinator over it must expose infer()`, { definitive: true });
12
- }
13
- return model.complete("model" in req ? toClassic(req) : req);
14
- }
15
- /** An infer request rendered for a chat inner: the rendering replaces the model; everything else rides along. */
16
- export function toClassic(req) {
17
- const { model, project: _project, ...rest } = req;
18
- return { payload: renderClassic(model), ...rest };
19
- }
20
- /** True when the request's output schema carries a decision question (either request shape). */
21
- export function isDecisionRequest(req) {
22
- const output = "model" in req ? req.model.output : req.payload.output;
23
- return output.syntax === "json" && findDecisionQuestions(output.schema).length > 0;
24
- }
25
- //# sourceMappingURL=dialect.js.map