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,
|
| 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 |
-
|
| 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: {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 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,
|
| 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 |
}
|