universal-llm-client 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/dist/ai-model.d.ts +20 -22
  2. package/dist/ai-model.d.ts.map +1 -1
  3. package/dist/ai-model.js +26 -23
  4. package/dist/ai-model.js.map +1 -1
  5. package/dist/client.d.ts +5 -5
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +17 -9
  8. package/dist/client.js.map +1 -1
  9. package/dist/http.d.ts +2 -0
  10. package/dist/http.d.ts.map +1 -1
  11. package/dist/http.js +1 -0
  12. package/dist/http.js.map +1 -1
  13. package/dist/index.d.ts +3 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -4
  16. package/dist/index.js.map +1 -1
  17. package/dist/interfaces.d.ts +49 -11
  18. package/dist/interfaces.d.ts.map +1 -1
  19. package/dist/interfaces.js +14 -0
  20. package/dist/interfaces.js.map +1 -1
  21. package/dist/providers/anthropic.d.ts +56 -0
  22. package/dist/providers/anthropic.d.ts.map +1 -0
  23. package/dist/providers/anthropic.js +524 -0
  24. package/dist/providers/anthropic.js.map +1 -0
  25. package/dist/providers/google.d.ts +5 -0
  26. package/dist/providers/google.d.ts.map +1 -1
  27. package/dist/providers/google.js +64 -8
  28. package/dist/providers/google.js.map +1 -1
  29. package/dist/providers/index.d.ts +1 -0
  30. package/dist/providers/index.d.ts.map +1 -1
  31. package/dist/providers/index.js +1 -0
  32. package/dist/providers/index.js.map +1 -1
  33. package/dist/providers/ollama.d.ts.map +1 -1
  34. package/dist/providers/ollama.js +38 -11
  35. package/dist/providers/ollama.js.map +1 -1
  36. package/dist/providers/openai.d.ts.map +1 -1
  37. package/dist/providers/openai.js +9 -7
  38. package/dist/providers/openai.js.map +1 -1
  39. package/dist/router.d.ts +13 -33
  40. package/dist/router.d.ts.map +1 -1
  41. package/dist/router.js +33 -57
  42. package/dist/router.js.map +1 -1
  43. package/dist/stream-decoder.d.ts +29 -2
  44. package/dist/stream-decoder.d.ts.map +1 -1
  45. package/dist/stream-decoder.js +39 -11
  46. package/dist/stream-decoder.js.map +1 -1
  47. package/dist/structured-output.d.ts +107 -181
  48. package/dist/structured-output.d.ts.map +1 -1
  49. package/dist/structured-output.js +137 -192
  50. package/dist/structured-output.js.map +1 -1
  51. package/dist/zod-adapter.d.ts +44 -0
  52. package/dist/zod-adapter.d.ts.map +1 -0
  53. package/dist/zod-adapter.js +61 -0
  54. package/dist/zod-adapter.js.map +1 -0
  55. package/package.json +9 -1
  56. package/src/ai-model.ts +350 -0
  57. package/src/auditor.ts +213 -0
  58. package/src/client.ts +402 -0
  59. package/src/debug/debug-google-streaming.ts +97 -0
  60. package/src/debug/debug-tool-execution.ts +86 -0
  61. package/src/debug/test-lmstudio-tools.ts +155 -0
  62. package/src/demos/README.md +47 -0
  63. package/src/demos/basic/universal-llm-examples.ts +161 -0
  64. package/src/demos/mcp/astrid-memory-demo.ts +295 -0
  65. package/src/demos/mcp/astrid-persona-memory.ts +357 -0
  66. package/src/demos/mcp/mcp-mongodb-demo.ts +275 -0
  67. package/src/demos/mcp/simple-astrid-memory.ts +148 -0
  68. package/src/demos/mcp/simple-mcp-demo.ts +68 -0
  69. package/src/demos/mcp/working-mcp-demo.ts +62 -0
  70. package/src/demos/model-alias-demo.ts +0 -0
  71. package/src/demos/tools/RAG_MEMORY_INTEGRATION.md +267 -0
  72. package/src/demos/tools/astrid-memory-demo.ts +270 -0
  73. package/src/demos/tools/astrid-production-memory-clean.ts +785 -0
  74. package/src/demos/tools/astrid-production-memory.ts +558 -0
  75. package/src/demos/tools/basic-translation-test.ts +66 -0
  76. package/src/demos/tools/chromadb-similarity-tuning.ts +390 -0
  77. package/src/demos/tools/clean-multilingual-conversation.ts +209 -0
  78. package/src/demos/tools/clean-translation-test.ts +119 -0
  79. package/src/demos/tools/clean-universal-multilingual-test.ts +131 -0
  80. package/src/demos/tools/complete-rag-demo.ts +369 -0
  81. package/src/demos/tools/complete-tool-demo.ts +132 -0
  82. package/src/demos/tools/demo-tool-calling.ts +124 -0
  83. package/src/demos/tools/dynamic-language-switching-test.ts +251 -0
  84. package/src/demos/tools/hybrid-thinking-test.ts +154 -0
  85. package/src/demos/tools/memory-integration-test.ts +420 -0
  86. package/src/demos/tools/multilingual-memory-system.ts +802 -0
  87. package/src/demos/tools/ondemand-translation-demo.ts +655 -0
  88. package/src/demos/tools/production-tool-demo.ts +245 -0
  89. package/src/demos/tools/revolutionary-multilingual-test.ts +151 -0
  90. package/src/demos/tools/rigorous-language-analysis.ts +218 -0
  91. package/src/demos/tools/test-universal-memory-system.ts +126 -0
  92. package/src/demos/tools/translation-integration-guide.ts +346 -0
  93. package/src/demos/tools/universal-memory-system.ts +560 -0
  94. package/src/http.ts +247 -0
  95. package/src/index.ts +161 -0
  96. package/src/interfaces.ts +657 -0
  97. package/src/mcp.ts +345 -0
  98. package/src/providers/anthropic.ts +762 -0
  99. package/src/providers/google.ts +620 -0
  100. package/src/providers/index.ts +8 -0
  101. package/src/providers/ollama.ts +469 -0
  102. package/src/providers/openai.ts +392 -0
  103. package/src/router.ts +780 -0
  104. package/src/stream-decoder.ts +361 -0
  105. package/src/structured-output.ts +759 -0
  106. package/src/test-scripts/test-advanced-tools.ts +310 -0
  107. package/src/test-scripts/test-google-streaming-enhanced.ts +147 -0
  108. package/src/test-scripts/test-google-streaming.ts +63 -0
  109. package/src/test-scripts/test-google-system-prompt-comprehensive.ts +189 -0
  110. package/src/test-scripts/test-mcp-config.ts +28 -0
  111. package/src/test-scripts/test-mcp-connection.ts +29 -0
  112. package/src/test-scripts/test-system-message-positions.ts +163 -0
  113. package/src/test-scripts/test-system-prompt-improvement-demo.ts +83 -0
  114. package/src/test-scripts/test-tool-calling.ts +231 -0
  115. package/src/tests/ai-model.test.ts +1614 -0
  116. package/src/tests/auditor.test.ts +224 -0
  117. package/src/tests/http.test.ts +200 -0
  118. package/src/tests/interfaces.test.ts +117 -0
  119. package/src/tests/providers/google.test.ts +660 -0
  120. package/src/tests/providers/ollama.test.ts +954 -0
  121. package/src/tests/providers/openai.test.ts +1122 -0
  122. package/src/tests/router.test.ts +254 -0
  123. package/src/tests/stream-decoder.test.ts +179 -0
  124. package/src/tests/structured-output.test.ts +1450 -0
  125. package/src/tests/tools.test.ts +175 -0
  126. package/src/tools.ts +246 -0
  127. package/src/zod-adapter.ts +72 -0
