rogasper commited on
Commit
df94b74
·
1 Parent(s): 62fdec8

feat: improve JSON parsing and error handling by introducing isLikelyTruncatedJson function to detect incomplete JSON responses. Update client logic to handle truncation retries more effectively and enhance error messaging for truncated responses. Add tests to validate new functionality and ensure robust error reporting.

Browse files
packages/ai/src/__tests__/parse-response.test.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { describe, expect, it } from "bun:test";
2
  import {
3
  extractContentFromCompletionBody,
 
4
  parseAiJsonResponse,
5
  stripReasoningBlocks,
6
  } from "../parse-response";
@@ -47,6 +48,13 @@ describe("parseAiJsonResponse", () => {
47
  "Empty response from AI",
48
  );
49
  });
 
 
 
 
 
 
 
50
  });
51
 
52
  describe("extractContentFromCompletionBody", () => {
 
1
  import { describe, expect, it } from "bun:test";
2
  import {
3
  extractContentFromCompletionBody,
4
+ isLikelyTruncatedJson,
5
  parseAiJsonResponse,
6
  stripReasoningBlocks,
7
  } from "../parse-response";
 
48
  "Empty response from AI",
49
  );
50
  });
51
+
52
+ it("detects truncated JSON and throws a clear truncation error", () => {
53
+ const truncated =
54
+ '{"title":"The Global Rise of Coffee Culture","passage":"Coffee has a rich history in the highl';
55
+ expect(isLikelyTruncatedJson(truncated)).toBe(true);
56
+ expect(() => parseAiJsonResponse(truncated)).toThrow("JSON response was truncated");
57
+ });
58
  });
59
 
