File size: 4,512 Bytes
ff49634
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df94b74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ff49634
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df94b74
 
 
 
 
 
ff49634
 
 
 
 
df94b74
 
ff49634
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/**
 * Normalize model output before JSON parsing.
 * Reasoning models (GLM, DeepSeek, etc.) often wrap chain-of-thought in tags
 * that are NOT HTML error pages.
 */

const THINK_TAG = "think";

const REASONING_BLOCK_PATTERNS: RegExp[] = [
  /<think>[\s\S]*?<\/redacted_thinking>/gi,
  /<thinking>[\s\S]*?<\/thinking>/gi,
  new RegExp(`<${THINK_TAG}>[\\s\\S]*?</${THINK_TAG}>`, "gi"),
];

const UNCLOSED_REASONING_PREFIXES = [
  /^<think>[\s\S]*/i,
  /^<thinking>[\s\S]*/i,
  new RegExp(`^<${THINK_TAG}>[\\s\\S]*`, "i"),
];

export function stripReasoningBlocks(content: string): string {
  let text = content;
  for (const pattern of REASONING_BLOCK_PATTERNS) {
    text = text.replace(pattern, "");
  }
  for (const pattern of UNCLOSED_REASONING_PREFIXES) {
    text = text.replace(pattern, "");
  }
  return text.trim();
}

const TRUNCATED_JSON_ERROR = "JSON response was truncated";

/** Detect incomplete JSON — valid start but stream/token limit cut off before closing braces. */
export function isLikelyTruncatedJson(content: string): boolean {
  const trimmed = stripReasoningBlocks(content).trim();
  if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;

  if (trimmed.endsWith("}") || trimmed.endsWith("]")) {
    try {
      JSON.parse(trimmed);
      return false;
    } catch (err: unknown) {
      const msg = err instanceof Error ? err.message : String(err);
      return /unterminated string|unexpected eof|unexpected end|expected .* at end/i.test(msg);
    }
  }

  try {
    JSON.parse(trimmed);
    return false;
  } catch (err: unknown) {
    const msg = err instanceof Error ? err.message : String(err);
    if (/unterminated string|unexpected eof|unexpected end|expected .* at end/i.test(msg)) {
      return true;
    }
  }

  return true;
}

function extractJsonCandidate(content: string): string {
  const trimmed = content.trim();
  const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
  if (fenced?.[1]) return fenced[1].trim();

  const objIdx = trimmed.indexOf("{");
  const arrIdx = trimmed.indexOf("[");
  let start = -1;
  if (objIdx === -1) start = arrIdx;
  else if (arrIdx === -1) start = objIdx;
  else start = Math.min(objIdx, arrIdx);

  if (start < 0) return trimmed;

  const slice = trimmed.slice(start);
  const lastBrace = slice.lastIndexOf("}");
  const lastBracket = slice.lastIndexOf("]");
  const end = Math.max(lastBrace, lastBracket);
  if (end === -1) return slice;
  return slice.slice(0, end + 1);
}

export function parseAiJsonResponse(content: string): unknown {
  const normalized = stripReasoningBlocks(content);
  if (!normalized) throw new Error("Empty response from AI");

  const candidates = [normalized, extractJsonCandidate(normalized)];
  const uniqueCandidates = [...new Set(candidates.filter(Boolean))];

  let lastError: Error | undefined;
  for (const candidate of uniqueCandidates) {
    try {
      return JSON.parse(candidate);
    } catch (err: unknown) {
      lastError = err instanceof Error ? err : new Error(String(err));
      const markdownCleaned = candidate
        .replace(/^```json\s*/i, "")
        .replace(/```\s*$/, "")
        .trim();
      if (markdownCleaned !== candidate) {
        try {
          return JSON.parse(markdownCleaned);
        } catch (inner: unknown) {
          lastError = inner instanceof Error ? inner : new Error(String(inner));
        }
      }
    }
  }

  if (isLikelyTruncatedJson(normalized)) {
    throw new Error(
      `${TRUNCATED_JSON_ERROR} before completion (usually max_tokens too low). Preview: ${normalized.slice(0, 200)}`,
    );
  }

  throw new Error(
    `Failed to parse AI response as JSON: ${lastError?.message ?? "unknown error"}. Preview: ${normalized.slice(0, 200)}`,
  );
}

export { TRUNCATED_JSON_ERROR };

export function extractContentFromCompletionBody(body: string): string | null {
  const trimmed = body.trim();
  if (!trimmed.startsWith("{")) return null;
  try {
    const json = JSON.parse(trimmed) as {
      choices?: Array<{ message?: { content?: string } }>;
    };
    const content = json.choices?.[0]?.message?.content;
    return typeof content === "string" ? content : null;
  } catch {
    return null;
  }
}

export function looksLikeHtmlErrorPage(content: string): boolean {
  const head = content.trim().slice(0, 300).toLowerCase();
  return (
    head.startsWith("<!doctype") ||
    head.startsWith("<html") ||
    head.startsWith("<head") ||
    head.startsWith("<body") ||
    /^<\?(xml|php)/.test(head)
  );
}