@fgv/ts-extras 5.1.0-52 → 5.1.0-53

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.
Files changed (41) hide show
  1. package/dist/packlets/ai-assist/completionClient.js +147 -19
  2. package/dist/packlets/ai-assist/completionClient.js.map +1 -1
  3. package/dist/packlets/ai-assist/index.js +2 -1
  4. package/dist/packlets/ai-assist/index.js.map +1 -1
  5. package/dist/packlets/ai-assist/jsonCompletion.js +20 -2
  6. package/dist/packlets/ai-assist/jsonCompletion.js.map +1 -1
  7. package/dist/packlets/ai-assist/model.js.map +1 -1
  8. package/dist/packlets/ai-assist/registry.js +39 -1
  9. package/dist/packlets/ai-assist/registry.js.map +1 -1
  10. package/dist/packlets/ai-assist/structuredOutput.js +315 -0
  11. package/dist/packlets/ai-assist/structuredOutput.js.map +1 -0
  12. package/dist/packlets/ai-assist/structuredOutputTypes.js +21 -0
  13. package/dist/packlets/ai-assist/structuredOutputTypes.js.map +1 -0
  14. package/dist/ts-extras.d.ts +229 -2
  15. package/lib/packlets/ai-assist/completionClient.d.ts +13 -0
  16. package/lib/packlets/ai-assist/completionClient.d.ts.map +1 -1
  17. package/lib/packlets/ai-assist/completionClient.js +146 -18
  18. package/lib/packlets/ai-assist/completionClient.js.map +1 -1
  19. package/lib/packlets/ai-assist/index.d.ts +3 -1
  20. package/lib/packlets/ai-assist/index.d.ts.map +1 -1
  21. package/lib/packlets/ai-assist/index.js +6 -2
  22. package/lib/packlets/ai-assist/index.js.map +1 -1
  23. package/lib/packlets/ai-assist/jsonCompletion.d.ts.map +1 -1
  24. package/lib/packlets/ai-assist/jsonCompletion.js +20 -2
  25. package/lib/packlets/ai-assist/jsonCompletion.js.map +1 -1
  26. package/lib/packlets/ai-assist/model.d.ts +38 -2
  27. package/lib/packlets/ai-assist/model.d.ts.map +1 -1
  28. package/lib/packlets/ai-assist/model.js.map +1 -1
  29. package/lib/packlets/ai-assist/registry.d.ts +20 -0
  30. package/lib/packlets/ai-assist/registry.d.ts.map +1 -1
  31. package/lib/packlets/ai-assist/registry.js +41 -1
  32. package/lib/packlets/ai-assist/registry.js.map +1 -1
  33. package/lib/packlets/ai-assist/structuredOutput.d.ts +88 -0
  34. package/lib/packlets/ai-assist/structuredOutput.d.ts.map +1 -0
  35. package/lib/packlets/ai-assist/structuredOutput.js +321 -0
  36. package/lib/packlets/ai-assist/structuredOutput.js.map +1 -0
  37. package/lib/packlets/ai-assist/structuredOutputTypes.d.ts +142 -0
  38. package/lib/packlets/ai-assist/structuredOutputTypes.d.ts.map +1 -0
  39. package/lib/packlets/ai-assist/structuredOutputTypes.js +22 -0
  40. package/lib/packlets/ai-assist/structuredOutputTypes.js.map +1 -0
  41. package/package.json +7 -7