60
  describe("extractContentFromCompletionBody", () => {
packages/ai/src/agentic.ts CHANGED
@@ -53,7 +53,7 @@ function calculateMaxTokens(
53
  const base = userMax > 0 ? userMax : 16_384;
54
  switch (step) {
55
  case "passage":
56
- return Math.min(base, 8_192);
57
  case "validate":
58
  return Math.min(base, 4_096);
59
  case "self_validate":
 
53
  const base = userMax > 0 ? userMax : 16_384;
54
  switch (step) {
55
  case "passage":
56
+ return Math.min(Math.max(base, 6_000), 16_384);
57
  case "validate":
58
  return Math.min(base, 4_096);
59
  case "self_validate":
packages/ai/src/client.ts CHANGED
@@ -1,5 +1,6 @@
1
  import {
2
  extractContentFromCompletionBody,
 
3
  looksLikeHtmlErrorPage,
4
  stripReasoningBlocks,
5
  } from "./parse-response";
@@ -117,22 +118,7 @@ async function readSSEStream(
117
  return { content: fullContent, usage: lastUsage, rawBody };
118
  }
119
 
120
- function looksTruncated(content: string): boolean {
121
- const trimmed = content.trim();
122
- if (trimmed.length === 0) return false;
123
- // JSON object/array should end with } or ]
124
- const lastChar = trimmed[trimmed.length - 1];
125
- if (lastChar === "}" || lastChar === "]") return false;
126
- // Check for common truncation signatures
127
- const unterminated = /Unterminated string|Unexpected end of JSON|Unexpected (token|EOF)|Expected ('.*'|".*")/i;
128
- try {
129
- JSON.parse(trimmed);
130
- return false;
131
- } catch (err: any) {
132
- if (unterminated.test(err.message)) return true;
133
- }
134
- return false;
135
- }
136
 
137
  function isResponseFormatError(status: number, text: string): boolean {
138
  if (status !== 400 && status !== 422) return false;
@@ -186,7 +172,11 @@ export class OpenAICompatibleClient {
186
  private async _doChatCompletion(
187
  opts: ChatCompletionOptions,
188
  callbacks: StreamCallbacks | undefined,
189
- ctx: { attempt: number; retriedForTruncation?: boolean; retriedForResponseFormat?: boolean },
 
 
 
 
190
  ): Promise<ChatCompletionResult> {
191
  let hostname: string;
192
  try {
@@ -299,20 +289,21 @@ export class OpenAICompatibleClient {
299
  throw new Error("Empty response from AI");
300
  }
301
 
302
- // Truncation detection + retry
303
- if (!ctx.retriedForTruncation && looksTruncated(result.content)) {
304
- const newMaxTokens = opts.max_tokens
305
- ? Math.min(Math.round(opts.max_tokens * 1.5), 128_000)
306
- : 16_384;
307
  log("warn", "Response looks truncated, retrying with more tokens", {
308
  originalLength: result.content.length,
309
  originalMaxTokens: opts.max_tokens,
310
  newMaxTokens,
 
311
  });
312
  return this._doChatCompletion(
313
  { ...opts, max_tokens: newMaxTokens },
314
  callbacks,
315
- { ...ctx, attempt: ctx.attempt + 1, retriedForTruncation: true },
316
  );
317
  }
318
 
 
1
  import {
2
  extractContentFromCompletionBody,
3
+ isLikelyTruncatedJson,
4
  looksLikeHtmlErrorPage,
5
  stripReasoningBlocks,
6
  } from "./parse-response";
 
118
  return { content: fullContent, usage: lastUsage, rawBody };
119
  }
120
 
121
+ const MAX_TRUNCATION_RETRIES = 2;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
  function isResponseFormatError(status: number, text: string): boolean {
124
  if (status !== 400 && status !== 422) return false;
 
172
  private async _doChatCompletion(
173
  opts: ChatCompletionOptions,
174
  callbacks: StreamCallbacks | undefined,
175
+ ctx: {
176
+ attempt: number;
177
+ truncationRetries?: number;
178
+ retriedForResponseFormat?: boolean;
179
+ },
180
  ): Promise<ChatCompletionResult> {
181
  let hostname: string;
182
  try {
 
289
  throw new Error("Empty response from AI");
290
  }
291
 
292
+ // Truncation detection + retry (incomplete JSON mid-stream)
293
+ const truncationRetries = ctx.truncationRetries ?? 0;
294
+ if (truncationRetries < MAX_TRUNCATION_RETRIES && isLikelyTruncatedJson(result.content)) {
295
+ const baseTokens = opts.max_tokens && opts.max_tokens > 0 ? opts.max_tokens : 8_192;
296
+ const newMaxTokens = Math.min(Math.round(baseTokens * 1.75), 128_000);
297
  log("warn", "Response looks truncated, retrying with more tokens", {
298
  originalLength: result.content.length,
299
  originalMaxTokens: opts.max_tokens,
300
  newMaxTokens,
301
+ truncationRetry: truncationRetries + 1,
302
  });
303
  return this._doChatCompletion(
304
  { ...opts, max_tokens: newMaxTokens },
305
  callbacks,
306
+ { ...ctx, attempt: ctx.attempt + 1, truncationRetries: truncationRetries + 1 },
307
  );
308
  }
309
 
packages/ai/src/parse-response.ts CHANGED
@@ -29,6 +29,36 @@ export function stripReasoningBlocks(content: string): string {
29
  return text.trim();
30
  }
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  function extractJsonCandidate(content: string): string {
33
  const trimmed = content.trim();
34
  const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
@@ -78,11 +108,19 @@ export function parseAiJsonResponse(content: string): unknown {
78
  }
79
  }
80
 
 
 
 
 
 
 
81
  throw new Error(
82
  `Failed to parse AI response as JSON: ${lastError?.message ?? "unknown error"}. Preview: ${normalized.slice(0, 200)}`,
83
  );
84
  }
85
 
 
 
86
  export function extractContentFromCompletionBody(body: string): string | null {
87
  const trimmed = body.trim();
88
  if (!trimmed.startsWith("{")) return null;
 
29
  return text.trim();
30
  }
31
 
32
+ const TRUNCATED_JSON_ERROR = "JSON response was truncated";
33
+
34
+ /** Detect incomplete JSON — valid start but stream/token limit cut off before closing braces. */
35
+ export function isLikelyTruncatedJson(content: string): boolean {
36
+ const trimmed = stripReasoningBlocks(content).trim();
37
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
38
+
39
+ if (trimmed.endsWith("}") || trimmed.endsWith("]")) {
40
+ try {
41
+ JSON.parse(trimmed);
42
+ return false;
43
+ } catch (err: unknown) {
44
+ const msg = err instanceof Error ? err.message : String(err);
45
+ return /unterminated string|unexpected eof|unexpected end|expected .* at end/i.test(msg);
46
+ }
47
+ }
48
+
49
+ try {
50
+ JSON.parse(trimmed);
51
+ return false;
52
+ } catch (err: unknown) {
53
+ const msg = err instanceof Error ? err.message : String(err);
54
+ if (/unterminated string|unexpected eof|unexpected end|expected .* at end/i.test(msg)) {
55
+ return true;
56
+ }
57
+ }
58
+
59
+ return true;
60
+ }
61
+
62
  function extractJsonCandidate(content: string): string {
63
  const trimmed = content.trim();
64
  const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
 
108
  }
109
  }
110
 
111
+ if (isLikelyTruncatedJson(normalized)) {
112
+ throw new Error(
113
+ `${TRUNCATED_JSON_ERROR} before completion (usually max_tokens too low). Preview: ${normalized.slice(0, 200)}`,
114
+ );
115
+ }
116
+
117
  throw new Error(
118
  `Failed to parse AI response as JSON: ${lastError?.message ?? "unknown error"}. Preview: ${normalized.slice(0, 200)}`,
119
  );
120
  }
121
 
122
+ export { TRUNCATED_JSON_ERROR };
123
+
124
  export function extractContentFromCompletionBody(body: string): string | null {
125
  const trimmed = body.trim();
126
  if (!trimmed.startsWith("{")) return null;
packages/api/src/__tests__/generation-outcome.test.ts CHANGED
@@ -68,6 +68,15 @@ describe("resolveGenerationJobOutcome", () => {
68
  expect(outcome.errorMessage).not.toContain("Periksa model/API provider");
69
  });
70
 
 
 
 
 
 
 
 
 
 
71
  it("surfaces JSON parse cause instead of generic provider hint", () => {
72
  const outcome = resolveGenerationJobOutcome({
73
  requestedCount: 5,
 
68
  expect(outcome.errorMessage).not.toContain("Periksa model/API provider");
69
  });
70
 
71
+ it("labels truncated JSON with actionable guidance", () => {
72
+ const message = formatGenerationErrorMessage({
73
+ cause: "JSON response was truncated before completion (usually max_tokens too low). Preview: {\"title\":",
74
+ failedSections: ["READING"],
75
+ });
76
+ expect(message).toContain("terpotong");
77
+ expect(message).toContain("Max Tokens");
78
+ });
79
+
80
  it("surfaces JSON parse cause instead of generic provider hint", () => {
81
  const outcome = resolveGenerationJobOutcome({
82
  requestedCount: 5,
packages/api/src/lib/generation-outcome.ts CHANGED
@@ -46,6 +46,13 @@ export function formatGenerationErrorMessage(input: {
46
  return `Model reasoning tidak kompatibel dengan parser Labas.${hint} Coba non-reasoning model, atau update server ke versi terbaru.`;
47
  }
48
 
 
 
 
 
 
 
 
49
  if (lower.includes("failed to parse ai response") || lower.includes("invalid json response")) {
50
  return `Model merespons, tapi output tidak valid JSON.${hint} ${truncate(cause, 280)}`;
51
  }
 
46
  return `Model reasoning tidak kompatibel dengan parser Labas.${hint} Coba non-reasoning model, atau update server ke versi terbaru.`;
47
  }
48
 
49
+ if (
50
+ lower.includes("json response was truncated") ||
51
+ lower.includes("unterminated string")
52
+ ) {
53
+ return `Respons model terpotong sebelum JSON selesai (biasanya max tokens terlalu kecil).${hint} Naikkan Max Tokens di Settings atau kurangi jumlah soal/format.`;
54
+ }
55
+
56
  if (lower.includes("failed to parse ai response") || lower.includes("invalid json response")) {
57
  return `Model merespons, tapi output tidak valid JSON.${hint} ${truncate(cause, 280)}`;
58
  }