All files / services/ai claude-json.ts

100% Statements 50/50
100% Branches 30/30
100% Functions 2/2
100% Lines 49/49

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118                                  22x     22x 22x   22x 1x       21x 21x   21x 21x 9x     12x 12x 8x       4x                       22x 22x 22x   22x 1580x     1580x 238x 238x 238x       1342x 2x 2x 2x     1340x 2x 2x 2x       1338x 973x   1x 1x   1x 1x   1x 1x   1x 1x   1x 1x     968x 968x 1x   967x           365x       22x    
// JSON extraction + sanitization for Claude responses.
//
// Claude often wraps JSON in markdown code fences and occasionally emits
// unescaped control characters inside string values, both of which break a
// naive `JSON.parse(text)`. These helpers normalize the model output before
// parsing so callers don't have to repeat the same string-handling per
// endpoint.
 
/**
 * Extract JSON from Claude API response that may be wrapped in markdown code blocks.
 * Handles formats like:
 * - ```json\n{...}\n```
 * - ```\n{...}\n```
 * - Plain JSON: {...}
 * - Text before/after JSON: "Here's the response:\n{...}\n"
 */
export function extractJSON(text: string): string {
	const trimmed = text.trim();
 
	// Try to extract from markdown code blocks first
	const codeBlockPattern = /```(?:json)?\s*\n?([\s\S]*?)\n?```/;
	const codeBlockMatch = trimmed.match(codeBlockPattern);
 
	if (codeBlockMatch?.[1]) {
		return sanitizeJSON(codeBlockMatch[1].trim());
	}
 
	// Try to find JSON array or object in the text
	const jsonArrayPattern = /(\[[\s\S]*\])/;
	const jsonObjectPattern = /(\{[\s\S]*\})/;
 
	const arrayMatch = trimmed.match(jsonArrayPattern);
	if (arrayMatch?.[1]) {
		return sanitizeJSON(arrayMatch[1].trim());
	}
 
	const objectMatch = trimmed.match(jsonObjectPattern);
	if (objectMatch?.[1]) {
		return sanitizeJSON(objectMatch[1].trim());
	}
 
	// Fall back to trimmed original text
	return sanitizeJSON(trimmed);
}
 
/**
 * Sanitize JSON string by escaping control characters within string values.
 * Fixes cases where Claude returns JSON with unescaped newlines/tabs inside
 * string values, which would otherwise break JSON.parse().
 *
 * Strategy: replace literal control characters with their escaped versions,
 * but only within quoted strings (not in structural JSON).
 */
export function sanitizeJSON(jsonText: string): string {
	let result = "";
	let inString = false;
	let escaped = false;
 
	for (let i = 0; i < jsonText.length; i++) {
		const char = jsonText[i];
 
		// Track if we're inside a string
		if (char === '"' && !escaped) {
			inString = !inString;
			result += char;
			continue;
		}
 
		// Track escape sequences
		if (char === "\\" && !escaped) {
			escaped = true;
			result += char;
			continue;
		}
 
		if (escaped) {
			escaped = false;
			result += char;
			continue;
		}
 
		// If we're inside a string, escape control characters
		if (inString) {
			switch (char) {
				case "\n":
					result += "\\n";
					break;
				case "\r":
					result += "\\r";
					break;
				case "\t":
					result += "\\t";
					break;
				case "\b":
					result += "\\b";
					break;
				case "\f":
					result += "\\f";
					break;
				default: {
					// Other control characters (ASCII 0-31)
					const code = char.charCodeAt(0);
					if (code < 32 && code !== 10 && code !== 13 && code !== 9) {
						result += `\\u${code.toString(16).padStart(4, "0")}`;
					} else {
						result += char;
					}
				}
			}
		} else {
			// Outside strings, preserve as-is
			result += char;
		}
	}
 
	return result;
}