@@ -0,0 +1,321 @@
1
+ "use strict";
2
+ /*
3
+ * Copyright (c) 2026 Erik Fortune
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.NO_STRUCTURED_OUTPUT = exports.ANTHROPIC_STRUCTURED_OUTPUT_TOOL_NAME = void 0;
8
+ exports.hasOptionalProperties = hasOptionalProperties;
9
+ exports.resolveStructuredOutput = resolveStructuredOutput;
10
+ exports.isStructuredOutputEnforcement = isStructuredOutputEnforcement;
11
+ const ts_utils_1 = require("@fgv/ts-utils");
12
+ const toolFormats_1 = require("./toolFormats");
13
+ /**
14
+ * The name the Anthropic forced-tool path gives its synthetic tool.
15
+ *
16
+ * @remarks
17
+ * Anthropic has no `response_format`; its structured-output mechanism is forced
18
+ * tool use, so a tool must exist to be forced. The name is fgv-owned and never
19
+ * reaches the caller — the structured-output resolver re-serializes the tool's
20
+ * `input` back into `IAiCompletionResponse.content`, so a caller's converter sees
21
+ * a JSON string exactly as it does on every other provider.
22
+ * @public
23
+ */
24
+ exports.ANTHROPIC_STRUCTURED_OUTPUT_TOOL_NAME = 'fgv_structured_output';
25
+ /** The `'none'` decision: nothing sent, nothing enforced. @internal */
26
+ exports.NO_STRUCTURED_OUTPUT = { enforcement: 'none', wire: {} };
27
+ /**
28
+ * Wire fields for a schema-constrained request. Every format can express this —
29
+ * a structured-output capability that could not carry a schema would have nothing
30
+ * to declare.
31
+ * @internal
32
+ */
33
+ function schemaWire(format, raw) {
34
+ switch (format) {
35
+ case 'openai-json-schema':
36
+ return {
37
+ enforcement: 'schema',
38
+ wire: {
39
+ response_format: {
40
+ type: 'json_schema',
41
+ json_schema: { name: 'response', strict: true, schema: raw }
42
+ }
43
+ }
44
+ };
45
+ case 'openai-responses-format':
46
+ // The Responses API nests the same choice under `text.format` and flattens the
47
+ // schema onto the format object rather than a `json_schema` sub-object.
48
+ return {
49
+ enforcement: 'schema',
50
+ wire: { text: { format: { type: 'json_schema', name: 'response', strict: true, schema: raw } } }
51
+ };
52
+ case 'gemini-response-schema':
53
+ // Merged into `generationConfig`, not the body. Gemini's schema is an
54
+ // OpenAPI-3.0 subset that REJECTS draft-07 keywords rather than ignoring them,
55
+ // and `JsonSchema` is strict-by-default so `.toJson()` emits
56
+ // `additionalProperties: false` on every object node — hence the same sanitizer
57
+ // the tool path uses.
58
+ return {
59
+ enforcement: 'schema',
60
+ wire: { responseMimeType: 'application/json', responseSchema: (0, toolFormats_1.toGeminiParameterSchema)(raw) }
61
+ };
62
+ case 'anthropic-tool-forced':
63
+ // Anthropic has no response-format field. The schema becomes a synthetic
64
+ // tool's `input_schema` and `tool_choice` forces it, which is why this is a
65
+ // distinct enforcement value rather than a spelling of `'schema'`: the reply
66
+ // arrives in a `tool_use` block, not as text.
67
+ return {
68
+ enforcement: 'tool-forced',
69
+ wire: {
70
+ tools: [
71
+ {
72
+ name: exports.ANTHROPIC_STRUCTURED_OUTPUT_TOOL_NAME,
73
+ description: 'Return the response as structured data matching the supplied schema.',
74
+ input_schema: raw
75
+ }
76
+ ],
77
+ tool_choice: { type: 'tool', name: exports.ANTHROPIC_STRUCTURED_OUTPUT_TOOL_NAME }
78
+ }
79
+ };
80
+ /* c8 ignore next 4 - defensive: exhaustive switch guaranteed by TypeScript */
81
+ default: {
82
+ const _exhaustive = format;
83
+ throw new Error(`unsupported structured-output format: ${String(_exhaustive)}`);
84
+ }
85
+ }
86
+ }
87
+ /**
88
+ * Wire fields for a bare JSON-object request, or `undefined` when the format
89
+ * cannot express one.
90
+ *
91
+ * @remarks
92
+ * The `undefined` return **is** the capability table — there is deliberately no
93
+ * separate `supportsJsonObject` flag anywhere, because a second declaration of
94
+ * what a format can do could only ever disagree with this function.
95
+ * @internal
96
+ */
97
+ function jsonObjectWire(format) {
98
+ switch (format) {
99
+ case 'openai-json-schema':
100
+ return { enforcement: 'json-mode', wire: { response_format: { type: 'json_object' } } };
101
+ case 'openai-responses-format':
102
+ return { enforcement: 'json-mode', wire: { text: { format: { type: 'json_object' } } } };
103
+ case 'gemini-response-schema':
104
+ return { enforcement: 'json-mode', wire: { responseMimeType: 'application/json' } };
105
+ case 'anthropic-tool-forced':
106
+ // A forced tool needs an input schema to be forced *to*, so there is no
107
+ // schema-less form of this mechanism.
108
+ return undefined;
109
+ /* c8 ignore next 4 - defensive: exhaustive switch guaranteed by TypeScript */
110
+ default: {
111
+ const _exhaustive = format;
112
+ throw new Error(`unsupported structured-output format: ${String(_exhaustive)}`);
113
+ }
114
+ }
115
+ }
116
+ /**
117
+ * Whether `raw` declares any object property that is absent from that object's
118
+ * `required` list — at any depth.
119
+ *
120
+ * @remarks
121
+ * **This is a hard constraint of OpenAI's strict structured output, not a style
122
+ * preference.** `response_format: { type: 'json_schema', json_schema: { strict: true } }`
123
+ * requires *every* key in `properties` to appear in `required`; a schema that omits
124
+ * one is rejected with a 400 before the model ever runs. `JsonSchema.optional(...)`
125
+ * produces exactly that shape, so an authored schema with one optional field is
126
+ * unsendable to the two OpenAI strict formats.
127
+ *
128
+ * The three obvious repairs are all worse than refusing. Rewriting optional to
129
+ * required-and-nullable changes what the model must emit (`null` rather than
130
+ * omission), so the reply would no longer satisfy the caller's own validator —
131
+ * breaking the one-object-cannot-drift property this whole surface exists for.
132
+ * Dropping `strict` silently downgrades the guarantee while still reporting
133
+ * `'schema'`, which is the lie the required report exists to prevent. And sending
134
+ * it anyway just relocates the failure to an opaque provider 400.
135
+ *
136
+ * So this is treated as a **capability mismatch** and routed through the caller's
137
+ * existing `onUnsupported` choice — degrade to unconstrained by default, fail loudly
138
+ * on request. Gemini and Anthropic have no such rule and are unaffected.
139
+ * @internal
140
+ */
141
+ function hasOptionalProperties(raw) {
142
+ if (Array.isArray(raw)) {
143
+ return raw.some(hasOptionalProperties);
144
+ }
145
+ if (raw === null || typeof raw !== 'object') {
146
+ return false;
147
+ }
148
+ const node = raw;
149
+ const properties = node.properties;
150
+ if (properties !== null && typeof properties === 'object' && !Array.isArray(properties)) {
151
+ const required = Array.isArray(node.required) ? node.required : [];
152
+ for (const name of Object.keys(properties)) {
153
+ if (!required.includes(name)) {
154
+ return true;
155
+ }
156
+ }
157
+ }
158
+ return Object.values(node).some((v) => v !== undefined && hasOptionalProperties(v));
159
+ }
160
+ /** The two formats that carry OpenAI's all-properties-required strict rule. @internal */
161
+ function isOpenAiStrictFormat(format) {
162
+ return format === 'openai-json-schema' || format === 'openai-responses-format';
163
+ }
164
+ /**
165
+ * Whether a resolved wire claims the provider's tools channel, and therefore
166
+ * genuinely conflicts with server-side tools.
167
+ *
168
+ * @remarks
169
+ * Asked of the **resolved wire** rather than the declared format, because a format
170
+ * that *would* claim the channel does not claim it when the request degraded to
171
+ * sending nothing. Anthropic + `json-object` is exactly that case: the mode has no
172
+ * expression there, so the wire is empty and there is nothing to conflict with —
173
+ * rejecting it would refuse a request that was about to become harmless.
174
+ * @internal
175
+ */
176
+ function conflictsWithServerTools(format, resolved) {
177
+ return (resolved.enforcement !== 'none' &&
178
+ (format === 'anthropic-tool-forced' || format === 'gemini-response-schema'));
179
+ }
180
+ /**
181
+ * The wire format actually in force, given which OpenAI endpoint the dispatcher
182
+ * will use.
183
+ *
184
+ * @remarks
185
+ * **The OpenAI route is not a function of the model alone.** `callProviderCompletion`
186
+ * sends a request to `/responses` when it carries server tools **or** when the model
187
+ * is Responses-only, and to `/chat/completions` otherwise — so the same model takes
188
+ * different endpoints on different calls, and those endpoints spell structured output
189
+ * differently (`response_format` vs `text.format`). A capability declaration keyed on
190
+ * the model therefore cannot name the right one by itself, and emitting
191
+ * `response_format` into a `/responses` body would be silently ignored by the
192
+ * provider: the request would look constrained and the reply would not be, with the
193
+ * report confidently saying `'schema'`.
194
+ *
195
+ * The declaration still names each family's *support*; this is the one axis it cannot
196
+ * carry, so it is supplied by the dispatcher that makes the routing decision.
197
+ * @internal
198
+ */
199
+ function effectiveFormat(declared, usesResponsesApi) {
200
+ if (usesResponsesApi && declared === 'openai-json-schema') {
201
+ return 'openai-responses-format';
202
+ }
203
+ return declared;
204
+ }
205
+ /**
206
+ * Resolve a caller's structured-output request against the concrete model that
207
+ * will serve it.
208
+ *
209
+ * @param descriptor - The provider descriptor.
210
+ * @param model - The **concrete** model id, already through `resolveProviderModel`.
211
+ * Passing an alias here would be a bug of the class `resolveImageCapability` once
212
+ * had, where an unresolved alias fell through to a catch-all `modelPrefix: ''` and
213
+ * returned a confidently wrong capability.
214
+ * @param request - The caller's intent, or `undefined` for no request at all.
215
+ * @param serverTools - Server-side tools on the same request, which conflict with
216
+ * structured output on two of the four formats.
217
+ * @param usesResponsesApi - Whether the dispatcher will send this request to the
218
+ * OpenAI Responses API rather than Chat Completions. See {@link effectiveFormat} —
219
+ * the route is not a function of the model alone, so the capability declaration
220
+ * cannot carry it.
221
+ * @returns The decision, or `Failure` when the caller asked to fail rather than
222
+ * degrade — or when the request conflicts with server tools, which is never
223
+ * degradable because the caller asked for two things the provider cannot both do.
224
+ * @internal
225
+ */
226
+ function resolveStructuredOutput(descriptor, model, request, serverTools, usesResponsesApi, resolveCapability) {
227
+ var _a;
228
+ if (request === undefined) {
229
+ return (0, ts_utils_1.succeed)(exports.NO_STRUCTURED_OUTPUT);
230
+ }
231
+ const fallback = (_a = request.onUnsupported) !== null && _a !== void 0 ? _a : 'degrade';
232
+ const capability = resolveCapability(descriptor, model);
233
+ if (capability === undefined) {
234
+ return fallback === 'fail'
235
+ ? (0, ts_utils_1.fail)(`provider '${descriptor.id}' model '${model}' declares no structured-output capability; ` +
236
+ `pass onUnsupported: 'degrade' to send the request unconstrained`)
237
+ : (0, ts_utils_1.succeed)(exports.NO_STRUCTURED_OUTPUT);
238
+ }
239
+ const format = effectiveFormat(capability.format, usesResponsesApi);
240
+ // Resolve the wire FIRST, then judge conflicts against what it actually is.
241
+ // Ordering matters: a format that would claim the tools channel does not claim
242
+ // it when the request degraded to sending nothing.
243
+ let resolved;
244
+ let unsupported;
245
+ if (request.mode === 'schema') {
246
+ const raw = request.schema.toJson();
247
+ if (isOpenAiStrictFormat(format) && hasOptionalProperties(raw)) {
248
+ // See `hasOptionalProperties` — a hard provider constraint, treated as a
249
+ // capability mismatch rather than relocated into an opaque 400.
250
+ unsupported =
251
+ `the supplied schema declares optional properties, and OpenAI strict structured output ` +
252
+ `requires every property to be required; author them as required, or pass ` +
253
+ `onUnsupported: 'degrade' to send the request unconstrained`;
254
+ }
255
+ else {
256
+ resolved = schemaWire(format, raw);
257
+ }
258
+ }
259
+ else {
260
+ resolved = jsonObjectWire(format);
261
+ if (resolved === undefined) {
262
+ // Today this is only `'json-object'` on Anthropic, whose mechanism needs a
263
+ // schema to force a tool to.
264
+ unsupported = `provider '${descriptor.id}' model '${model}' cannot enforce '${request.mode}' structured output`;
265
+ }
266
+ }
267
+ if (resolved === undefined) {
268
+ return fallback === 'fail' ? (0, ts_utils_1.fail)(`${unsupported}`) : (0, ts_utils_1.succeed)(exports.NO_STRUCTURED_OUTPUT);
269
+ }
270
+ // Two formats cannot carry structured output and server-side tools at once, for
271
+ // DIFFERENT reasons — worth separating, because a reader who assumes one
272
+ // mechanism will reason wrongly about the other.
273
+ //
274
+ // anthropic-tool-forced: a wire-level clash. The constraint IS `tools` +
275
+ // `tool_choice`, so server tools would be overwritten (and `tool_choice`
276
+ // forces ours, which disables theirs anyway).
277
+ // gemini-response-schema: NOT a wire clash — `responseMimeType` /
278
+ // `responseSchema` live in `generationConfig`, nowhere near `tools`. It is
279
+ // an API-level mutual exclusivity Gemini enforces, the same restriction the
280
+ // client-tool path already pre-empts.
281
+ //
282
+ // Neither is degradable: silently dropping either half would give the caller
283
+ // something they did not ask for, and `onUnsupported` speaks to what a model can
284
+ // enforce, not to a caller asking for two incompatible things.
285
+ if (serverTools !== undefined && serverTools.length > 0 && conflictsWithServerTools(format, resolved)) {
286
+ const why = format === 'anthropic-tool-forced'
287
+ ? 'Anthropic enforces structured output by forcing a tool, so it cannot be combined with'
288
+ : 'Gemini cannot combine a response schema with';
289
+ return (0, ts_utils_1.fail)(`${why} server-side tools (${serverTools.map((t) => t.type).join(', ')}) in the same request; ` +
290
+ `send one or the other`);
291
+ }
292
+ return (0, ts_utils_1.succeed)(resolved);
293
+ }
294
+ /**
295
+ * Every valid `StructuredOutputEnforcement`, for the wire-shape guard below.
296
+ *
297
+ * @remarks
298
+ * A **total** `Record`, not a `Set` built from an array literal — the same reasoning
299
+ * as `SCHEMA_NODE_TYPES` in `@fgv/ts-json-base`. A `Set` catches a removed or
300
+ * misspelled member but not an *added* one, so a new enforcement value would compile
301
+ * fine here while this guard silently began rejecting it off a proxy response. The
302
+ * `Record` makes that addition a compile error at this line.
303
+ * @internal
304
+ */
305
+ const ENFORCEMENTS = {
306
+ none: true,
307
+ 'json-mode': true,
308
+ schema: true,
309
+ 'tool-forced': true
310
+ };
311
+ /**
312
+ * Whether an untyped value off a proxy response is a valid
313
+ * `StructuredOutputEnforcement`.
314
+ * @internal
315
+ */
316
+ function isStructuredOutputEnforcement(value) {
317
+ // Indexed read compared to `true`, NOT `in` — `in` walks the prototype chain, so a
318
+ // proxy answering `structuredOutput: 'constructor'` would pass it.
319
+ return typeof value === 'string' && ENFORCEMENTS[value] === true;
320
+ }
321
+ //# sourceMappingURL=structuredOutput.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"structuredOutput.js","sourceRoot":"","sources":["../../../src/packlets/ai-assist/structuredOutput.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport type { JsonObject, JsonValue } from '@fgv/ts-json-base';\nimport { Result, fail, succeed } from '@fgv/ts-utils';\nimport type { AiServerToolConfig, IAiProviderDescriptor } from './model';\nimport type {\n IAiStructuredOutputCapability,\n StructuredOutputEnforcement,\n StructuredOutputFallback,\n StructuredOutputRequest\n} from './structuredOutputTypes';\nimport { toGeminiParameterSchema } from './toolFormats';\n\n/**\n * The name the Anthropic forced-tool path gives its synthetic tool.\n *\n * @remarks\n * Anthropic has no `response_format`; its structured-output mechanism is forced\n * tool use, so a tool must exist to be forced. The name is fgv-owned and never\n * reaches the caller — the structured-output resolver re-serializes the tool's\n * `input` back into `IAiCompletionResponse.content`, so a caller's converter sees\n * a JSON string exactly as it does on every other provider.\n * @public\n */\nexport const ANTHROPIC_STRUCTURED_OUTPUT_TOOL_NAME: string = 'fgv_structured_output';\n\n/**\n * A resolved structured-output decision: what will be enforced, and the wire\n * fields that enforce it.\n * @internal\n */\nexport interface IResolvedStructuredOutput {\n /** What to report on the response. */\n readonly enforcement: StructuredOutputEnforcement;\n /**\n * Fields to merge into the request, **at the location the format dictates** —\n * the request body for the OpenAI and Anthropic formats, `generationConfig` for\n * Gemini. Empty when `enforcement` is `'none'`.\n */\n readonly wire: JsonObject;\n}\n\n/** The `'none'` decision: nothing sent, nothing enforced. @internal */\nexport const NO_STRUCTURED_OUTPUT: IResolvedStructuredOutput = { enforcement: 'none', wire: {} };\n\n/**\n * Wire fields for a schema-constrained request. Every format can express this —\n * a structured-output capability that could not carry a schema would have nothing\n * to declare.\n * @internal\n */\nfunction schemaWire(\n format: IAiStructuredOutputCapability['format'],\n raw: JsonValue\n): IResolvedStructuredOutput {\n switch (format) {\n case 'openai-json-schema':\n return {\n enforcement: 'schema',\n wire: {\n response_format: {\n type: 'json_schema',\n json_schema: { name: 'response', strict: true, schema: raw }\n }\n }\n };\n case 'openai-responses-format':\n // The Responses API nests the same choice under `text.format` and flattens the\n // schema onto the format object rather than a `json_schema` sub-object.\n return {\n enforcement: 'schema',\n wire: { text: { format: { type: 'json_schema', name: 'response', strict: true, schema: raw } } }\n };\n case 'gemini-response-schema':\n // Merged into `generationConfig`, not the body. Gemini's schema is an\n // OpenAPI-3.0 subset that REJECTS draft-07 keywords rather than ignoring them,\n // and `JsonSchema` is strict-by-default so `.toJson()` emits\n // `additionalProperties: false` on every object node — hence the same sanitizer\n // the tool path uses.\n return {\n enforcement: 'schema',\n wire: { responseMimeType: 'application/json', responseSchema: toGeminiParameterSchema(raw) }\n };\n case 'anthropic-tool-forced':\n // Anthropic has no response-format field. The schema becomes a synthetic\n // tool's `input_schema` and `tool_choice` forces it, which is why this is a\n // distinct enforcement value rather than a spelling of `'schema'`: the reply\n // arrives in a `tool_use` block, not as text.\n return {\n enforcement: 'tool-forced',\n wire: {\n tools: [\n {\n name: ANTHROPIC_STRUCTURED_OUTPUT_TOOL_NAME,\n description: 'Return the response as structured data matching the supplied schema.',\n input_schema: raw\n }\n ],\n tool_choice: { type: 'tool', name: ANTHROPIC_STRUCTURED_OUTPUT_TOOL_NAME }\n }\n };\n /* c8 ignore next 4 - defensive: exhaustive switch guaranteed by TypeScript */\n default: {\n const _exhaustive: never = format;\n throw new Error(`unsupported structured-output format: ${String(_exhaustive)}`);\n }\n }\n}\n\n/**\n * Wire fields for a bare JSON-object request, or `undefined` when the format\n * cannot express one.\n *\n * @remarks\n * The `undefined` return **is** the capability table — there is deliberately no\n * separate `supportsJsonObject` flag anywhere, because a second declaration of\n * what a format can do could only ever disagree with this function.\n * @internal\n */\nfunction jsonObjectWire(\n format: IAiStructuredOutputCapability['format']\n): IResolvedStructuredOutput | undefined {\n switch (format) {\n case 'openai-json-schema':\n return { enforcement: 'json-mode', wire: { response_format: { type: 'json_object' } } };\n case 'openai-responses-format':\n return { enforcement: 'json-mode', wire: { text: { format: { type: 'json_object' } } } };\n case 'gemini-response-schema':\n return { enforcement: 'json-mode', wire: { responseMimeType: 'application/json' } };\n case 'anthropic-tool-forced':\n // A forced tool needs an input schema to be forced *to*, so there is no\n // schema-less form of this mechanism.\n return undefined;\n /* c8 ignore next 4 - defensive: exhaustive switch guaranteed by TypeScript */\n default: {\n const _exhaustive: never = format;\n throw new Error(`unsupported structured-output format: ${String(_exhaustive)}`);\n }\n }\n}\n\n/**\n * Whether `raw` declares any object property that is absent from that object's\n * `required` list — at any depth.\n *\n * @remarks\n * **This is a hard constraint of OpenAI's strict structured output, not a style\n * preference.** `response_format: { type: 'json_schema', json_schema: { strict: true } }`\n * requires *every* key in `properties` to appear in `required`; a schema that omits\n * one is rejected with a 400 before the model ever runs. `JsonSchema.optional(...)`\n * produces exactly that shape, so an authored schema with one optional field is\n * unsendable to the two OpenAI strict formats.\n *\n * The three obvious repairs are all worse than refusing. Rewriting optional to\n * required-and-nullable changes what the model must emit (`null` rather than\n * omission), so the reply would no longer satisfy the caller's own validator —\n * breaking the one-object-cannot-drift property this whole surface exists for.\n * Dropping `strict` silently downgrades the guarantee while still reporting\n * `'schema'`, which is the lie the required report exists to prevent. And sending\n * it anyway just relocates the failure to an opaque provider 400.\n *\n * So this is treated as a **capability mismatch** and routed through the caller's\n * existing `onUnsupported` choice — degrade to unconstrained by default, fail loudly\n * on request. Gemini and Anthropic have no such rule and are unaffected.\n * @internal\n */\nexport function hasOptionalProperties(raw: JsonValue): boolean {\n if (Array.isArray(raw)) {\n return raw.some(hasOptionalProperties);\n }\n if (raw === null || typeof raw !== 'object') {\n return false;\n }\n const node: Record<string, JsonValue | undefined> = raw as Record<string, JsonValue | undefined>;\n const properties = node.properties;\n if (properties !== null && typeof properties === 'object' && !Array.isArray(properties)) {\n const required: ReadonlyArray<JsonValue> = Array.isArray(node.required) ? node.required : [];\n for (const name of Object.keys(properties)) {\n if (!required.includes(name)) {\n return true;\n }\n }\n }\n return Object.values(node).some((v) => v !== undefined && hasOptionalProperties(v));\n}\n\n/** The two formats that carry OpenAI's all-properties-required strict rule. @internal */\nfunction isOpenAiStrictFormat(format: IAiStructuredOutputCapability['format']): boolean {\n return format === 'openai-json-schema' || format === 'openai-responses-format';\n}\n\n/**\n * Whether a resolved wire claims the provider's tools channel, and therefore\n * genuinely conflicts with server-side tools.\n *\n * @remarks\n * Asked of the **resolved wire** rather than the declared format, because a format\n * that *would* claim the channel does not claim it when the request degraded to\n * sending nothing. Anthropic + `json-object` is exactly that case: the mode has no\n * expression there, so the wire is empty and there is nothing to conflict with —\n * rejecting it would refuse a request that was about to become harmless.\n * @internal\n */\nfunction conflictsWithServerTools(\n format: IAiStructuredOutputCapability['format'],\n resolved: IResolvedStructuredOutput\n): boolean {\n return (\n resolved.enforcement !== 'none' &&\n (format === 'anthropic-tool-forced' || format === 'gemini-response-schema')\n );\n}\n\n/**\n * The wire format actually in force, given which OpenAI endpoint the dispatcher\n * will use.\n *\n * @remarks\n * **The OpenAI route is not a function of the model alone.** `callProviderCompletion`\n * sends a request to `/responses` when it carries server tools **or** when the model\n * is Responses-only, and to `/chat/completions` otherwise — so the same model takes\n * different endpoints on different calls, and those endpoints spell structured output\n * differently (`response_format` vs `text.format`). A capability declaration keyed on\n * the model therefore cannot name the right one by itself, and emitting\n * `response_format` into a `/responses` body would be silently ignored by the\n * provider: the request would look constrained and the reply would not be, with the\n * report confidently saying `'schema'`.\n *\n * The declaration still names each family's *support*; this is the one axis it cannot\n * carry, so it is supplied by the dispatcher that makes the routing decision.\n * @internal\n */\nfunction effectiveFormat(\n declared: IAiStructuredOutputCapability['format'],\n usesResponsesApi: boolean\n): IAiStructuredOutputCapability['format'] {\n if (usesResponsesApi && declared === 'openai-json-schema') {\n return 'openai-responses-format';\n }\n return declared;\n}\n\n/**\n * Resolve a caller's structured-output request against the concrete model that\n * will serve it.\n *\n * @param descriptor - The provider descriptor.\n * @param model - The **concrete** model id, already through `resolveProviderModel`.\n * Passing an alias here would be a bug of the class `resolveImageCapability` once\n * had, where an unresolved alias fell through to a catch-all `modelPrefix: ''` and\n * returned a confidently wrong capability.\n * @param request - The caller's intent, or `undefined` for no request at all.\n * @param serverTools - Server-side tools on the same request, which conflict with\n * structured output on two of the four formats.\n * @param usesResponsesApi - Whether the dispatcher will send this request to the\n * OpenAI Responses API rather than Chat Completions. See {@link effectiveFormat} —\n * the route is not a function of the model alone, so the capability declaration\n * cannot carry it.\n * @returns The decision, or `Failure` when the caller asked to fail rather than\n * degrade — or when the request conflicts with server tools, which is never\n * degradable because the caller asked for two things the provider cannot both do.\n * @internal\n */\nexport function resolveStructuredOutput(\n descriptor: IAiProviderDescriptor,\n model: string,\n request: StructuredOutputRequest | undefined,\n serverTools: ReadonlyArray<AiServerToolConfig> | undefined,\n usesResponsesApi: boolean,\n resolveCapability: (\n descriptor: IAiProviderDescriptor,\n model: string\n ) => IAiStructuredOutputCapability | undefined\n): Result<IResolvedStructuredOutput> {\n if (request === undefined) {\n return succeed(NO_STRUCTURED_OUTPUT);\n }\n const fallback: StructuredOutputFallback = request.onUnsupported ?? 'degrade';\n const capability = resolveCapability(descriptor, model);\n if (capability === undefined) {\n return fallback === 'fail'\n ? fail(\n `provider '${descriptor.id}' model '${model}' declares no structured-output capability; ` +\n `pass onUnsupported: 'degrade' to send the request unconstrained`\n )\n : succeed(NO_STRUCTURED_OUTPUT);\n }\n\n const format = effectiveFormat(capability.format, usesResponsesApi);\n\n // Resolve the wire FIRST, then judge conflicts against what it actually is.\n // Ordering matters: a format that would claim the tools channel does not claim\n // it when the request degraded to sending nothing.\n let resolved: IResolvedStructuredOutput | undefined;\n let unsupported: string | undefined;\n if (request.mode === 'schema') {\n const raw: JsonValue = request.schema.toJson();\n if (isOpenAiStrictFormat(format) && hasOptionalProperties(raw)) {\n // See `hasOptionalProperties` — a hard provider constraint, treated as a\n // capability mismatch rather than relocated into an opaque 400.\n unsupported =\n `the supplied schema declares optional properties, and OpenAI strict structured output ` +\n `requires every property to be required; author them as required, or pass ` +\n `onUnsupported: 'degrade' to send the request unconstrained`;\n } else {\n resolved = schemaWire(format, raw);\n }\n } else {\n resolved = jsonObjectWire(format);\n if (resolved === undefined) {\n // Today this is only `'json-object'` on Anthropic, whose mechanism needs a\n // schema to force a tool to.\n unsupported = `provider '${descriptor.id}' model '${model}' cannot enforce '${request.mode}' structured output`;\n }\n }\n\n if (resolved === undefined) {\n return fallback === 'fail' ? fail(`${unsupported}`) : succeed(NO_STRUCTURED_OUTPUT);\n }\n\n // Two formats cannot carry structured output and server-side tools at once, for\n // DIFFERENT reasons — worth separating, because a reader who assumes one\n // mechanism will reason wrongly about the other.\n //\n // anthropic-tool-forced: a wire-level clash. The constraint IS `tools` +\n // `tool_choice`, so server tools would be overwritten (and `tool_choice`\n // forces ours, which disables theirs anyway).\n // gemini-response-schema: NOT a wire clash — `responseMimeType` /\n // `responseSchema` live in `generationConfig`, nowhere near `tools`. It is\n // an API-level mutual exclusivity Gemini enforces, the same restriction the\n // client-tool path already pre-empts.\n //\n // Neither is degradable: silently dropping either half would give the caller\n // something they did not ask for, and `onUnsupported` speaks to what a model can\n // enforce, not to a caller asking for two incompatible things.\n if (serverTools !== undefined && serverTools.length > 0 && conflictsWithServerTools(format, resolved)) {\n const why =\n format === 'anthropic-tool-forced'\n ? 'Anthropic enforces structured output by forcing a tool, so it cannot be combined with'\n : 'Gemini cannot combine a response schema with';\n return fail(\n `${why} server-side tools (${serverTools.map((t) => t.type).join(', ')}) in the same request; ` +\n `send one or the other`\n );\n }\n\n return succeed(resolved);\n}\n\n/**\n * Every valid `StructuredOutputEnforcement`, for the wire-shape guard below.\n *\n * @remarks\n * A **total** `Record`, not a `Set` built from an array literal — the same reasoning\n * as `SCHEMA_NODE_TYPES` in `@fgv/ts-json-base`. A `Set` catches a removed or\n * misspelled member but not an *added* one, so a new enforcement value would compile\n * fine here while this guard silently began rejecting it off a proxy response. The\n * `Record` makes that addition a compile error at this line.\n * @internal\n */\nconst ENFORCEMENTS: Readonly<Record<StructuredOutputEnforcement, true>> = {\n none: true,\n 'json-mode': true,\n schema: true,\n 'tool-forced': true\n};\n\n/**\n * Whether an untyped value off a proxy response is a valid\n * `StructuredOutputEnforcement`.\n * @internal\n */\nexport function isStructuredOutputEnforcement(value: unknown): value is StructuredOutputEnforcement {\n // Indexed read compared to `true`, NOT `in` — `in` walks the prototype chain, so a\n // proxy answering `structuredOutput: 'constructor'` would pass it.\n return typeof value === 'string' && ENFORCEMENTS[value as StructuredOutputEnforcement] === true;\n}\n"]}
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Structured-output types: the capability a provider declares, the request a
3
+ * caller makes, and the enforcement the response reports.
4
+ *
5
+ * @remarks
6
+ * Their own module rather than part of `model.ts` because they depend on nothing
7
+ * there — `model.ts` imports them, not the reverse — and because `model.ts` was
8
+ * at the `max-lines` cap. A dependency-free cut is one of the few available at a
9
+ * moment like that which is not chosen under pressure.
10
+ * @packageDocumentation
11
+ */
12
+ import { type JsonSchema } from '@fgv/ts-json-base';
13
+ /**
14
+ * Wire format a provider uses to express a structured-output constraint.
15
+ *
16
+ * @remarks
17
+ * Four shapes, not one, and they differ in more than field names: the OpenAI
18
+ * pair carry the schema in the request body, Gemini carries it inside
19
+ * `generationConfig`, and Anthropic has no response-format field at all —
20
+ * its mechanism is forced tool use, which is why `'tool-forced'` is a distinct
21
+ * {@link AiAssist.StructuredOutputEnforcement} value rather than a spelling of
22
+ * `'schema'`.
23
+ * @public
24
+ */
25
+ export type AiStructuredOutputFormat = 'openai-json-schema' | 'openai-responses-format' | 'gemini-response-schema' | 'anthropic-tool-forced';
26
+ /**
27
+ * Structured-output capability for a model family within a provider. Used as an
28
+ * entry in `IAiProviderDescriptor.structuredOutput`.
29
+ *
30
+ * @remarks
31
+ * Deliberately thinner than its `imageGeneration` / `embedding` siblings: it
32
+ * carries no `supportsX` flags, because what each format can enforce is a
33
+ * property of the provider's **API surface** rather than of any one model, and a
34
+ * per-entry declaration of it could only ever disagree with the one in code.
35
+ * @public
36
+ */
37
+ export interface IAiStructuredOutputCapability {
38
+ /**
39
+ * Prefix matched against the resolved completion model id. The empty string is
40
+ * the catch-all and matches every model. When multiple rules' prefixes match a
41
+ * model id, the longest prefix wins; ties are broken by first-encountered.
42
+ */
43
+ readonly modelPrefix: string;
44
+ /** Wire format used to express the constraint for matching models. */
45
+ readonly format: AiStructuredOutputFormat;
46
+ }
47
+ /**
48
+ * Which constraint the provider was **asked** to apply to this response.
49
+ *
50
+ * @remarks
51
+ * Three questions hide inside *"did it honour my schema"*, and they have different
52
+ * owners:
53
+ *
54
+ * | question | answerable by |
55
+ * |---|---|
56
+ * | did we send a constraint? | this client, at request-build time |
57
+ * | which constraint did the provider apply? | this client, from the resolved model's capability |
58
+ * | does *this response* conform to my shape? | the caller's converter, and nothing else |
59
+ *
60
+ * This type answers the first two and deliberately not the third. Reporting
61
+ * conformance would mean re-validating against the caller's own schema to
62
+ * re-derive an answer the caller already holds.
63
+ *
64
+ * - `'none'` — nothing was sent; the resolved model declares no capability.
65
+ * - `'json-mode'` — syntactically valid JSON is guaranteed; the shape is not.
66
+ * - `'schema'` — generation was constrained to the supplied schema.
67
+ * - `'tool-forced'` — Anthropic-style forced tool use; the shape comes from the
68
+ * forced tool's input schema, and `content` is the re-serialized tool input.
69
+ * @public
70
+ */
71
+ export type StructuredOutputEnforcement = 'none' | 'json-mode' | 'schema' | 'tool-forced';
72
+ /**
73
+ * What to do when the resolved model cannot apply the requested constraint.
74
+ *
75
+ * @remarks
76
+ * `'degrade'` is the default, and it is only safe **because
77
+ * `IAiCompletionResponse.structuredOutput` is required** rather than
78
+ * optional. Degrade-and-tell-me is safe; degrade-silently is the failure this
79
+ * whole surface exists to remove — so the two decisions are one decision, not
80
+ * two independent ones.
81
+ *
82
+ * Reach for `'fail'` when the output is persisted or put on a wire, where an
83
+ * unconstrained generation that happens to parse is worse than an error because
84
+ * it is wrong quietly. Leave it at `'degrade'` on paths that are *designed* to
85
+ * degrade — an extractor that may return nothing, a segmenter that floors to a
86
+ * mechanical chunker — where a hard failure would make this library less safe
87
+ * than the code it replaces.
88
+ * @public
89
+ */
90
+ export type StructuredOutputFallback = 'degrade' | 'fail';
91
+ /**
92
+ * Ask the provider for JSON constrained to a schema.
93
+ * @public
94
+ */
95
+ export interface ISchemaStructuredOutputRequest {
96
+ readonly mode: 'schema';
97
+ /**
98
+ * The schema to constrain generation to — **the same object you validate the
99
+ * reply with**, so the wire schema and the check cannot drift.
100
+ *
101
+ * @remarks
102
+ * Author it with `JsonSchema.object({...})` from `@fgv/ts-json-base`. This is
103
+ * the property `@fgv/ts-extras-ollama`'s `chatStructured` already has; this
104
+ * surface is its cloud sibling.
105
+ */
106
+ readonly schema: JsonSchema.ISchemaValidator<unknown>;
107
+ readonly onUnsupported?: StructuredOutputFallback;
108
+ }
109
+ /**
110
+ * Ask the provider for syntactically valid JSON of arbitrary shape.
111
+ *
112
+ * @remarks
113
+ * The weaker floor, and worth having on its own: the failure that motivated this
114
+ * surface (`Expected ',' or '}' after property value` — an unescaped quote closing
115
+ * a string early) is **syntactic**, so a JSON-mode guarantee removes it. Schema
116
+ * constraint is what additionally buys shape. It is also the only mode some
117
+ * model/provider pairs support.
118
+ * @public
119
+ */
120
+ export interface IJsonObjectStructuredOutputRequest {
121
+ readonly mode: 'json-object';
122
+ readonly onUnsupported?: StructuredOutputFallback;
123
+ }
124
+ /**
125
+ * A caller's structured-output intent.
126
+ *
127
+ * @remarks
128
+ * A discriminated union rather than an optional `schema` whose absence means
129
+ * *"json-object please"* — an absence that means something is the shape this repo
130
+ * has been burned by (see `MemoryEmbedOutcome` in `@fgv/ts-agent-memory`, which
131
+ * exists because a three-ways-ambiguous absence could not be read).
132
+ *
133
+ * **The caller supplies intent; the response reports outcome.** A request never
134
+ * needs to know whether the constraint will be honoured, because
135
+ * `resolveProviderModel` resolves aliases and tiers at *call* time — a `tier`
136
+ * request can cascade — so the concrete model that will serve a request is not
137
+ * knowable to the caller up front. Requiring it to know would be unsound, which
138
+ * is why the report rides on the response rather than being a lookup.
139
+ * @public
140
+ */
141
+ export type StructuredOutputRequest = ISchemaStructuredOutputRequest | IJsonObjectStructuredOutputRequest;
142
+ //# sourceMappingURL=structuredOutputTypes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"structuredOutputTypes.d.ts","sourceRoot":"","sources":["../../../src/packlets/ai-assist/structuredOutputTypes.ts"],"names":[],"mappings":"AAoBA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAMpD;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,wBAAwB,GAChC,oBAAoB,GACpB,yBAAyB,GACzB,wBAAwB,GACxB,uBAAuB,CAAC;AAE5B;;;;;;;;;;GAUG;AACH,MAAM,WAAW,6BAA6B;IAC5C;;;;OAIG;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,sEAAsE;IACtE,QAAQ,CAAC,MAAM,EAAE,wBAAwB,CAAC;CAC3C;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,MAAM,2BAA2B,GAAG,MAAM,GAAG,WAAW,GAAG,QAAQ,GAAG,aAAa,CAAC;AAE1F;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,wBAAwB,GAAG,SAAS,GAAG,MAAM,CAAC;AAE1D;;;GAGG;AACH,MAAM,WAAW,8BAA8B;IAC7C,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,gBAAgB,CAAC,OAAO,CAAC,CAAC;IACtD,QAAQ,CAAC,aAAa,CAAC,EAAE,wBAAwB,CAAC;CACnD;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,kCAAkC;IACjD,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,QAAQ,CAAC,aAAa,CAAC,EAAE,wBAAwB,CAAC;CACnD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,uBAAuB,GAAG,8BAA8B,GAAG,kCAAkC,CAAC"}
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ // Copyright (c) 2026 Erik Fortune
3
+ //
4
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ // of this software and associated documentation files (the "Software"), to deal
6
+ // in the Software without restriction, including without limitation the rights
7
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ // copies of the Software, and to permit persons to whom the Software is
9
+ // furnished to do so, subject to the following conditions:
10
+ //
11
+ // The above copyright notice and this permission notice shall be included in all
12
+ // copies or substantial portions of the Software.
13
+ //
14
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ // SOFTWARE.
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ //# sourceMappingURL=structuredOutputTypes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"structuredOutputTypes.js","sourceRoot":"","sources":["../../../src/packlets/ai-assist/structuredOutputTypes.ts"],"names":[],"mappings":";AAAA,kCAAkC;AAClC,EAAE;AACF,+EAA+E;AAC/E,gFAAgF;AAChF,+EAA+E;AAC/E,4EAA4E;AAC5E,wEAAwE;AACxE,2DAA2D;AAC3D,EAAE;AACF,iFAAiF;AACjF,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,8EAA8E;AAC9E,yEAAyE;AACzE,gFAAgF;AAChF,gFAAgF;AAChF,YAAY","sourcesContent":["// Copyright (c) 2026 Erik Fortune\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\n/**\n * Structured-output types: the capability a provider declares, the request a\n * caller makes, and the enforcement the response reports.\n *\n * @remarks\n * Their own module rather than part of `model.ts` because they depend on nothing\n * there — `model.ts` imports them, not the reverse — and because `model.ts` was\n * at the `max-lines` cap. A dependency-free cut is one of the few available at a\n * moment like that which is not chosen under pressure.\n * @packageDocumentation\n */\n\nimport { type JsonSchema } from '@fgv/ts-json-base';\n\n// ============================================================================\n// Structured output — capability\n// ============================================================================\n\n/**\n * Wire format a provider uses to express a structured-output constraint.\n *\n * @remarks\n * Four shapes, not one, and they differ in more than field names: the OpenAI\n * pair carry the schema in the request body, Gemini carries it inside\n * `generationConfig`, and Anthropic has no response-format field at all —\n * its mechanism is forced tool use, which is why `'tool-forced'` is a distinct\n * {@link AiAssist.StructuredOutputEnforcement} value rather than a spelling of\n * `'schema'`.\n * @public\n */\nexport type AiStructuredOutputFormat =\n | 'openai-json-schema'\n | 'openai-responses-format'\n | 'gemini-response-schema'\n | 'anthropic-tool-forced';\n\n/**\n * Structured-output capability for a model family within a provider. Used as an\n * entry in `IAiProviderDescriptor.structuredOutput`.\n *\n * @remarks\n * Deliberately thinner than its `imageGeneration` / `embedding` siblings: it\n * carries no `supportsX` flags, because what each format can enforce is a\n * property of the provider's **API surface** rather than of any one model, and a\n * per-entry declaration of it could only ever disagree with the one in code.\n * @public\n */\nexport interface IAiStructuredOutputCapability {\n /**\n * Prefix matched against the resolved completion model id. The empty string is\n * the catch-all and matches every model. When multiple rules' prefixes match a\n * model id, the longest prefix wins; ties are broken by first-encountered.\n */\n readonly modelPrefix: string;\n /** Wire format used to express the constraint for matching models. */\n readonly format: AiStructuredOutputFormat;\n}\n\n/**\n * Which constraint the provider was **asked** to apply to this response.\n *\n * @remarks\n * Three questions hide inside *\"did it honour my schema\"*, and they have different\n * owners:\n *\n * | question | answerable by |\n * |---|---|\n * | did we send a constraint? | this client, at request-build time |\n * | which constraint did the provider apply? | this client, from the resolved model's capability |\n * | does *this response* conform to my shape? | the caller's converter, and nothing else |\n *\n * This type answers the first two and deliberately not the third. Reporting\n * conformance would mean re-validating against the caller's own schema to\n * re-derive an answer the caller already holds.\n *\n * - `'none'` — nothing was sent; the resolved model declares no capability.\n * - `'json-mode'` — syntactically valid JSON is guaranteed; the shape is not.\n * - `'schema'` — generation was constrained to the supplied schema.\n * - `'tool-forced'` — Anthropic-style forced tool use; the shape comes from the\n * forced tool's input schema, and `content` is the re-serialized tool input.\n * @public\n */\nexport type StructuredOutputEnforcement = 'none' | 'json-mode' | 'schema' | 'tool-forced';\n\n/**\n * What to do when the resolved model cannot apply the requested constraint.\n *\n * @remarks\n * `'degrade'` is the default, and it is only safe **because\n * `IAiCompletionResponse.structuredOutput` is required** rather than\n * optional. Degrade-and-tell-me is safe; degrade-silently is the failure this\n * whole surface exists to remove — so the two decisions are one decision, not\n * two independent ones.\n *\n * Reach for `'fail'` when the output is persisted or put on a wire, where an\n * unconstrained generation that happens to parse is worse than an error because\n * it is wrong quietly. Leave it at `'degrade'` on paths that are *designed* to\n * degrade — an extractor that may return nothing, a segmenter that floors to a\n * mechanical chunker — where a hard failure would make this library less safe\n * than the code it replaces.\n * @public\n */\nexport type StructuredOutputFallback = 'degrade' | 'fail';\n\n/**\n * Ask the provider for JSON constrained to a schema.\n * @public\n */\nexport interface ISchemaStructuredOutputRequest {\n readonly mode: 'schema';\n /**\n * The schema to constrain generation to — **the same object you validate the\n * reply with**, so the wire schema and the check cannot drift.\n *\n * @remarks\n * Author it with `JsonSchema.object({...})` from `@fgv/ts-json-base`. This is\n * the property `@fgv/ts-extras-ollama`'s `chatStructured` already has; this\n * surface is its cloud sibling.\n */\n readonly schema: JsonSchema.ISchemaValidator<unknown>;\n readonly onUnsupported?: StructuredOutputFallback;\n}\n\n/**\n * Ask the provider for syntactically valid JSON of arbitrary shape.\n *\n * @remarks\n * The weaker floor, and worth having on its own: the failure that motivated this\n * surface (`Expected ',' or '}' after property value` — an unescaped quote closing\n * a string early) is **syntactic**, so a JSON-mode guarantee removes it. Schema\n * constraint is what additionally buys shape. It is also the only mode some\n * model/provider pairs support.\n * @public\n */\nexport interface IJsonObjectStructuredOutputRequest {\n readonly mode: 'json-object';\n readonly onUnsupported?: StructuredOutputFallback;\n}\n\n/**\n * A caller's structured-output intent.\n *\n * @remarks\n * A discriminated union rather than an optional `schema` whose absence means\n * *\"json-object please\"* — an absence that means something is the shape this repo\n * has been burned by (see `MemoryEmbedOutcome` in `@fgv/ts-agent-memory`, which\n * exists because a three-ways-ambiguous absence could not be read).\n *\n * **The caller supplies intent; the response reports outcome.** A request never\n * needs to know whether the constraint will be honoured, because\n * `resolveProviderModel` resolves aliases and tiers at *call* time — a `tier`\n * request can cascade — so the concrete model that will serve a request is not\n * knowable to the caller up front. Requiring it to know would be unsound, which\n * is why the report rides on the response rather than being a lookup.\n * @public\n */\nexport type StructuredOutputRequest = ISchemaStructuredOutputRequest | IJsonObjectStructuredOutputRequest;\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fgv/ts-extras",
3
- "version": "5.1.0-52",
3
+ "version": "5.1.0-53",
4
4
  "description": "Assorted Typescript Utilities",
