react-native-ai-core 0.1.0 → 0.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.
@@ -0,0 +1,1118 @@
1
+ import { Platform } from 'react-native';
2
+ import { z, type ZodTypeAny } from 'zod';
3
+ import NativeAiCore from './NativeAiCore';
4
+
5
+ export type StructuredSchema<T> = z.ZodType<T>;
6
+
7
+ export interface StructuredGenerateOptions<
8
+ TOutputSchema extends ZodTypeAny,
9
+ TInput = unknown,
10
+ > {
11
+ prompt: string;
12
+ output: TOutputSchema;
13
+ input?: TInput;
14
+ inputSchema?: z.ZodType<TInput>;
15
+ maxRetries?: number;
16
+ maxContinuations?: number;
17
+ timeoutMs?: number;
18
+ /**
19
+ * 'single' — generate the entire JSON in one call (default).
20
+ * 'chunked' — generate each top-level field separately and assemble the object.
21
+ * Recommended for schemas with many fields or long responses.
22
+ */
23
+ strategy?: 'single' | 'chunked';
24
+ /**
25
+ * Called at each step of chunked generation.
26
+ * @param field JSON path of the field being generated, e.g. "days.0.exercises"
27
+ * @param done true when the field has just been completed
28
+ */
29
+ onProgress?: (field: string, done: boolean) => void;
30
+ }
31
+
32
+ export interface StructuredValidationIssue {
33
+ path: string;
34
+ message: string;
35
+ }
36
+
37
+ export class StructuredOutputError extends Error {
38
+ issues: StructuredValidationIssue[];
39
+ rawResponse: string;
40
+
41
+ constructor(
42
+ message: string,
43
+ rawResponse: string,
44
+ issues: StructuredValidationIssue[] = []
45
+ ) {
46
+ super(message);
47
+ this.name = 'StructuredOutputError';
48
+ this.rawResponse = rawResponse;
49
+ this.issues = issues;
50
+ }
51
+ }
52
+
53
+ const STRUCTURED_PROMPT_BUDGET = 2600;
54
+ const STRUCTURED_REPAIR_RESPONSE_BUDGET = 1200;
55
+ const STRUCTURED_ISSUES_BUDGET = 600;
56
+ const STRUCTURED_CONTINUATION_BUDGET = 1400;
57
+ const DEFAULT_MAX_STRUCTURED_CONTINUATIONS = 8;
58
+ const CONTINUATION_OVERLAP_WINDOW = 160;
59
+ const DEFAULT_STRUCTURED_TIMEOUT_MS = 300000; // 5 min default
60
+ const QUOTA_ERROR_CODE = 9;
61
+ const QUOTA_RETRY_DELAY_MS = 1800;
62
+ const MAX_QUOTA_RETRIES = 2;
63
+
64
+ function sleep(ms: number): Promise<void> {
65
+ return new Promise((resolve) => setTimeout(resolve, ms));
66
+ }
67
+
68
+ function toErrorMessage(error: unknown): string {
69
+ if (error instanceof Error) return error.message;
70
+ if (typeof error === 'string') return error;
71
+ try {
72
+ return JSON.stringify(error);
73
+ } catch {
74
+ return String(error);
75
+ }
76
+ }
77
+
78
+ function readNumericErrorCode(error: unknown): number | null {
79
+ if (typeof error === 'object' && error !== null) {
80
+ const directCode = (error as { errorCode?: unknown }).errorCode;
81
+ if (typeof directCode === 'number') return directCode;
82
+
83
+ const code = (error as { code?: unknown }).code;
84
+ if (typeof code === 'number') return code;
85
+ if (typeof code === 'string') {
86
+ const parsed = Number.parseInt(code, 10);
87
+ if (Number.isFinite(parsed)) return parsed;
88
+ }
89
+ }
90
+
91
+ const message = toErrorMessage(error);
92
+ const match = message.match(/error\s*code\s*[:=]?\s*(\d+)/i);
93
+ if (!match) return null;
94
+ const capturedCode = match[1];
95
+ if (!capturedCode) return null;
96
+ const parsed = Number.parseInt(capturedCode, 10);
97
+ return Number.isFinite(parsed) ? parsed : null;
98
+ }
99
+
100
+ function isQuotaError(error: unknown): boolean {
101
+ const code = readNumericErrorCode(error);
102
+ if (code === QUOTA_ERROR_CODE) return true;
103
+ const message = toErrorMessage(error).toLowerCase();
104
+ return (
105
+ message.includes('out of quota') ||
106
+ message.includes('quota exceeded') ||
107
+ message.includes('error code: 9')
108
+ );
109
+ }
110
+
111
+ function truncateStart(text: string, maxChars: number): string {
112
+ if (text.length <= maxChars) return text;
113
+ return text.slice(text.length - maxChars);
114
+ }
115
+
116
+ function truncateEnd(text: string, maxChars: number): string {
117
+ if (text.length <= maxChars) return text;
118
+ return text.slice(0, maxChars);
119
+ }
120
+
121
+ function compactWhitespace(text: string): string {
122
+ return text.replace(/\s+/g, ' ').trim();
123
+ }
124
+
125
+ function stringifyInput(value: unknown, maxChars: number): string {
126
+ return truncateStart(JSON.stringify(value, null, 2), maxChars);
127
+ }
128
+
129
+ function fitStructuredPrompt(parts: string[]): string {
130
+ return truncateStart(
131
+ parts.filter(Boolean).join('\n'),
132
+ STRUCTURED_PROMPT_BUDGET
133
+ );
134
+ }
135
+
136
+ function fitContinuationPrompt(parts: string[]): string {
137
+ return truncateStart(
138
+ parts.filter(Boolean).join('\n'),
139
+ STRUCTURED_CONTINUATION_BUDGET
140
+ );
141
+ }
142
+
143
+ function zodTypeToDescription(schema: ZodTypeAny): string {
144
+ if (schema instanceof z.ZodString) return 'string';
145
+ if (schema instanceof z.ZodNumber) return 'number';
146
+ if (schema instanceof z.ZodBoolean) return 'boolean';
147
+ if (schema instanceof z.ZodNull) return 'null';
148
+ if (schema instanceof z.ZodEnum) {
149
+ return `enum(${schema.options
150
+ .map((value: string) => JSON.stringify(value))
151
+ .join(', ')})`;
152
+ }
153
+ if (schema instanceof z.ZodLiteral) {
154
+ return `literal(${JSON.stringify(schema.value)})`;
155
+ }
156
+ if (schema instanceof z.ZodArray) {
157
+ return `Array<${zodTypeToDescription(schema.element as unknown as ZodTypeAny)}>`;
158
+ }
159
+ if (schema instanceof z.ZodOptional) {
160
+ return `${zodTypeToDescription(schema.unwrap() as unknown as ZodTypeAny)} | undefined`;
161
+ }
162
+ if (schema instanceof z.ZodNullable) {
163
+ return `${zodTypeToDescription(schema.unwrap() as unknown as ZodTypeAny)} | null`;
164
+ }
165
+ if (schema instanceof z.ZodUnion) {
166
+ return schema.options
167
+ .map((option: unknown) => zodTypeToDescription(option as ZodTypeAny))
168
+ .join(' | ');
169
+ }
170
+ if (schema instanceof z.ZodObject) {
171
+ const shape = schema.shape;
172
+ const entries = Object.entries(shape).map(
173
+ ([key, value]) => ` ${key}: ${zodTypeToDescription(value as ZodTypeAny)}`
174
+ );
175
+ return `{
176
+ ${entries.join(',\n')}
177
+ }`;
178
+ }
179
+
180
+ return 'unknown';
181
+ }
182
+
183
+ function formatIssues(error: z.ZodError): StructuredValidationIssue[] {
184
+ return error.issues.map((issue) => ({
185
+ path: issue.path.join('.') || '$',
186
+ message: issue.message,
187
+ }));
188
+ }
189
+
190
+ function extractFencedJson(text: string): string | null {
191
+ const match = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
192
+ return match?.[1]?.trim() ?? null;
193
+ }
194
+
195
+ function extractBalancedJson(text: string): string | null {
196
+ let start = -1;
197
+ let depth = 0;
198
+ let inString = false;
199
+ let escaped = false;
200
+
201
+ for (let index = 0; index < text.length; index++) {
202
+ const char = text[index];
203
+
204
+ if (inString) {
205
+ if (escaped) {
206
+ escaped = false;
207
+ } else if (char === '\\') {
208
+ escaped = true;
209
+ } else if (char === '"') {
210
+ inString = false;
211
+ }
212
+ continue;
213
+ }
214
+
215
+ if (char === '"') {
216
+ inString = true;
217
+ continue;
218
+ }
219
+
220
+ if (char === '{' || char === '[') {
221
+ if (depth === 0) start = index;
222
+ depth++;
223
+ continue;
224
+ }
225
+
226
+ if (char === '}' || char === ']') {
227
+ depth--;
228
+ if (depth === 0 && start >= 0) {
229
+ return text.slice(start, index + 1);
230
+ }
231
+ }
232
+ }
233
+
234
+ return null;
235
+ }
236
+
237
+ function extractJsonPayload(raw: string): string {
238
+ const trimmed = raw.trim();
239
+ const fenced = extractFencedJson(trimmed);
240
+ if (fenced) return fenced;
241
+ const balanced = extractBalancedJson(trimmed);
242
+ if (balanced) return balanced;
243
+ return trimmed;
244
+ }
245
+
246
+ function stripCodeFences(text: string): string {
247
+ return text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
248
+ }
249
+
250
+ function mergeStructuredFragments(existing: string, incoming: string): string {
251
+ const normalizedIncoming = stripCodeFences(incoming).trimStart();
252
+ if (!normalizedIncoming) return existing;
253
+ if (!existing) return normalizedIncoming;
254
+
255
+ const maxOverlap = Math.min(
256
+ CONTINUATION_OVERLAP_WINDOW,
257
+ existing.length,
258
+ normalizedIncoming.length
259
+ );
260
+
261
+ for (let size = maxOverlap; size > 0; size--) {
262
+ if (existing.endsWith(normalizedIncoming.slice(0, size))) {
263
+ return existing + normalizedIncoming.slice(size);
264
+ }
265
+ }
266
+
267
+ return existing + normalizedIncoming;
268
+ }
269
+
270
+ function looksLikeIncompleteJson(text: string): boolean {
271
+ const candidate = extractJsonPayload(text).trim();
272
+ if (!candidate) return false;
273
+
274
+ let depth = 0;
275
+ let inString = false;
276
+ let escaped = false;
277
+
278
+ for (let index = 0; index < candidate.length; index++) {
279
+ const char = candidate[index];
280
+
281
+ if (inString) {
282
+ if (escaped) {
283
+ escaped = false;
284
+ } else if (char === '\\') {
285
+ escaped = true;
286
+ } else if (char === '"') {
287
+ inString = false;
288
+ }
289
+ continue;
290
+ }
291
+
292
+ if (char === '"') {
293
+ inString = true;
294
+ continue;
295
+ }
296
+
297
+ if (char === '{' || char === '[') {
298
+ depth++;
299
+ continue;
300
+ }
301
+
302
+ if (char === '}' || char === ']') {
303
+ depth = Math.max(0, depth - 1);
304
+ }
305
+ }
306
+
307
+ const lastChar = candidate[candidate.length - 1];
308
+ return inString || depth > 0 || lastChar === ',' || lastChar === ':';
309
+ }
310
+
311
+ function buildStructuredContinuationPrompt<TInput>(
312
+ prompt: string,
313
+ outputSchema: ZodTypeAny,
314
+ partialResponse: string,
315
+ input?: TInput
316
+ ): string {
317
+ const schemaDescription = truncateEnd(
318
+ zodTypeToDescription(outputSchema),
319
+ 700
320
+ );
321
+ const normalizedPrompt = truncateEnd(compactWhitespace(prompt), 500);
322
+ const serializedInput =
323
+ input === undefined ? '' : `Input JSON:\n${stringifyInput(input, 500)}`;
324
+ const partialTail = truncateStart(stripCodeFences(partialResponse), 900);
325
+
326
+ return fitContinuationPrompt([
327
+ 'Continue the same JSON value from the next character.',
328
+ 'Do not restart, repeat, explain, summarize, or wrap in markdown.',
329
+ 'Output only the missing suffix needed to complete the same valid JSON.',
330
+ 'Schema:',
331
+ schemaDescription,
332
+ `Task:\n${normalizedPrompt}`,
333
+ serializedInput,
334
+ 'Current partial JSON tail:',
335
+ partialTail,
336
+ ]);
337
+ }
338
+
339
+ function buildJsonParseIssues(error: unknown): StructuredValidationIssue[] {
340
+ if (error instanceof z.ZodError) {
341
+ return formatIssues(error);
342
+ }
343
+
344
+ if (error instanceof Error) {
345
+ return [{ path: '$', message: error.message }];
346
+ }
347
+
348
+ return [{ path: '$', message: 'Unknown structured output error' }];
349
+ }
350
+
351
+ async function generateStructuredRawResponse<
352
+ TOutputSchema extends ZodTypeAny,
353
+ TInput = unknown,
354
+ >(
355
+ prompt: string,
356
+ output: TOutputSchema,
357
+ input: TInput | undefined,
358
+ maxContinuations: number,
359
+ timeoutMs: number
360
+ ): Promise<string> {
361
+ let combined = await generateStatelessWithQuotaRetry(
362
+ buildStructuredPrompt(prompt, output, input),
363
+ timeoutMs
364
+ );
365
+
366
+ for (let attempt = 0; attempt < maxContinuations; attempt++) {
367
+ try {
368
+ JSON.parse(extractJsonPayload(combined));
369
+ return combined;
370
+ } catch {
371
+ if (!looksLikeIncompleteJson(combined)) {
372
+ return combined;
373
+ }
374
+ }
375
+
376
+ const continuationPrompt = buildStructuredContinuationPrompt(
377
+ prompt,
378
+ output,
379
+ combined,
380
+ input
381
+ );
382
+ const continuation = await tryGenerateWithQuotaTolerance(
383
+ continuationPrompt,
384
+ timeoutMs
385
+ );
386
+ if (continuation === null) {
387
+ break;
388
+ }
389
+ const merged = mergeStructuredFragments(combined, continuation);
390
+ if (merged === combined) {
391
+ break;
392
+ }
393
+ combined = merged;
394
+ }
395
+
396
+ return combined;
397
+ }
398
+
399
+ function buildStructuredPrompt<TInput>(
400
+ prompt: string,
401
+ outputSchema: ZodTypeAny,
402
+ input?: TInput
403
+ ): string {
404
+ const schemaDescription = truncateEnd(
405
+ zodTypeToDescription(outputSchema),
406
+ 900
407
+ );
408
+ const normalizedPrompt = truncateEnd(compactWhitespace(prompt), 900);
409
+ const serializedInput =
410
+ input === undefined ? '' : `Input JSON:\n${stringifyInput(input, 900)}`;
411
+
412
+ return fitStructuredPrompt([
413
+ 'Return only valid JSON.',
414
+ 'Do not include markdown, code fences, comments, prose, or explanations.',
415
+ 'The JSON must match this exact TypeScript-like schema:',
416
+ schemaDescription,
417
+ `Task:\n${normalizedPrompt}`,
418
+ serializedInput,
419
+ ]);
420
+ }
421
+
422
+ function buildRepairPrompt(
423
+ prompt: string,
424
+ outputSchema: ZodTypeAny,
425
+ invalidResponse: string,
426
+ issues: StructuredValidationIssue[]
427
+ ): string {
428
+ const issueText = issues
429
+ .map((issue) => `- ${issue.path}: ${issue.message}`)
430
+ .join('\n');
431
+ const normalizedIssues = truncateEnd(issueText, STRUCTURED_ISSUES_BUDGET);
432
+ const normalizedInvalidResponse = truncateStart(
433
+ compactWhitespace(extractJsonPayload(invalidResponse)),
434
+ STRUCTURED_REPAIR_RESPONSE_BUDGET
435
+ );
436
+
437
+ return fitStructuredPrompt([
438
+ buildStructuredPrompt(prompt, outputSchema),
439
+ 'Your previous response was invalid.',
440
+ 'Fix it and return only corrected JSON.',
441
+ 'Validation errors:',
442
+ normalizedIssues,
443
+ 'Previous response:',
444
+ normalizedInvalidResponse,
445
+ ]);
446
+ }
447
+
448
+ async function generateStateless(prompt: string): Promise<string> {
449
+ if (!NativeAiCore) {
450
+ throw new Error(
451
+ `react-native-ai-core: native module unavailable on ${Platform.OS}. This feature requires Android.`
452
+ );
453
+ }
454
+
455
+ if (NativeAiCore?.generateResponseStateless) {
456
+ return NativeAiCore.generateResponseStateless(prompt);
457
+ }
458
+
459
+ return NativeAiCore.generateResponse(prompt);
460
+ }
461
+
462
+ async function generateStatelessWithTimeout(
463
+ prompt: string,
464
+ timeoutMs: number
465
+ ): Promise<string> {
466
+ if (timeoutMs <= 0) {
467
+ return generateStateless(prompt);
468
+ }
469
+
470
+ let timer: ReturnType<typeof setTimeout> | undefined;
471
+ const timeout = new Promise<string>((_, reject) => {
472
+ timer = setTimeout(() => {
473
+ reject(new Error(`Structured generation timed out after ${timeoutMs}ms`));
474
+ }, timeoutMs);
475
+ });
476
+
477
+ try {
478
+ return await Promise.race([generateStateless(prompt), timeout]);
479
+ } finally {
480
+ if (timer) clearTimeout(timer);
481
+ }
482
+ }
483
+
484
+ async function generateStatelessWithQuotaRetry(
485
+ prompt: string,
486
+ timeoutMs: number
487
+ ): Promise<string> {
488
+ let quotaRetries = 0;
489
+ while (true) {
490
+ try {
491
+ return await generateStatelessWithTimeout(prompt, timeoutMs);
492
+ } catch (error) {
493
+ if (!isQuotaError(error) || quotaRetries >= MAX_QUOTA_RETRIES) {
494
+ throw error;
495
+ }
496
+ quotaRetries += 1;
497
+ await sleep(QUOTA_RETRY_DELAY_MS);
498
+ }
499
+ }
500
+ }
501
+
502
+ async function tryGenerateWithQuotaTolerance(
503
+ prompt: string,
504
+ timeoutMs: number
505
+ ): Promise<string | null> {
506
+ try {
507
+ return await generateStatelessWithQuotaRetry(prompt, timeoutMs);
508
+ } catch (error) {
509
+ if (isQuotaError(error)) {
510
+ return null;
511
+ }
512
+ throw error;
513
+ }
514
+ }
515
+
516
+ // ── Chunked strategy — tree-walker ───────────────────────────────────────────
517
+ //
518
+ // Walks the schema recursively choosing the optimal call granularity:
519
+ //
520
+ // • Leaf (string/number/bool/enum) → 1 call with coercion
521
+ // • Object/array whose schema fits in MAX_COMPACT_SCHEMA_CHARS → 1 walkCompact
522
+ // call (with continuation if truncated)
523
+ // • Array of compact elements → askCount + 1 walkCompact per element
524
+ // • Complex object → per-field walkLeaf/walkCompact
525
+ //
526
+ // This minimises the total number of calls without exceeding the token limit.
527
+
528
+ const INTER_CALL_DELAY_MS = 100;
529
+ const DEFAULT_ARRAY_COUNT = 5;
530
+ const MAX_ARRAY_COUNT = 7;
531
+ const MAX_WHOLE_ARRAY_COMPACT_ITEMS = 2;
532
+ const LEAF_TIMEOUT_CAP_MS = 15000;
533
+ const COMPACT_TIMEOUT_CAP_MS = 30000;
534
+ const ARRAY_COUNT_TIMEOUT_CAP_MS = 8000;
535
+ const LEAF_MAX_RETRIES_CAP = 1;
536
+ // Schema description length threshold below which a single compact call is used.
537
+ const MAX_COMPACT_SCHEMA_CHARS = 500;
538
+
539
+ interface WalkContext {
540
+ taskPrompt: string;
541
+ input: unknown;
542
+ timeoutMs: number;
543
+ maxRetries: number;
544
+ /** Current JSON path for model context, e.g. "days.0.exercises" */
545
+ path: string[];
546
+ /** Extra context hint when iterating an array element */
547
+ itemHint?: string;
548
+ /** Label for onProgress — overrides the computed path when set */
549
+ progressLabel?: string;
550
+ onProgress?: (field: string, done: boolean) => void;
551
+ }
552
+
553
+ interface ArrayCountBounds {
554
+ min: number;
555
+ max: number;
556
+ preferred: number;
557
+ }
558
+
559
+ function readZodLengthValue(value: unknown): number | null {
560
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
561
+ if (typeof value === 'object' && value !== null) {
562
+ const maybe = (value as { value?: unknown }).value;
563
+ if (typeof maybe === 'number' && Number.isFinite(maybe)) return maybe;
564
+ }
565
+ return null;
566
+ }
567
+
568
+ function getArrayBounds(
569
+ arraySchema: z.ZodArray<ZodTypeAny>,
570
+ fieldName: string
571
+ ): ArrayCountBounds {
572
+ const def = (arraySchema as unknown as { _def?: Record<string, unknown> })
573
+ ._def;
574
+ const exact = readZodLengthValue(def?.exactLength);
575
+ const minLen = readZodLengthValue(def?.minLength);
576
+ const maxLen = readZodLengthValue(def?.maxLength);
577
+
578
+ if (exact !== null) {
579
+ const fixed = Math.max(0, Math.round(exact));
580
+ return { min: fixed, max: fixed, preferred: fixed };
581
+ }
582
+
583
+ let min = minLen !== null ? Math.max(0, Math.round(minLen)) : 1;
584
+ let max =
585
+ maxLen !== null ? Math.max(min, Math.round(maxLen)) : MAX_ARRAY_COUNT;
586
+
587
+ // Domain heuristics when schema does not constrain lengths explicitly.
588
+ const name = fieldName.toLowerCase();
589
+ if (name === 'days') {
590
+ min = Math.max(min, 7);
591
+ max = Math.min(max, 7);
592
+ }
593
+ if (name === 'exercises') {
594
+ min = Math.max(min, 3);
595
+ max = Math.min(max, 4);
596
+ }
597
+
598
+ const preferred = Math.max(min, Math.min(max, DEFAULT_ARRAY_COUNT));
599
+ return { min, max, preferred };
600
+ }
601
+
602
+ function unwrapModifiers(schema: ZodTypeAny): ZodTypeAny {
603
+ if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
604
+ return unwrapModifiers(schema.unwrap() as ZodTypeAny);
605
+ }
606
+ return schema;
607
+ }
608
+
609
+ function isLeafSchema(schema: ZodTypeAny): boolean {
610
+ const inner = unwrapModifiers(schema);
611
+ return (
612
+ inner instanceof z.ZodString ||
613
+ inner instanceof z.ZodNumber ||
614
+ inner instanceof z.ZodBoolean ||
615
+ inner instanceof z.ZodEnum ||
616
+ inner instanceof z.ZodLiteral ||
617
+ inner instanceof z.ZodNull
618
+ );
619
+ }
620
+
621
+ // A schema is "compact" if its description fits within MAX_COMPACT_SCHEMA_CHARS.
622
+ // Arrays are always excluded because their output can multiply by N.
623
+ function isCompactSchema(schema: ZodTypeAny): boolean {
624
+ const inner = unwrapModifiers(schema);
625
+ if (isLeafSchema(inner)) return true;
626
+ if (inner instanceof z.ZodArray) return false;
627
+ if (hasNestedArray(inner)) return false;
628
+ return zodTypeToDescription(inner).length <= MAX_COMPACT_SCHEMA_CHARS;
629
+ }
630
+
631
+ // Returns true if each array element can be generated in a single compact call.
632
+ function isCompactElement(schema: ZodTypeAny): boolean {
633
+ const inner = unwrapModifiers(schema);
634
+ if (isLeafSchema(inner)) return true;
635
+ if (hasNestedArray(inner)) return false;
636
+ return zodTypeToDescription(inner).length <= MAX_COMPACT_SCHEMA_CHARS;
637
+ }
638
+
639
+ // Returns true if the schema contains arrays at any depth.
640
+ // Such schemas are never compacted to avoid exceeding the 256-token output limit.
641
+ function hasNestedArray(schema: ZodTypeAny): boolean {
642
+ const inner = unwrapModifiers(schema);
643
+
644
+ if (inner instanceof z.ZodArray) return true;
645
+
646
+ if (inner instanceof z.ZodObject) {
647
+ const shape = (inner as z.ZodObject<z.ZodRawShape>).shape;
648
+ return Object.values(shape).some((value) =>
649
+ hasNestedArray(value as ZodTypeAny)
650
+ );
651
+ }
652
+
653
+ if (inner instanceof z.ZodUnion) {
654
+ return inner.options.some((option: unknown) =>
655
+ hasNestedArray(option as unknown as ZodTypeAny)
656
+ );
657
+ }
658
+
659
+ return false;
660
+ }
661
+
662
+ function isStringSchema(schema: ZodTypeAny): boolean {
663
+ return unwrapModifiers(schema) instanceof z.ZodString;
664
+ }
665
+
666
+ function isNumberSchema(schema: ZodTypeAny): boolean {
667
+ return unwrapModifiers(schema) instanceof z.ZodNumber;
668
+ }
669
+
670
+ function isBooleanSchema(schema: ZodTypeAny): boolean {
671
+ return unwrapModifiers(schema) instanceof z.ZodBoolean;
672
+ }
673
+
674
+ function getEnumOptions(schema: ZodTypeAny): string[] | null {
675
+ const inner = unwrapModifiers(schema);
676
+ if (inner instanceof z.ZodEnum) {
677
+ return [...inner.options];
678
+ }
679
+ return null;
680
+ }
681
+
682
+ function getLiteralValue(schema: ZodTypeAny): unknown | null {
683
+ const inner = unwrapModifiers(schema);
684
+ if (inner instanceof z.ZodLiteral) {
685
+ return inner.value;
686
+ }
687
+ return null;
688
+ }
689
+
690
+ function synthesizeLeafFallback(schema: ZodTypeAny, path: string[]): unknown {
691
+ const inner = unwrapModifiers(schema);
692
+ const field = (path[path.length - 1] ?? '').toLowerCase();
693
+
694
+ if (inner instanceof z.ZodLiteral) {
695
+ return inner.value;
696
+ }
697
+
698
+ if (inner instanceof z.ZodEnum) {
699
+ // For paths like days.0.day, map index -> weekday to improve coherence.
700
+ if (field === 'day' && path.length >= 2) {
701
+ const maybeIndex = Number.parseInt(path[path.length - 2] ?? '', 10);
702
+ if (Number.isFinite(maybeIndex)) {
703
+ const idx = Math.max(0, Math.min(inner.options.length - 1, maybeIndex));
704
+ return inner.options[idx];
705
+ }
706
+ }
707
+ return inner.options[0] ?? null;
708
+ }
709
+
710
+ if (inner instanceof z.ZodString) {
711
+ if (field === 'goal') return 'Strength + fat loss';
712
+ if (field === 'focus') return 'Strength + conditioning';
713
+ if (field === 'notes') return 'Progressively increase load week to week.';
714
+ if (field === 'reps') return '8-12';
715
+ if (field === 'name') return 'Accessory Lift';
716
+ return 'TBD';
717
+ }
718
+
719
+ if (inner instanceof z.ZodNumber) {
720
+ if (field === 'durationweeks') return 1;
721
+ if (field === 'sets') return 3;
722
+ if (field === 'restseconds') return 90;
723
+ return 0;
724
+ }
725
+
726
+ if (inner instanceof z.ZodBoolean) {
727
+ return false;
728
+ }
729
+
730
+ if (inner instanceof z.ZodNull) {
731
+ return null;
732
+ }
733
+
734
+ return null;
735
+ }
736
+
737
+ function coercePrimitiveField(raw: string, schema: ZodTypeAny): unknown {
738
+ const trimmed = raw
739
+ .trim()
740
+ .replace(/^```[\w]*\n?|```$/g, '')
741
+ .trim();
742
+
743
+ try {
744
+ return JSON.parse(trimmed);
745
+ } catch {
746
+ /* sigue */
747
+ }
748
+
749
+ if (isStringSchema(schema)) return trimmed;
750
+
751
+ if (isNumberSchema(schema)) {
752
+ const n = Number(trimmed.replace(/[^\d.-]/g, ''));
753
+ if (!Number.isNaN(n)) return n;
754
+ }
755
+
756
+ if (isBooleanSchema(schema)) {
757
+ if (/^true$/i.test(trimmed)) return true;
758
+ if (/^false$/i.test(trimmed)) return false;
759
+ }
760
+
761
+ const enumOptions = getEnumOptions(schema);
762
+ if (enumOptions) {
763
+ const normalized = trimmed.replace(/^"|"$/g, '').trim();
764
+ const direct = enumOptions.find((opt) => opt === normalized);
765
+ if (direct) return direct;
766
+ const ci = enumOptions.find(
767
+ (opt) => opt.toLowerCase() === normalized.toLowerCase()
768
+ );
769
+ if (ci) return ci;
770
+ }
771
+
772
+ const literalValue = getLiteralValue(schema);
773
+ if (literalValue !== null) {
774
+ const normalized = trimmed.replace(/^"|"$/g, '').trim();
775
+ if (String(literalValue) === normalized) return literalValue;
776
+ }
777
+
778
+ return null;
779
+ }
780
+
781
+ async function walkLeaf(
782
+ schema: ZodTypeAny,
783
+ ctx: WalkContext
784
+ ): Promise<unknown> {
785
+ const fieldPath = ctx.progressLabel ?? (ctx.path.join('.') || 'root');
786
+ const fieldName = ctx.path[ctx.path.length - 1] ?? 'value';
787
+ ctx.onProgress?.(fieldPath, false);
788
+ const fieldType = truncateEnd(zodTypeToDescription(schema), 200);
789
+ const itemCtx = ctx.itemHint ? `Context: ${ctx.itemHint}.` : '';
790
+ const isStr = isStringSchema(schema);
791
+ const enumOptions = getEnumOptions(schema);
792
+ const literalValue = getLiteralValue(schema);
793
+
794
+ const valueInstruction = enumOptions
795
+ ? `one of: ${enumOptions.join(' | ')}. Reply exact value only, no key.`
796
+ : literalValue !== null
797
+ ? `exact literal value: ${JSON.stringify(literalValue)}.`
798
+ : isStr
799
+ ? 'a plain phrase — no quotes, no JSON'
800
+ : 'raw JSON value only — no key, no markdown';
801
+
802
+ const prompt = fitStructuredPrompt([
803
+ `"${fieldName}" (${fieldType}): output ${valueInstruction}.`,
804
+ `Task: ${truncateEnd(compactWhitespace(ctx.taskPrompt), 250)}`,
805
+ ctx.input !== undefined ? `Input: ${stringifyInput(ctx.input, 150)}` : '',
806
+ itemCtx,
807
+ ]);
808
+
809
+ const leafTimeoutMs = Math.min(ctx.timeoutMs, LEAF_TIMEOUT_CAP_MS);
810
+ const leafMaxRetries = Math.min(ctx.maxRetries, LEAF_MAX_RETRIES_CAP);
811
+ let lastRaw = '';
812
+ let lastErrorMessage = '';
813
+ for (let attempt = 0; attempt <= leafMaxRetries; attempt++) {
814
+ if (attempt > 0) await sleep(INTER_CALL_DELAY_MS);
815
+ try {
816
+ lastRaw = await generateStatelessWithQuotaRetry(prompt, leafTimeoutMs);
817
+ const coerced = coercePrimitiveField(lastRaw, schema);
818
+ const validated = schema.safeParse(coerced);
819
+ if (validated.success) {
820
+ ctx.onProgress?.(fieldPath, true);
821
+ return validated.data;
822
+ }
823
+ } catch (error) {
824
+ lastErrorMessage = toErrorMessage(error);
825
+ }
826
+ }
827
+
828
+ const fallbackFromRaw = isStr ? lastRaw.trim() : null;
829
+ const fallbackSynth = synthesizeLeafFallback(schema, ctx.path);
830
+ const fallback = fallbackFromRaw ?? fallbackSynth;
831
+ const fallbackValidated = schema.safeParse(fallback);
832
+ if (fallbackValidated.success) {
833
+ ctx.onProgress?.(fieldPath, true);
834
+ return fallbackValidated.data;
835
+ }
836
+ throw new StructuredOutputError(
837
+ `Could not generate leaf "${ctx.path.join('.')}"`,
838
+ lastRaw || lastErrorMessage,
839
+ [
840
+ {
841
+ path: ctx.path.join('.'),
842
+ message: (
843
+ lastRaw ||
844
+ lastErrorMessage ||
845
+ 'leaf generation failed'
846
+ ).slice(0, 120),
847
+ },
848
+ ]
849
+ );
850
+ }
851
+
852
+ async function walkCompact(
853
+ schema: ZodTypeAny,
854
+ ctx: WalkContext
855
+ ): Promise<unknown> {
856
+ const fieldPath = ctx.progressLabel ?? (ctx.path.join('.') || 'root');
857
+ const fieldName = ctx.path[ctx.path.length - 1] ?? 'value';
858
+ ctx.onProgress?.(fieldPath, false);
859
+ const typeDesc = truncateEnd(zodTypeToDescription(schema), 400);
860
+ const itemCtx = ctx.itemHint ? `Context: ${ctx.itemHint}.` : '';
861
+
862
+ const prompt = fitStructuredPrompt([
863
+ `"${fieldName}" (${typeDesc}): return JSON value only — no key, no markdown.`,
864
+ `Task: ${truncateEnd(compactWhitespace(ctx.taskPrompt), 250)}`,
865
+ ctx.input !== undefined ? `Input: ${stringifyInput(ctx.input, 150)}` : '',
866
+ itemCtx,
867
+ ]);
868
+
869
+ const compactTimeoutMs = Math.min(ctx.timeoutMs, COMPACT_TIMEOUT_CAP_MS);
870
+ let combined = await generateStatelessWithQuotaRetry(
871
+ prompt,
872
+ compactTimeoutMs
873
+ );
874
+
875
+ for (let cont = 0; cont < 6; cont++) {
876
+ try {
877
+ const result = JSON.parse(extractJsonPayload(combined));
878
+ ctx.onProgress?.(fieldPath, true);
879
+ return result;
880
+ } catch {
881
+ if (!looksLikeIncompleteJson(combined)) break;
882
+ }
883
+ await sleep(INTER_CALL_DELAY_MS);
884
+ const extra = await tryGenerateWithQuotaTolerance(
885
+ buildStructuredContinuationPrompt(
886
+ ctx.taskPrompt,
887
+ schema,
888
+ combined,
889
+ ctx.input
890
+ ),
891
+ compactTimeoutMs
892
+ );
893
+ if (!extra) break;
894
+ const merged = mergeStructuredFragments(combined, extra);
895
+ if (merged === combined) break;
896
+ combined = merged;
897
+ }
898
+
899
+ const finalResult = JSON.parse(extractJsonPayload(combined));
900
+ ctx.onProgress?.(fieldPath, true);
901
+ return finalResult;
902
+ }
903
+
904
+ async function askArrayCount(
905
+ arraySchema: z.ZodArray<ZodTypeAny>,
906
+ fieldName: string,
907
+ ctx: WalkContext
908
+ ): Promise<number> {
909
+ const bounds = getArrayBounds(arraySchema, fieldName);
910
+ const prompt = fitContinuationPrompt([
911
+ `"${fieldName}" array count (${bounds.min}-${bounds.max}). Reply: single integer only.`,
912
+ `Task: ${truncateEnd(compactWhitespace(ctx.taskPrompt), 150)}`,
913
+ ]);
914
+
915
+ try {
916
+ await sleep(INTER_CALL_DELAY_MS);
917
+ const countTimeoutMs = Math.min(ctx.timeoutMs, ARRAY_COUNT_TIMEOUT_CAP_MS);
918
+ const raw = await generateStatelessWithQuotaRetry(prompt, countTimeoutMs);
919
+ const n = parseInt(raw.trim().replace(/\D/g, ''), 10);
920
+ if (Number.isFinite(n) && n >= bounds.min && n <= bounds.max) return n;
921
+ } catch {
922
+ /* usa defecto */
923
+ }
924
+ return bounds.preferred;
925
+ }
926
+
927
+ async function walkSchema(
928
+ schema: ZodTypeAny,
929
+ ctx: WalkContext
930
+ ): Promise<unknown> {
931
+ const inner = unwrapModifiers(schema);
932
+
933
+ if (isLeafSchema(inner)) {
934
+ await sleep(INTER_CALL_DELAY_MS);
935
+ return walkLeaf(inner, ctx);
936
+ }
937
+
938
+ if (inner instanceof z.ZodArray) {
939
+ const elementSchema = unwrapModifiers(inner.element as ZodTypeAny);
940
+ const fieldName = ctx.path[ctx.path.length - 1] ?? 'items';
941
+ const arrayLabel = ctx.path.join('.') || fieldName;
942
+ const count = await askArrayCount(
943
+ inner as z.ZodArray<ZodTypeAny>,
944
+ fieldName,
945
+ ctx
946
+ );
947
+
948
+ // Fast path: if each element fits in a compact call and the array is small,
949
+ // generate the entire array in one walkCompact call instead of per-element.
950
+ if (
951
+ isCompactElement(elementSchema) &&
952
+ count <= MAX_WHOLE_ARRAY_COMPACT_ITEMS
953
+ ) {
954
+ ctx.onProgress?.(arrayLabel, false);
955
+ await sleep(INTER_CALL_DELAY_MS);
956
+ const result = await walkCompact(inner, {
957
+ ...ctx,
958
+ progressLabel: arrayLabel,
959
+ });
960
+ ctx.onProgress?.(arrayLabel, true);
961
+ return result;
962
+ }
963
+
964
+ // Slow path: non-compact elements → generate one by one
965
+ ctx.onProgress?.(arrayLabel, false);
966
+ const items: unknown[] = [];
967
+
968
+ for (let i = 0; i < count; i++) {
969
+ await sleep(INTER_CALL_DELAY_MS);
970
+ const elemLabel = `${arrayLabel}[${i + 1}/${count}]`;
971
+ const elemCtx: WalkContext = {
972
+ ...ctx,
973
+ path: [...ctx.path, String(i)],
974
+ itemHint: `item ${i + 1} of ${count}`,
975
+ progressLabel: elemLabel,
976
+ };
977
+ items.push(await walkSchema(elementSchema, elemCtx));
978
+ }
979
+
980
+ ctx.onProgress?.(arrayLabel, true);
981
+ return items;
982
+ }
983
+
984
+ // Compact object: single call (except at root where per-field gives better progress).
985
+ if (isCompactSchema(inner) && ctx.path.length > 0) {
986
+ await sleep(INTER_CALL_DELAY_MS);
987
+ return walkCompact(inner, ctx);
988
+ }
989
+
990
+ if (inner instanceof z.ZodObject) {
991
+ const shape = (inner as z.ZodObject<z.ZodRawShape>).shape;
992
+ const entries = Object.entries(shape);
993
+ const result: Record<string, unknown> = {};
994
+
995
+ const compactEntries = entries.filter(([, s]) =>
996
+ isCompactSchema(s as ZodTypeAny)
997
+ );
998
+ const complexEntries = entries.filter(
999
+ ([, s]) => !isCompactSchema(s as ZodTypeAny)
1000
+ );
1001
+
1002
+ if (compactEntries.length > 0) {
1003
+ for (const [key, fieldSchema] of compactEntries) {
1004
+ const fieldCtx: WalkContext = {
1005
+ ...ctx,
1006
+ path: [...ctx.path, key],
1007
+ progressLabel: [...ctx.path, key].join('.'),
1008
+ };
1009
+ if (isLeafSchema(unwrapModifiers(fieldSchema as ZodTypeAny))) {
1010
+ await sleep(INTER_CALL_DELAY_MS);
1011
+ result[key] = await walkLeaf(fieldSchema as ZodTypeAny, fieldCtx);
1012
+ } else {
1013
+ await sleep(INTER_CALL_DELAY_MS);
1014
+ result[key] = await walkCompact(fieldSchema as ZodTypeAny, fieldCtx);
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ for (const [key, fieldSchema] of complexEntries) {
1020
+ result[key] = await walkSchema(fieldSchema as ZodTypeAny, {
1021
+ ...ctx,
1022
+ path: [...ctx.path, key],
1023
+ });
1024
+ }
1025
+
1026
+ return result;
1027
+ }
1028
+
1029
+ await sleep(INTER_CALL_DELAY_MS);
1030
+ return walkLeaf(inner, ctx);
1031
+ }
1032
+
1033
+ async function generateChunked<TOutputSchema extends ZodTypeAny, TInput>(
1034
+ prompt: string,
1035
+ schema: TOutputSchema,
1036
+ input: TInput | undefined,
1037
+ timeoutMs: number,
1038
+ maxRetries: number,
1039
+ onProgress?: (field: string, done: boolean) => void
1040
+ ): Promise<unknown> {
1041
+ return walkSchema(schema, {
1042
+ taskPrompt: prompt,
1043
+ input,
1044
+ timeoutMs,
1045
+ maxRetries,
1046
+ path: [],
1047
+ onProgress,
1048
+ });
1049
+ }
1050
+
1051
+ export async function generateStructuredResponse<
1052
+ TOutputSchema extends ZodTypeAny,
1053
+ TInput = unknown,
1054
+ >(
1055
+ options: StructuredGenerateOptions<TOutputSchema, TInput>
1056
+ ): Promise<z.infer<TOutputSchema>> {
1057
+ const {
1058
+ prompt,
1059
+ output,
1060
+ input,
1061
+ inputSchema,
1062
+ maxRetries = 2,
1063
+ maxContinuations = DEFAULT_MAX_STRUCTURED_CONTINUATIONS,
1064
+ timeoutMs = DEFAULT_STRUCTURED_TIMEOUT_MS,
1065
+ strategy = 'single',
1066
+ onProgress,
1067
+ } = options;
1068
+
1069
+ if (inputSchema && input !== undefined) {
1070
+ inputSchema.parse(input);
1071
+ }
1072
+
1073
+ if (strategy === 'chunked') {
1074
+ const assembled = await generateChunked(
1075
+ prompt,
1076
+ output,
1077
+ input,
1078
+ timeoutMs,
1079
+ maxRetries,
1080
+ onProgress
1081
+ );
1082
+ return output.parse(assembled);
1083
+ }
1084
+
1085
+ let lastRawResponse = '';
1086
+ let lastIssues: StructuredValidationIssue[] = [];
1087
+
1088
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1089
+ lastRawResponse =
1090
+ attempt === 0
1091
+ ? await generateStructuredRawResponse(
1092
+ prompt,
1093
+ output,
1094
+ input,
1095
+ maxContinuations,
1096
+ timeoutMs
1097
+ )
1098
+ : await generateStatelessWithQuotaRetry(
1099
+ buildRepairPrompt(prompt, output, lastRawResponse, lastIssues),
1100
+ timeoutMs
1101
+ );
1102
+
1103
+ try {
1104
+ const parsed = JSON.parse(extractJsonPayload(lastRawResponse));
1105
+ return output.parse(parsed);
1106
+ } catch (error) {
1107
+ lastIssues = buildJsonParseIssues(error);
1108
+
1109
+ if (attempt === maxRetries) break;
1110
+ }
1111
+ }
1112
+
1113
+ throw new StructuredOutputError(
1114
+ 'Unable to produce valid structured output after retries.',
1115
+ lastRawResponse,
1116
+ lastIssues
1117
+ );
1118
+ }