@@ -0,0 +1,759 @@
1
+ /**
2
+ * Structured Output Core Types
3
+ *
4
+ * Core types for structured output support in universal-llm-client.
5
+ * Zero-dependency — works with raw JSON Schema and optional validate functions.
6
+ *
7
+ * For Zod integration, use the `universal-llm-client/zod` entrypoint.
8
+ *
9
+ * @module structured-output
10
+ */
11
+
12
+ // ============================================================================
13
+ // JSON Schema Types
14
+ // ============================================================================
15
+
16
+ /**
17
+ * JSON Schema definition for structured output.
18
+ * This is a subset of JSON Schema focused on what providers need.
19
+ */
20
+ export interface JSONSchema {
21
+ type?: string | string[];
22
+ properties?: Record<string, JSONSchema>;
23
+ items?: JSONSchema | JSONSchema[] | { type: string } | { type: string }[];
24
+ required?: string[];
25
+ additionalProperties?: boolean | JSONSchema;
26
+ enum?: (string | number | boolean | null)[];
27
+ const?: unknown;
28
+ oneOf?: JSONSchema[];
29
+ anyOf?: JSONSchema[];
30
+ allOf?: JSONSchema[];
31
+ not?: JSONSchema;
32
+ description?: string;
33
+ default?: unknown;
34
+ examples?: unknown[];
35
+ title?: string;
36
+ format?: string;
37
+ pattern?: string;
38
+ minLength?: number;
39
+ maxLength?: number;
40
+ minimum?: number;
41
+ maximum?: number;
42
+ exclusiveMinimum?: number;
43
+ exclusiveMaximum?: number;
44
+ minItems?: number;
45
+ maxItems?: number;
46
+ uniqueItems?: boolean;
47
+ minProperties?: number;
48
+ maxProperties?: number;
49
+ $ref?: string;
50
+ $id?: string;
51
+ $schema?: string;
52
+ definitions?: Record<string, JSONSchema>;
53
+ $defs?: Record<string, JSONSchema>;
54
+ }
55
+
56
+ // ============================================================================
57
+ // SchemaConfig — The core abstraction replacing z.ZodType<T>
58
+ // ============================================================================
59
+
60
+ /**
61
+ * Universal schema configuration for structured output.
62
+ *
63
+ * This is the Bring-Your-Own-Validator interface. The core library never
64
+ * imports Zod — instead it accepts a JSON Schema + optional validate function.
65
+ *
66
+ * Use `universal-llm-client/zod` → `fromZod()` to create this from Zod schemas.
67
+ *
68
+ * @template T The expected output type
69
+ *
70
+ * @example
71
+ * ```typescript
72
+ * // Zero-dep usage (no Zod):
73
+ * const config: SchemaConfig<{ name: string; age: number }> = {
74
+ * jsonSchema: {
75
+ * type: 'object',
76
+ * properties: {
77
+ * name: { type: 'string' },
78
+ * age: { type: 'number' },
79
+ * },
80
+ * required: ['name', 'age'],
81
+ * },
82
+ * validate: (data) => data as { name: string; age: number },
83
+ * };
84
+ *
85
+ * // With Zod (via adapter):
86
+ * import { fromZod } from 'universal-llm-client/zod';
87
+ * const config = fromZod(z.object({ name: z.string(), age: z.number() }));
88
+ * ```
89
+ */
90
+ export interface SchemaConfig<T = unknown> {
91
+ /** JSON Schema sent to the LLM provider */
92
+ readonly jsonSchema: JSONSchema;
93
+ /**
94
+ * Optional validator: parse unknown → T or throw.
95
+ * If omitted, JSON.parse() result is returned as-is (unsafe cast).
96
+ */
97
+ readonly validate?: (data: unknown) => T;
98
+ /** Schema name (for provider guidance, e.g. OpenAI strict mode) */
99
+ readonly name?: string;
100
+ /** Schema description (for provider guidance) */
101
+ readonly description?: string;
102
+ }
103
+
104
+ // ============================================================================
105
+ // Provider Schema Types
106
+ // ============================================================================
107
+
108
+ /**
109
+ * Provider identifier for schema conversion.
110
+ */
111
+ export type SchemaProvider = 'openai' | 'ollama' | 'google';
112
+
113
+ /**
114
+ * Result of converting a schema for a specific provider.
115
+ */
116
+ export interface ProviderSchema {
117
+ /** The JSON Schema for the provider */
118
+ schema: JSONSchema;
119
+ /** Optional schema name (used by OpenAI) */
120
+ name?: string;
121
+ /** Optional schema description (used by OpenAI) */
122
+ description?: string;
123
+ }
124
+
125
+ // ============================================================================
126
+ // Structured Output Error
127
+ // ============================================================================
128
+
129
+ /**
130
+ * Error options for StructuredOutputError
131
+ */
132
+ export interface StructuredOutputErrorOptions {
133
+ /** The raw output from the LLM that failed validation */
134
+ rawOutput: string;
135
+ /** The underlying cause (e.g., validation error) */
136
+ cause?: Error;
137
+ }
138
+
139
+ /**
140
+ * Custom error class for structured output validation failures.
141
+ *
142
+ * Thrown when:
143
+ * - JSON parsing of LLM response fails
144
+ * - Schema validation fails
145
+ *
146
+ * Features:
147
+ * - `rawOutput` property containing the original LLM response
148
+ * - `cause` property for the underlying error
149
+ *
150
+ * @example
151
+ * ```typescript
152
+ * try {
153
+ * const result = await model.generateStructured(schema, messages);
154
+ * } catch (error) {
155
+ * if (error instanceof StructuredOutputError) {
156
+ * console.log('Raw LLM output:', error.rawOutput);
157
+ * console.log('Cause:', error.cause);
158
+ * }
159
+ * }
160
+ * ```
161
+ */
162
+ export class StructuredOutputError extends Error {
163
+ /** The raw output from the LLM that failed validation */
164
+ public readonly rawOutput: string;
165
+
166
+ /** The underlying cause (e.g., validation error) */
167
+ public override readonly cause?: Error;
168
+
169
+ constructor(message: string, options: StructuredOutputErrorOptions) {
170
+ super(message);
171
+ this.rawOutput = options.rawOutput;
172
+ this.cause = options.cause;
173
+
174
+ // Maintains proper stack trace for where error was thrown (only available in V8)
175
+ if (Error.captureStackTrace) {
176
+ Error.captureStackTrace(this, StructuredOutputError);
177
+ }
178
+ }
179
+ }
180
+
181
+ // ============================================================================
182
+ // Structured Output Options (legacy — prefer SchemaConfig)
183
+ // ============================================================================
184
+
185
+ /**
186
+ * Options for structured output generation.
187
+ *
188
+ * Accepts either:
189
+ * - A SchemaConfig (recommended)
190
+ * - A raw JSON Schema object for flexibility
191
+ *
192
+ * @template T The expected output type
193
+ *
194
+ * @example
195
+ * ```typescript
196
+ * // Using SchemaConfig
197
+ * const options: StructuredOutputOptions<User> = {
198
+ * schemaConfig: mySchemaConfig,
199
+ * };
200
+ * ```
201
+ *
202
+ * @example
203
+ * ```typescript
204
+ * // Using raw JSON Schema
205
+ * const options: StructuredOutputOptions<User> = {
206
+ * jsonSchema: {
207
+ * type: 'object',
208
+ * properties: {
209
+ * name: { type: 'string' },
210
+ * age: { type: 'number' },
211
+ * },
212
+ * required: ['name', 'age'],
213
+ * },
214
+ * };
215
+ * ```
216
+ */
217
+ export interface StructuredOutputOptions<T> {
218
+ /**
219
+ * Schema configuration for structured output.
220
+ * Contains JSON Schema + optional validator.
221
+ */
222
+ schemaConfig?: SchemaConfig<T>;
223
+
224
+ /**
225
+ * Raw JSON Schema for structured output.
226
+ * Use this when you have a pre-defined schema without validation.
227
+ */
228
+ jsonSchema?: JSONSchema;
229
+
230
+ /**
231
+ * Optional name for the schema.
232
+ * Used by providers like OpenAI for better LLM guidance.
233
+ */
234
+ name?: string;
235
+
236
+ /**
237
+ * Optional description for the schema.
238
+ * Used by providers like OpenAI for better LLM guidance.
239
+ */
240
+ description?: string;
241
+ }
242
+
243
+ // ============================================================================
244
+ // Structured Output Result
245
+ // ============================================================================
246
+
247
+ /**
248
+ * Successful structured output result.
249
+ *
250
+ * @template T The output type
251
+ */
252
+ export interface StructuredOutputSuccess<T> {
253
+ /** Indicates success */
254
+ readonly ok: true;
255
+ /** The validated output value */
256
+ readonly value: T;
257
+ }
258
+
259
+ /**
260
+ * Failed structured output result.
261
+ *
262
+ * @template _T The expected output type (unused but kept for type alignment with StructuredOutputSuccess)
263
+ */
264
+ export interface StructuredOutputFailure<_T> {
265
+ /** Indicates failure */
266
+ readonly ok: false;
267
+ /** The error that occurred */
268
+ readonly error: StructuredOutputError;
269
+ /** The raw output from the LLM */
270
+ readonly rawOutput: string;
271
+ }
272
+
273
+ /**
274
+ * Result of structured output parsing.
275
+ *
276
+ * Discriminated union type that provides type-safe result handling:
277
+ * - `ok: true` → `{ value: T }`
278
+ * - `ok: false` → `{ error: StructuredOutputError, rawOutput: string }`
279
+ *
280
+ * @template T The output type
281
+ */
282
+ export type StructuredOutputResult<T> =
283
+ | StructuredOutputSuccess<T>
284
+ | StructuredOutputFailure<T>;
285
+
286
+ // ============================================================================
287
+ // Type Guards
288
+ // ============================================================================
289
+
290
+ /**
291
+ * Type guard to check if a structured output result is successful.
292
+ */
293
+ export function isStructuredOutputSuccess<T>(
294
+ result: StructuredOutputResult<T>,
295
+ ): result is StructuredOutputSuccess<T> {
296
+ return result.ok === true;
297
+ }
298
+
299
+ /**
300
+ * Type guard to check if a structured output result is a failure.
301
+ */
302
+ export function isStructuredOutputFailure<T>(
303
+ result: StructuredOutputResult<T>,
304
+ ): result is StructuredOutputFailure<T> {
305
+ return result.ok === false;
306
+ }
307
+
308
+ // ============================================================================
309
+ // Schema Conversion Utilities
310
+ // ============================================================================
311
+
312
+ /**
313
+ * Normalize a raw JSON Schema.
314
+ *
315
+ * Currently passes through without modification.
316
+ * Future versions may add normalization for provider compatibility.
317
+ *
318
+ * @param schema The JSON Schema to normalize
319
+ * @returns Normalized JSON Schema
320
+ */
321
+ export function normalizeJsonSchema(schema: JSONSchema): JSONSchema {
322
+ // Deep clone to avoid mutating the input
323
+ return JSON.parse(JSON.stringify(schema)) as JSONSchema;
324
+ }
325
+
326
+ /**
327
+ * Get the JSON Schema from a SchemaConfig or StructuredOutputOptions.
328
+ *
329
+ * @param options The structured output options
330
+ * @returns JSON Schema
331
+ */
332
+ export function getJsonSchema<T>(options: StructuredOutputOptions<T>): JSONSchema {
333
+ if (options.schemaConfig) {
334
+ return normalizeJsonSchema(options.schemaConfig.jsonSchema);
335
+ }
336
+ if (options.jsonSchema) {
337
+ return normalizeJsonSchema(options.jsonSchema);
338
+ }
339
+ throw new Error('Either schemaConfig or jsonSchema must be provided');
340
+ }
341
+
342
+ /**
343
+ * Get the JSON Schema from a SchemaConfig directly.
344
+ */
345
+ export function getJsonSchemaFromConfig<T>(config: SchemaConfig<T>): JSONSchema {
346
+ return normalizeJsonSchema(config.jsonSchema);
347
+ }
348
+
349
+ /**
350
+ * Features that some providers don't support.
351
+ * These are removed when transforming schemas for those providers.
352
+ */
353
+ const GOOGLE_UNSUPPORTED_FEATURES = [
354
+ 'pattern',
355
+ 'minLength',
356
+ 'maxLength',
357
+ 'minimum',
358
+ 'maximum',
359
+ 'exclusiveMinimum',
360
+ 'exclusiveMaximum',
361
+ // Google doesn't support additionalProperties in response schema
362
+ 'additionalProperties',
363
+ ] as const;
364
+
365
+ /**
366
+ * Strip unsupported features from a JSON Schema for a specific provider.
367
+ *
368
+ * Google/Gemini doesn't support certain JSON Schema features like pattern, min/max.
369
+ * This function removes those recursively.
370
+ *
371
+ * @param schema The JSON Schema to transform
372
+ * @param provider The target provider
373
+ * @returns Cleaned JSON Schema
374
+ */
375
+ export function stripUnsupportedFeatures(
376
+ schema: JSONSchema,
377
+ provider: SchemaProvider,
378
+ ): JSONSchema {
379
+ // Only Google needs transformation currently
380
+ if (provider !== 'google') {
381
+ return schema;
382
+ }
383
+
384
+ // Deep clone to avoid mutating input
385
+ const result: JSONSchema = JSON.parse(JSON.stringify(schema));
386
+
387
+ // Remove unsupported top-level properties
388
+ for (const feature of GOOGLE_UNSUPPORTED_FEATURES) {
389
+ delete (result as Record<string, unknown>)[feature];
390
+ }
391
+
392
+ // Recursively clean nested schemas
393
+ if (result.properties) {
394
+ for (const key of Object.keys(result.properties)) {
395
+ if (result.properties[key]) {
396
+ result.properties[key] = stripUnsupportedFeatures(result.properties[key], provider);
397
+ }
398
+ }
399
+ }
400
+
401
+ if (result['items']) {
402
+ if (Array.isArray(result['items'])) {
403
+ (result as Record<string, unknown>)['items'] = result['items'].map(item => stripUnsupportedFeatures(item as JSONSchema, provider));
404
+ } else {
405
+ result['items'] = stripUnsupportedFeatures(result['items'] as JSONSchema, provider);
406
+ }
407
+ }
408
+
409
+ // Handle oneOf, anyOf, allOf
410
+ for (const key of ['oneOf', 'anyOf', 'allOf'] as const) {
411
+ const schemas = result[key];
412
+ if (Array.isArray(schemas)) {
413
+ (result as Record<string, unknown>)[key] = schemas.map(s => stripUnsupportedFeatures(s, provider));
414
+ }
415
+ }
416
+
417
+ return result;
418
+ }
419
+
420
+ /**
421
+ * Convert structured output options to a provider-specific schema.
422
+ *
423
+ * This function:
424
+ * 1. Extracts/converts the JSON Schema from options
425
+ * 2. Applies provider-specific transformations (e.g., removing unsupported features for Google)
426
+ * 3. Adds name/description for LLM guidance
427
+ */
428
+ export function convertToProviderSchema<T>(
429
+ provider: SchemaProvider,
430
+ options: StructuredOutputOptions<T>,
431
+ ): ProviderSchema {
432
+ // Get the JSON Schema
433
+ const jsonSchema = getJsonSchema(options);
434
+
435
+ // Apply provider-specific transformations
436
+ const schema = stripUnsupportedFeatures(jsonSchema, provider);
437
+
438
+ // Generate a default name if not provided (some providers require it)
439
+ const name = options.name ?? options.schemaConfig?.name ?? 'response';
440
+
441
+ return {
442
+ schema,
443
+ name,
444
+ description: options.description ?? options.schemaConfig?.description,
445
+ };
446
+ }
447
+
448
+ // ============================================================================
449
+ // Validation Functions
450
+ // ============================================================================
451
+
452
+ /**
453
+ * Strip markdown code fences from raw LLM output.
454
+ *
455
+ * Some providers/models — notably Gemma variants via Ollama — wrap
456
+ * structured JSON output in ` ```json ... ``` ` fences even when a JSON
457
+ * schema is passed via the `format` field. This helper extracts the
458
+ * inner payload so it can be fed to `JSON.parse`.
459
+ *
460
+ * Behavior:
461
+ * - If the input contains no triple-backtick fences, the input is
462
+ * returned unchanged (zero risk of disturbing well-formed output).
463
+ * - Otherwise, the first fenced block is extracted and returned trimmed.
464
+ * Language tags (` ```json `, ` ```JSON `, etc.) are handled.
465
+ * - Callers should still treat the return value as untrusted — it may
466
+ * not be valid JSON (e.g. the model emitted prose, not JSON).
467
+ *
468
+ * This helper is idempotent: `stripJsonFences(stripJsonFences(x)) === stripJsonFences(x)`.
469
+ *
470
+ * @param rawOutput The raw LLM response text
471
+ * @returns Fence-stripped content if fences were present, otherwise `rawOutput` unchanged
472
+ */
473
+ export function stripJsonFences(rawOutput: string): string {
474
+ if (!rawOutput.includes('```')) return rawOutput;
475
+
476
+ // Match the first fenced block, optionally with a language tag.
477
+ // The non-greedy `[\s\S]*?` keeps us from swallowing content after
478
+ // the first closing fence. Whitespace/newlines around the payload
479
+ // are tolerated so we accept both inline and multi-line fences.
480
+ const fenceMatch = rawOutput.match(
481
+ /```(?:[a-zA-Z0-9_-]*)[ \t]*\n?([\s\S]*?)\n?[ \t]*```/,
482
+ );
483
+ if (fenceMatch && fenceMatch[1] !== undefined) {
484
+ return fenceMatch[1].trim();
485
+ }
486
+ return rawOutput;
487
+ }
488
+
489
+ /**
490
+ * Parse and validate structured output from raw LLM response text.
491
+ *
492
+ * This function:
493
+ * 1. Parses JSON from the raw output string (with fallback fence stripping
494
+ * for models that wrap structured output in ` ```json ... ``` `)
495
+ * 2. Validates using the SchemaConfig's validate function (if provided)
496
+ * 3. Throws StructuredOutputError on failure
497
+ *
498
+ * @param config The schema configuration with optional validator
499
+ * @param rawOutput The raw string output from the LLM
500
+ * @returns The validated and typed data
501
+ * @throws StructuredOutputError if JSON parsing fails or validation fails
502
+ */
503
+ export function parseStructured<T>(
504
+ config: SchemaConfig<T>,
505
+ rawOutput: string,
506
+ ): T {
507
+ // Step 1: Parse JSON. Try the raw output first — well-behaved providers
508
+ // return clean JSON and we don't want to pay any cost in the fast path.
509
+ // If that fails, fall back to stripping markdown code fences before
510
+ // re-trying; some models (e.g. Gemma via Ollama) wrap structured output
511
+ // in ` ```json ... ``` ` even when a JSON schema is requested.
512
+ let parsed: unknown;
513
+ let parseError: SyntaxError | undefined;
514
+ try {
515
+ parsed = JSON.parse(rawOutput);
516
+ } catch (error) {
517
+ parseError = error instanceof SyntaxError
518
+ ? error
519
+ : new SyntaxError(String(error));
520
+
521
+ const fenceStripped = stripJsonFences(rawOutput);
522
+ if (fenceStripped !== rawOutput) {
523
+ try {
524
+ parsed = JSON.parse(fenceStripped);
525
+ parseError = undefined;
526
+ } catch {
527
+ // Fence stripping didn't help — fall through to throw the
528
+ // original parse error so error messages reflect what the
529
+ // model actually emitted, not the post-processed variant.
530
+ }
531
+ }
532
+ }
533
+
534
+ if (parseError) {
535
+ throw new StructuredOutputError(
536
+ `Failed to parse JSON: ${parseError.message}`,
537
+ { rawOutput, cause: parseError },
538
+ );
539
+ }
540
+
541
+ // Step 2: Validate if validator is provided
542
+ if (config.validate) {
543
+ try {
544
+ return config.validate(parsed);
545
+ } catch (error) {
546
+ const validationError = error instanceof Error ? error : new Error(String(error));
547
+ throw new StructuredOutputError(
548
+ `Validation failed: ${validationError.message}`,
549
+ { rawOutput, cause: validationError },
550
+ );
551
+ }
552
+ }
553
+
554
+ // No validator — return as-is (unsafe cast, user chose to skip validation)
555
+ return parsed as T;
556
+ }
557
+
558
+ /**
559
+ * Try to parse and validate structured output, returning a result object.
560
+ *
561
+ * This is the non-throwing variant of `parseStructured`. Instead of throwing
562
+ * on validation failure, it returns a result object with `ok: false` and
563
+ * the error details.
564
+ *
565
+ * @param config The schema configuration with optional validator
566
+ * @param rawOutput The raw string output from the LLM
567
+ * @returns A result object: `{ ok: true, value }` on success, `{ ok: false, error, rawOutput }` on failure
568
+ */
569
+ export function tryParseStructured<T>(
570
+ config: SchemaConfig<T>,
571
+ rawOutput: string,
572
+ ): StructuredOutputResult<T> {
573
+ try {
574
+ const value = parseStructured(config, rawOutput);
575
+ return { ok: true, value };
576
+ } catch (error) {
577
+ if (error instanceof StructuredOutputError) {
578
+ return {
579
+ ok: false,
580
+ error,
581
+ rawOutput,
582
+ };
583
+ }
584
+ // Re-throw unexpected errors
585
+ throw error;
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Validate already-parsed data using a SchemaConfig's validator.
591
+ *
592
+ * This is useful when you have already parsed JSON and need to validate it.
593
+ *
594
+ * @param config The schema configuration with optional validator
595
+ * @param data The parsed data to validate
596
+ * @param rawOutput Optional raw output string for error messages
597
+ * @returns The validated and typed data
598
+ * @throws StructuredOutputError if validation fails
599
+ */
600
+ export function validateStructuredOutput<T>(
601
+ config: SchemaConfig<T>,
602
+ data: unknown,
603
+ rawOutput?: string,
604
+ ): T {
605
+ if (config.validate) {
606
+ try {
607
+ return config.validate(data);
608
+ } catch (error) {
609
+ const rawData = rawOutput ?? JSON.stringify(data);
610
+ const validationError = error instanceof Error ? error : new Error(String(error));
611
+ throw new StructuredOutputError(
612
+ `Validation failed: ${validationError.message}`,
613
+ { rawOutput: rawData, cause: validationError },
614
+ );
615
+ }
616
+ }
617
+ return data as T;
618
+ }
619
+
620
+ // ============================================================================
621
+ // Streaming JSON Parsing
622
+ // ============================================================================
623
+
624
+ /**
625
+ * Incremental JSON parser for streaming structured output.
626
+ *
627
+ * Allows parsing partial JSON as it streams in, returning validated partial
628
+ * objects when possible. Useful for structured output streaming where you
629
+ * want to see partial results before the complete JSON arrives.
630
+ */
631
+ export class StreamingJsonParser<T> {
632
+ private buffer = '';
633
+ private readonly validateFn?: (data: unknown) => T;
634
+
635
+ constructor(config: SchemaConfig<T>) {
636
+ this.validateFn = config.validate;
637
+ }
638
+
639
+ /**
640
+ * Feed a chunk of JSON text to the parser.
641
+ * Returns a validated partial object if the current buffer can be parsed
642
+ * as valid JSON that passes validation, or undefined if not yet valid.
643
+ */
644
+ feed(chunk: string): { partial: T | undefined; complete: boolean } {
645
+ this.buffer += chunk;
646
+
647
+ // Try to parse as complete JSON first
648
+ try {
649
+ const parsed = JSON.parse(this.buffer);
650
+ if (this.validateFn) {
651
+ try {
652
+ const validated = this.validateFn(parsed);
653
+ return { partial: validated, complete: true };
654
+ } catch {
655
+ // Validation failed on complete JSON — return parsed but not validated
656
+ }
657
+ }
658
+ return { partial: parsed as T, complete: true };
659
+ } catch {
660
+ // Not yet valid complete JSON
661
+ }
662
+
663
+ // Try to create a valid partial by closing braces
664
+ const partialResult = this.tryParsePartial();
665
+ return { partial: partialResult, complete: false };
666
+ }
667
+
668
+ /**
669
+ * Get the current buffer content.
670
+ */
671
+ getBuffer(): string {
672
+ return this.buffer;
673
+ }
674
+
675
+ /**
676
+ * Reset the parser state.
677
+ */
678
+ reset(): void {
679
+ this.buffer = '';
680
+ }
681
+
682
+ /**
683
+ * Attempt to parse partial JSON by adding closing brackets.
684
+ */
685
+ private tryParsePartial(): T | undefined {
686
+ // Count unclosed brackets and braces
687
+ let braceCount = 0;
688
+ let bracketCount = 0;
689
+ let inString = false;
690
+ let escaped = false;
691
+
692
+ for (let i = 0; i < this.buffer.length; i++) {
693
+ const char = this.buffer[i];
694
+
695
+ if (escaped) {
696
+ escaped = false;
697
+ continue;
698
+ }
699
+
700
+ if (char === '\\' && inString) {
701
+ escaped = true;
702
+ continue;
703
+ }
704
+
705
+ if (char === '"') {
706
+ inString = !inString;
707
+ continue;
708
+ }
709
+
710
+ if (inString) continue;
711
+
712
+ if (char === '{') braceCount++;
713
+ else if (char === '}') braceCount--;
714
+ else if (char === '[') bracketCount++;
715
+ else if (char === ']') bracketCount--;
716
+ }
717
+
718
+ // Build closing sequence
719
+ let closing = '';
720
+ while (bracketCount > 0) {
721
+ closing += ']';
722
+ bracketCount--;
723
+ }
724
+ while (braceCount > 0) {
725
+ closing += '}';
726
+ braceCount--;
727
+ }
728
+
729
+ // Try parsing with closing
730
+ const candidate = this.buffer + closing;
731
+ try {
732
+ const parsed = JSON.parse(candidate);
733
+ if (this.validateFn) {
734
+ try {
735
+ return this.validateFn(parsed);
736
+ } catch {
737
+ // Partial validation failed — that's expected for partials
738
+ }
739
+ }
740
+ return parsed as T;
741
+ } catch {
742
+ // Silently fail for partial JSON
743
+ }
744
+
745
+ return undefined;
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Streaming structured output result.
751
+ * Each partial yield from the generator is a validated object (possibly partial).
752
+ * The final return value is the complete validated object.
753
+ */
754
+ export interface StreamingStructuredResult<T> {
755
+ /** Whether this is partial (incomplete) data */
756
+ partial: boolean;
757
+ /** The validated partial or complete object */
758
+ value: T;
759
+ }