5
5
  "main": "lib/index.js",
6
6
  "types": "dist/ts-extras.d.ts",
@@ -100,10 +100,10 @@
100
100
  "@types/js-yaml": "~4.0.9",
101
101
  "typedoc": "~0.28.16",
102
102
  "typedoc-plugin-markdown": "~4.9.0",
103
- "@fgv/heft-dual-rig": "5.1.0-52",
104
- "@fgv/ts-utils-jest": "5.1.0-52",
105
- "@fgv/ts-utils": "5.1.0-52",
106
- "@fgv/typedoc-compact-theme": "5.1.0-52"
103
+ "@fgv/heft-dual-rig": "5.1.0-53",
104
+ "@fgv/ts-utils-jest": "5.1.0-53",
105
+ "@fgv/typedoc-compact-theme": "5.1.0-53",
106
+ "@fgv/ts-utils": "5.1.0-53"
107
107
  },
108
108
  "dependencies": {
109
109
  "@types/luxon": "^3.7.1",
@@ -112,10 +112,10 @@
112
112
  "papaparse": "^5.4.1",
113
113
  "fflate": "~0.8.2",
114
114
  "js-yaml": "~4.1.1",
115
- "@fgv/ts-json-base": "5.1.0-52"
115
+ "@fgv/ts-json-base": "5.1.0-53"
116
116
  },
117
117
  "peerDependencies": {
118
- "@fgv/ts-utils": "5.1.0-52"
118
+ "@fgv/ts-utils": "5.1.0-53"
119
119
  },
120
120
  "repository": {
121
121
  "type": "git",