rogasper commited on
Commit
ff49634
·
1 Parent(s): ef46e44

feat: refactor AI response handling by introducing parseAiJsonResponse and related utilities to improve JSON parsing and error handling. Streamline response processing in client and pipeline modules, enhancing robustness against HTML error pages and reasoning blocks. Add tests for new parsing functions to ensure reliability.

Browse files
packages/ai/src/__tests__/client.test.ts CHANGED
@@ -5,6 +5,24 @@ const BASE_URL = "https://api.openai.com/v1";
5
  const API_KEY = "sk-test-key-12345";
6
  const MODEL = "gpt-4";
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  function makeMockStream(chunks: string[]): ReadableStream {
9
  const encoder = new TextEncoder();
10
  return new ReadableStream({
@@ -17,16 +35,17 @@ function makeMockStream(chunks: string[]): ReadableStream {
17
  });
18
  }
19
 
20
- function makeFetchMock(streamChunks: string[], status = 200) {
21
- return async () =>
22
  new Response(makeMockStream(streamChunks), {
23
  status,
24
  headers: { "content-type": "text/event-stream" },
25
- });
 
26
  }
27
 
28
  describe("OpenAICompatibleClient", () => {
29
- let originalFetch: typeof globalThis.fetch;
30
 
31
  beforeAll(() => {
32
  originalFetch = globalThis.fetch;
@@ -87,8 +106,9 @@ describe("OpenAICompatibleClient", () => {
87
  });
88
 
89
  it("throws on non-OK response", async () => {
90
- globalThis.fetch = async () =>
91
- new Response("Bad Request", { status: 400, headers: { "content-type": "text/plain" } });
 
92
 
93
  const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
94
  expect(
@@ -100,8 +120,7 @@ describe("OpenAICompatibleClient", () => {
100
  });
101
 
102
  it("throws on empty response body", async () => {
103
- globalThis.fetch = async () =>
104
- new Response(null, { status: 200 });
105
 
106
  const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
107
  expect(
@@ -134,17 +153,17 @@ describe("OpenAICompatibleClient", () => {
134
 
135
  it("retries without response_format on 400 with response_format error", async () => {
136
  let callCount = 0;
137
- globalThis.fetch = async (_url: string, opts: any) => {
138
  callCount++;
139
  if (callCount === 1) {
140
- const body = JSON.parse(opts.body);
141
  expect(body.response_format).toBeDefined();
142
  return new Response("response_format is not supported", {
143
  status: 400,
144
  headers: { "content-type": "text/plain" },
145
  });
146
  }
147
- const body = JSON.parse(opts.body);
148
  expect(body.response_format).toBeUndefined();
149
  return new Response(makeMockStream([
150
  `data: ${JSON.stringify({ choices: [{ delta: { content: "retried" } }] })}\n`,
@@ -153,7 +172,7 @@ describe("OpenAICompatibleClient", () => {
153
  status: 200,
154
  headers: { "content-type": "text/event-stream" },
155
  });
156
- };
157
 
158
  const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
159
  const result = await client.chatCompletion({
@@ -168,7 +187,7 @@ describe("OpenAICompatibleClient", () => {
168
 
169
  it("retries with more tokens on truncated response", async () => {
170
  let callCount = 0;
171
- globalThis.fetch = async (_url: string, opts: any) => {
172
  callCount++;
173
  const responseContent = callCount === 1
174
  ? `data: ${JSON.stringify({ choices: [{ delta: { content: '{"incomplete":' } }] })}\n` + "data: [DONE]\n"
@@ -178,7 +197,7 @@ describe("OpenAICompatibleClient", () => {
178
  status: 200,
179
  headers: { "content-type": "text/event-stream" },
180
  });
181
- };
182
 
183
  const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
184
  const result = await client.chatCompletion({
@@ -191,15 +210,49 @@ describe("OpenAICompatibleClient", () => {
191
  expect(result.content).toBe('{"complete": true}');
192
  });
193
 
194
- it("throws on HTML response in stream", async () => {
195
- globalThis.fetch = async () =>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  new Response(makeMockStream([
197
- `data: ${JSON.stringify({ choices: [{ delta: { content: "<html>Not JSON</html>" } }] })}\n`,
198
  "data: [DONE]\n",
199
  ]), {
200
  status: 200,
201
  headers: { "content-type": "text/event-stream" },
202
- });
 
203
 
204
  const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
205
  expect(
@@ -209,4 +262,24 @@ describe("OpenAICompatibleClient", () => {
209
  }),
210
  ).rejects.toThrow("HTML");
211
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  });
 
5
  const API_KEY = "sk-test-key-12345";
6
  const MODEL = "gpt-4";
7
 
8
+ type FetchFn = typeof globalThis.fetch;
9
+
10
+ /** Bun/TS types fetch as an object with static helpers (e.g. preconnect), not just a function. */
11
+ function asFetchMock(fn: (...args: Parameters<FetchFn>) => ReturnType<FetchFn>): FetchFn {
12
+ return fn as FetchFn;
13
+ }
14
+
15
+ function setMockFetch(fn: (...args: Parameters<FetchFn>) => ReturnType<FetchFn>): void {
16
+ globalThis.fetch = asFetchMock(fn);
17
+ }
18
+
19
+ function parseRequestBody(opts?: RequestInit): Record<string, unknown> {
20
+ if (opts?.body == null) {
21
+ throw new Error("Expected request body in mock fetch");
22
+ }
23
+ return JSON.parse(String(opts.body));
24
+ }
25
+
26
  function makeMockStream(chunks: string[]): ReadableStream {
27
  const encoder = new TextEncoder();
28
  return new ReadableStream({
 
35
  });
36
  }
37
 
38
+ function makeFetchMock(streamChunks: string[], status = 200): FetchFn {
39
+ return asFetchMock(async () =>
40
  new Response(makeMockStream(streamChunks), {
41
  status,
42
  headers: { "content-type": "text/event-stream" },
43
+ }),
44
+ );
45
  }
46
 
47
  describe("OpenAICompatibleClient", () => {
48
+ let originalFetch: FetchFn;
49
 
50
  beforeAll(() => {
51
  originalFetch = globalThis.fetch;
 
106
  });
107
 
108
  it("throws on non-OK response", async () => {
109
+ setMockFetch(async () =>
110
+ new Response("Bad Request", { status: 400, headers: { "content-type": "text/plain" } }),
111
+ );
112
 
113
  const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
114
  expect(
 
120
  });
121
 
122
  it("throws on empty response body", async () => {
123
+ setMockFetch(async () => new Response(null, { status: 200 }));
 
124
 
125
  const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
126
  expect(
 
153
 
154
  it("retries without response_format on 400 with response_format error", async () => {
155
  let callCount = 0;
156
+ setMockFetch(async (_input, opts) => {
157
  callCount++;
158
  if (callCount === 1) {
159
+ const body = parseRequestBody(opts);
160
  expect(body.response_format).toBeDefined();
161
  return new Response("response_format is not supported", {
162
  status: 400,
163
  headers: { "content-type": "text/plain" },
164
  });
165
  }
166
+ const body = parseRequestBody(opts);
167
  expect(body.response_format).toBeUndefined();
168
  return new Response(makeMockStream([
169
  `data: ${JSON.stringify({ choices: [{ delta: { content: "retried" } }] })}\n`,
 
172
  status: 200,
173
  headers: { "content-type": "text/event-stream" },
174
  });
175
+ });
176
 
177
  const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
178
  const result = await client.chatCompletion({
 
187
 
188
  it("retries with more tokens on truncated response", async () => {
189
  let callCount = 0;
190
+ setMockFetch(async (_input, _opts) => {
191
  callCount++;
192
  const responseContent = callCount === 1
193
  ? `data: ${JSON.stringify({ choices: [{ delta: { content: '{"incomplete":' } }] })}\n` + "data: [DONE]\n"
 
197
  status: 200,
198
  headers: { "content-type": "text/event-stream" },
199
  });
200
+ });
201
 
202
  const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
203
  const result = await client.chatCompletion({
 
210
  expect(result.content).toBe('{"complete": true}');
211
  });
212
 
213
+ it("strips reasoning blocks and returns JSON from GLM-style streams", async () => {
214
+ const json = '{"questions":[{"format":"mcq"}]}';
215
+ globalThis.fetch = makeFetchMock([
216
+ `data: ${JSON.stringify({ choices: [{ delta: { content: "<think>\nPlanning questions...\n</think>" } }] })}\n`,
217
+ `data: ${JSON.stringify({ choices: [{ delta: { content: json } }] })}\n`,
218
+ "data: [DONE]\n",
219
+ ]);
220
+
221
+ const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
222
+ const result = await client.chatCompletion({
223
+ model: MODEL,
224
+ messages: [{ role: "user", content: "Test" }],
225
+ });
226
+
227
+ expect(result.content).toBe(json);
228
+ });
229
+
230
+ it("ignores reasoning_content delta and keeps final content", async () => {
231
+ globalThis.fetch = makeFetchMock([
232
+ `data: ${JSON.stringify({ choices: [{ delta: { reasoning_content: "internal reasoning only" } }] })}\n`,
233
+ `data: ${JSON.stringify({ choices: [{ delta: { content: '{"ok":true}' } }] })}\n`,
234
+ "data: [DONE]\n",
235
+ ]);
236
+
237
+ const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
238
+ const result = await client.chatCompletion({
239
+ model: MODEL,
240
+ messages: [{ role: "user", content: "Test" }],
241
+ });
242
+
243
+ expect(result.content).toBe('{"ok":true}');
244
+ });
245
+
246
+ it("throws on HTML error page in stream", async () => {
247
+ setMockFetch(async () =>
248
  new Response(makeMockStream([
249
+ `data: ${JSON.stringify({ choices: [{ delta: { content: "<!DOCTYPE html><html><body>502 Bad Gateway</body></html>" } }] })}\n`,
250
  "data: [DONE]\n",
251
  ]), {
252
  status: 200,
253
  headers: { "content-type": "text/event-stream" },
254
+ }),
255
+ );
256
 
257
  const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
258
  expect(
 
262
  }),
263
  ).rejects.toThrow("HTML");
264
  });
265
+
266
+ it("falls back to non-stream JSON body when SSE yields no tokens", async () => {
267
+ const body = JSON.stringify({
268
+ choices: [{ message: { content: '{"questions":[]}' } }],
269
+ });
270
+ setMockFetch(async () =>
271
+ new Response(body, {
272
+ status: 200,
273
+ headers: { "content-type": "application/json" },
274
+ }),
275
+ );
276
+
277
+ const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
278
+ const result = await client.chatCompletion({
279
+ model: MODEL,
280
+ messages: [{ role: "user", content: "Test" }],
281
+ });
282
+
283
+ expect(result.content).toBe('{"questions":[]}');
284
+ });
285
  });
packages/ai/src/__tests__/parse-response.test.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ extractContentFromCompletionBody,
4
+ parseAiJsonResponse,
5
+ stripReasoningBlocks,
6
+ } from "../parse-response";
7
+
8
+ describe("stripReasoningBlocks", () => {
9
+ it("removes closed redacted_thinking blocks", () => {
10
+ const input = "<think>plan</think>\n{\"ok\":true}";
11
+ expect(stripReasoningBlocks(input)).toBe('{"ok":true}');
12
+ });
13
+
14
+ it("removes think blocks", () => {
15
+ const input = ["<", "think", ">planning</", "think", ">\n", '{"ok":true}'].join("");
16
+ expect(stripReasoningBlocks(input)).toBe('{"ok":true}');
17
+ });
18
+
19
+ it("removes unclosed thinking at start", () => {
20
+ const input = "<think>still streaming...";
21
+ expect(stripReasoningBlocks(input)).toBe("");
22
+ });
23
+
24
+ it("does not treat XML-like tags in JSON as reasoning", () => {
25
+ const input = '{"questionText":"<b>highlight</b>"}';
26
+ expect(stripReasoningBlocks(input)).toBe(input);
27
+ });
28
+ });
29
+
30
+ describe("parseAiJsonResponse", () => {
31
+ it("parses JSON after reasoning preamble", () => {
32
+ const parsed = parseAiJsonResponse(
33
+ "<think>planning</think>\n```json\n{\"questions\":[]}\n```",
34
+ );
35
+ expect(parsed).toEqual({ questions: [] });
36
+ });
37
+
38
+ it("extracts JSON object embedded in prose", () => {
39
+ const parsed = parseAiJsonResponse(
40
+ "Here is the result:\n{\"questions\":[{\"format\":\"multiple_choice\"}]}\nThanks!",
41
+ );
42
+ expect(parsed).toEqual({ questions: [{ format: "multiple_choice" }] });
43
+ });
44
+
45
+ it("throws on empty content after stripping", () => {
46
+ expect(() => parseAiJsonResponse("<think>only thinking")).toThrow(
47
+ "Empty response from AI",
48
+ );
49
+ });
50
+ });
51
+
52
+ describe("extractContentFromCompletionBody", () => {
53
+ it("reads non-stream chat completion JSON", () => {
54
+ const body = JSON.stringify({
55
+ choices: [{ message: { content: '{"questions":[]}' } }],
56
+ });
57
+ expect(extractContentFromCompletionBody(body)).toBe('{"questions":[]}');
58
+ });
59
+
60
+ it("returns null for non-JSON bodies", () => {
61
+ expect(extractContentFromCompletionBody("<html>error</html>")).toBeNull();
62
+ });
63
+ });
packages/ai/src/agentic.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { OpenAICompatibleClient } from "./client";
2
  import { GenerationError } from "./errors";
 
3
  import {
4
  getPassageJsonSchemaDescription,
5
  getValidationJsonSchemaDescription,
@@ -68,18 +69,7 @@ function calculateMaxTokens(
68
  }
69
 
70
  function parseJsonResponse(content: string): unknown {
71
- if (!content) throw new Error("Empty response from AI");
72
- let parsed: unknown;
73
- try {
74
- parsed = JSON.parse(content);
75
- } catch {
76
- const cleaned = content
77
- .replace(/^```json\s*/, "")
78
- .replace(/```\s*$/, "")
79
- .trim();
80
- parsed = JSON.parse(cleaned);
81
- }
82
- return parsed;
83
  }
84
 
85
  async function step1GeneratePassage(
 
1
  import { OpenAICompatibleClient } from "./client";
2
  import { GenerationError } from "./errors";
3
+ import { parseAiJsonResponse } from "./parse-response";
4
  import {
5
  getPassageJsonSchemaDescription,
6
  getValidationJsonSchemaDescription,
 
69
  }
70
 
71
  function parseJsonResponse(content: string): unknown {
72
+ return parseAiJsonResponse(content);
 
 
 
 
 
 
 
 
 
 
 
73
  }
74
 
75
  async function step1GeneratePassage(
packages/ai/src/client.ts CHANGED
@@ -1,3 +1,9 @@
 
 
 
 
 
 
1
  export interface ChatMessage {
2
  role: "system" | "user" | "assistant";
3
  content: string;
@@ -42,7 +48,8 @@ function parseSSELine(line: string): { content?: string; usage?: ChatCompletionR
42
  if (data === "[DONE]") return null;
43
  try {
44
  const chunk = JSON.parse(data);
45
- const content = chunk.choices?.[0]?.delta?.content;
 
46
  const usage = chunk.usage;
47
  return { content: typeof content === "string" ? content : undefined, usage };
48
  } catch {
@@ -53,9 +60,10 @@ function parseSSELine(line: string): { content?: string; usage?: ChatCompletionR
53
  async function readSSEStream(
54
  reader: any,
55
  callbacks: StreamCallbacks,
56
- ): Promise<{ content: string; usage?: ChatCompletionResult["usage"] }> {
57
  const decoder = new TextDecoder();
58
  let buffer = "";
 
59
  let fullContent = "";
60
  let lastUsage: ChatCompletionResult["usage"] | undefined;
61
 
@@ -63,12 +71,16 @@ async function readSSEStream(
63
  const { done, value } = await reader.read();
64
  if (done) break;
65
 
66
- buffer += decoder.decode(value, { stream: true });
 
 
67
  const lines = buffer.split("\n");
68
  buffer = lines.pop() ?? "";
69
 
70
  for (const line of lines) {
71
- const parsed = parseSSELine(line);
 
 
72
  if (!parsed) continue;
73
  if (parsed.content) {
74
  fullContent += parsed.content;
@@ -94,7 +106,15 @@ async function readSSEStream(
94
  }
95
  }
96
 
97
- return { content: fullContent, usage: lastUsage };
 
 
 
 
 
 
 
 
98
  }
99
 
100
  function looksTruncated(content: string): boolean {
@@ -224,7 +244,7 @@ export class OpenAICompatibleClient {
224
  status: res.status,
225
  statusText: res.statusText,
226
  preview,
227
- isHtml: preview.trim().startsWith("<"),
228
  });
229
 
230
  // Retry without response_format if provider doesn't support it
@@ -237,10 +257,10 @@ export class OpenAICompatibleClient {
237
  });
238
  }
239
 
240
- if (preview.trim().startsWith("<")) {
241
  throw new Error(
242
- `Provider returned HTML instead of JSON (status ${res.status}). ` +
243
- `This usually means the base URL or endpoint is wrong, or the provider does not support this API. ` +
244
  `Preview: ${preview.slice(0, 200)}`,
245
  );
246
  }
@@ -253,20 +273,24 @@ export class OpenAICompatibleClient {
253
  }
254
 
255
  const reader = res.body.getReader() as any;
256
- const result = await readSSEStream(
257
  reader,
258
  callbacks ?? { onToken: () => {} },
259
  );
 
 
 
 
260
 
261
- // Defense: if content looks like HTML, something went wrong with streaming
262
- if (result.content.trim().startsWith("<")) {
263
  const preview = result.content.slice(0, 500);
264
- log("error", "Stream returned HTML instead of JSON", {
265
  preview: preview.slice(0, 200),
266
  });
267
  throw new Error(
268
- `Provider returned HTML in stream instead of JSON. ` +
269
- `The provider may not support SSE streaming. ` +
270
  `Preview: ${preview.slice(0, 200)}`,
271
  );
272
  }
 
1
+ import {
2
+ extractContentFromCompletionBody,
3
+ looksLikeHtmlErrorPage,
4
+ stripReasoningBlocks,
5
+ } from "./parse-response";
6
+
7
  export interface ChatMessage {
8
  role: "system" | "user" | "assistant";
9
  content: string;
 
48
  if (data === "[DONE]") return null;
49
  try {
50
  const chunk = JSON.parse(data);
51
+ const choice = chunk.choices?.[0];
52
+ const content = choice?.delta?.content ?? choice?.message?.content;
53
  const usage = chunk.usage;
54
  return { content: typeof content === "string" ? content : undefined, usage };
55
  } catch {
 
60
  async function readSSEStream(
61
  reader: any,
62
  callbacks: StreamCallbacks,
63
+ ): Promise<{ content: string; usage?: ChatCompletionResult["usage"]; rawBody: string }> {
64
  const decoder = new TextDecoder();
65
  let buffer = "";
66
+ let rawBody = "";
67
  let fullContent = "";
68
  let lastUsage: ChatCompletionResult["usage"] | undefined;
69
 
 
71
  const { done, value } = await reader.read();
72
  if (done) break;
73
 
74
+ const chunkText = decoder.decode(value, { stream: true });
75
+ rawBody += chunkText;
76
+ buffer += chunkText;
77
  const lines = buffer.split("\n");
78
  buffer = lines.pop() ?? "";
79
 
80
  for (const line of lines) {
81
+ const trimmedLine = line.trim();
82
+ if (!trimmedLine || trimmedLine.startsWith(":")) continue;
83
+ const parsed = parseSSELine(trimmedLine);
84
  if (!parsed) continue;
85
  if (parsed.content) {
86
  fullContent += parsed.content;
 
106
  }
107
  }
108
 
109
+ if (!fullContent) {
110
+ const nonStreamContent = extractContentFromCompletionBody(rawBody);
111
+ if (nonStreamContent) {
112
+ fullContent = nonStreamContent;
113
+ callbacks.onToken(nonStreamContent);
114
+ }
115
+ }
116
+
117
+ return { content: fullContent, usage: lastUsage, rawBody };
118
  }
119
 
120
  function looksTruncated(content: string): boolean {
 
244
  status: res.status,
245
  statusText: res.statusText,
246
  preview,
247
+ isHtml: looksLikeHtmlErrorPage(preview),
248
  });
249
 
250
  // Retry without response_format if provider doesn't support it
 
257
  });
258
  }
259
 
260
+ if (looksLikeHtmlErrorPage(preview)) {
261
  throw new Error(
262
+ `Upstream returned an HTML error page (status ${res.status}), not AI JSON. ` +
263
+ `Check base URL, proxy, or gateway configuration. ` +
264
  `Preview: ${preview.slice(0, 200)}`,
265
  );
266
  }
 
273
  }
274
 
275
  const reader = res.body.getReader() as any;
276
+ const streamResult = await readSSEStream(
277
  reader,
278
  callbacks ?? { onToken: () => {} },
279
  );
280
+ const result = {
281
+ ...streamResult,
282
+ content: stripReasoningBlocks(streamResult.content),
283
+ };
284
 
285
+ // Defense: reject actual HTML error pages, not model thinking tags like <think>
286
+ if (looksLikeHtmlErrorPage(result.content)) {
287
  const preview = result.content.slice(0, 500);
288
+ log("error", "Stream returned HTML error page instead of AI content", {
289
  preview: preview.slice(0, 200),
290
  });
291
  throw new Error(
292
+ `Upstream returned an HTML error page in the stream, not AI JSON. ` +
293
+ `Check base URL, proxy, or gateway configuration. ` +
294
  `Preview: ${preview.slice(0, 200)}`,
295
  );
296
  }
packages/ai/src/parse-response.ts ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Normalize model output before JSON parsing.
3
+ * Reasoning models (GLM, DeepSeek, etc.) often wrap chain-of-thought in tags
4
+ * that are NOT HTML error pages.
5
+ */
6
+
7
+ const THINK_TAG = "think";
8
+
9
+ const REASONING_BLOCK_PATTERNS: RegExp[] = [
10
+ /<think>[\s\S]*?<\/redacted_thinking>/gi,
11
+ /<thinking>[\s\S]*?<\/thinking>/gi,
12
+ new RegExp(`<${THINK_TAG}>[\\s\\S]*?</${THINK_TAG}>`, "gi"),
13
+ ];
14
+
15
+ const UNCLOSED_REASONING_PREFIXES = [
16
+ /^<think>[\s\S]*/i,
17
+ /^<thinking>[\s\S]*/i,
18
+ new RegExp(`^<${THINK_TAG}>[\\s\\S]*`, "i"),
19
+ ];
20
+
21
+ export function stripReasoningBlocks(content: string): string {
22
+ let text = content;
23
+ for (const pattern of REASONING_BLOCK_PATTERNS) {
24
+ text = text.replace(pattern, "");
25
+ }
26
+ for (const pattern of UNCLOSED_REASONING_PREFIXES) {
27
+ text = text.replace(pattern, "");
28
+ }
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);
35
+ if (fenced?.[1]) return fenced[1].trim();
36
+
37
+ const objIdx = trimmed.indexOf("{");
38
+ const arrIdx = trimmed.indexOf("[");
39
+ let start = -1;
40
+ if (objIdx === -1) start = arrIdx;
41
+ else if (arrIdx === -1) start = objIdx;
42
+ else start = Math.min(objIdx, arrIdx);
43
+
44
+ if (start < 0) return trimmed;
45
+
46
+ const slice = trimmed.slice(start);
47
+ const lastBrace = slice.lastIndexOf("}");
48
+ const lastBracket = slice.lastIndexOf("]");
49
+ const end = Math.max(lastBrace, lastBracket);
50
+ if (end === -1) return slice;
51
+ return slice.slice(0, end + 1);
52
+ }
53
+
54
+ export function parseAiJsonResponse(content: string): unknown {
55
+ const normalized = stripReasoningBlocks(content);
56
+ if (!normalized) throw new Error("Empty response from AI");
57
+
58
+ const candidates = [normalized, extractJsonCandidate(normalized)];
59
+ const uniqueCandidates = [...new Set(candidates.filter(Boolean))];
60
+
61
+ let lastError: Error | undefined;
62
+ for (const candidate of uniqueCandidates) {
63
+ try {
64
+ return JSON.parse(candidate);
65
+ } catch (err: unknown) {
66
+ lastError = err instanceof Error ? err : new Error(String(err));
67
+ const markdownCleaned = candidate
68
+ .replace(/^```json\s*/i, "")
69
+ .replace(/```\s*$/, "")
70
+ .trim();
71
+ if (markdownCleaned !== candidate) {
72
+ try {
73
+ return JSON.parse(markdownCleaned);
74
+ } catch (inner: unknown) {
75
+ lastError = inner instanceof Error ? inner : new Error(String(inner));
76
+ }
77
+ }
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;
89
+ try {
90
+ const json = JSON.parse(trimmed) as {
91
+ choices?: Array<{ message?: { content?: string } }>;
92
+ };
93
+ const content = json.choices?.[0]?.message?.content;
94
+ return typeof content === "string" ? content : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ export function looksLikeHtmlErrorPage(content: string): boolean {
101
+ const head = content.trim().slice(0, 300).toLowerCase();
102
+ return (
103
+ head.startsWith("<!doctype") ||
104
+ head.startsWith("<html") ||
105
+ head.startsWith("<head") ||
106
+ head.startsWith("<body") ||
107
+ /^<\?(xml|php)/.test(head)
108
+ );
109
+ }
packages/ai/src/pipeline.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { OpenAICompatibleClient } from "./client";
2
  import { GenerationError } from "./errors";
 
3
  import { buildQuickModePrompt } from "./prompts";
4
  import { repairAndParseQuestions } from "./repair";
5
  import { regenerateQuestions, buildRegenerationContext } from "./regenerate-questions";
@@ -76,28 +77,19 @@ export async function generateQuestionsQuick(
76
 
77
  let parsed: unknown;
78
  try {
79
- parsed = JSON.parse(content);
80
- log("info", "JSON parsed successfully (direct)");
81
  } catch (parseErr: any) {
82
- log("warn", "Direct JSON parse failed, trying markdown strip", { error: parseErr.message });
83
- const cleaned = content
84
- .replace(/^```json\s*/, "")
85
- .replace(/```\s*$/, "")
86
- .trim();
87
- try {
88
- parsed = JSON.parse(cleaned);
89
- log("info", "JSON parsed successfully after markdown strip");
90
- } catch (stripErr: any) {
91
- log("error", "JSON parse failed even after markdown strip", {
92
- error: stripErr.message,
93
- cleanedPreview: cleaned.slice(0, 500),
94
- originalPreview: content.slice(0, 500),
95
- });
96
- throw new GenerationError(
97
- `Failed to parse AI response as JSON: ${stripErr.message}. Preview: ${content.slice(0, 200)}`,
98
- { tokensUsed },
99
- );
100
- }
101
  }
102
 
103
  if (!parsed || typeof parsed !== "object") {
 
1
  import { OpenAICompatibleClient } from "./client";
2
  import { GenerationError } from "./errors";
3
+ import { parseAiJsonResponse } from "./parse-response";
4
  import { buildQuickModePrompt } from "./prompts";
5
  import { repairAndParseQuestions } from "./repair";
6
  import { regenerateQuestions, buildRegenerationContext } from "./regenerate-questions";
 
77
 
78
  let parsed: unknown;
79
  try {
80
+ parsed = parseAiJsonResponse(content);
81
+ log("info", "JSON parsed successfully");
82
  } catch (parseErr: any) {
83
+ log("error", "JSON parse failed", {
84
+ error: parseErr.message,
85
+ originalPreview: content.slice(0, 500),
86
+ });
87
+ throw new GenerationError(
88
+ parseErr.message.includes("Failed to parse AI response")
89
+ ? parseErr.message
90
+ : `Failed to parse AI response as JSON: ${parseErr.message}. Preview: ${content.slice(0, 200)}`,
91
+ { tokensUsed },
92
+ );
 
 
 
 
 
 
 
 
 
93
  }
94
 
95
  if (!parsed || typeof parsed !== "object") {
packages/ai/src/regenerate-questions.ts CHANGED
@@ -1,20 +1,12 @@
1
  import { OpenAICompatibleClient } from "./client";
 
2
  import { getGenericQuestionJsonSchemaDescription } from "./repair";
3
  import { OPTION_QUALITY_RULES } from "./prompts";
4
  import { buildContentLanguageRules, buildExplanationLanguageRule } from "./language-rules";
5
  import type { GenerationInput } from "./schemas";
6
 
7
  function parseJsonResponse(content: string): unknown {
8
- if (!content) throw new Error("Empty response from AI");
9
- try {
10
- return JSON.parse(content);
11
- } catch {
12
- const cleaned = content
13
- .replace(/^```json\s*/, "")
14
- .replace(/```\s*$/, "")
15
- .trim();
16
- return JSON.parse(cleaned);
17
- }
18
  }
19
 
20
  function calculateRegenerateMaxTokens(userMax: number, count: number): number {
 
1
  import { OpenAICompatibleClient } from "./client";
2
+ import { parseAiJsonResponse } from "./parse-response";
3
  import { getGenericQuestionJsonSchemaDescription } from "./repair";
4
  import { OPTION_QUALITY_RULES } from "./prompts";
5
  import { buildContentLanguageRules, buildExplanationLanguageRule } from "./language-rules";
6
  import type { GenerationInput } from "./schemas";
7
 
8
  function parseJsonResponse(content: string): unknown {
9
+ return parseAiJsonResponse(content);
 
 
 
 
 
 
 
 
 
10
  }
11
 
12
  function calculateRegenerateMaxTokens(userMax: number, count: number): number {
packages/api/src/__tests__/generation-outcome.test.ts CHANGED
@@ -1,5 +1,7 @@
1
  import { describe, expect, it } from "bun:test";
2
  import {
 
 
3
  isNonRetryableProviderError,
4
  resolveGenerationJobOutcome,
5
  sectionsWithNoQuestions,
@@ -23,6 +25,14 @@ describe("isNonRetryableProviderError", () => {
23
  it("returns false for JSON parse failures", () => {
24
  expect(isNonRetryableProviderError(new Error("Failed to parse AI response as JSON"))).toBe(false);
25
  });
 
 
 
 
 
 
 
 
26
  });
27
 
28
  describe("resolveGenerationJobOutcome", () => {
@@ -47,7 +57,7 @@ describe("resolveGenerationJobOutcome", () => {
47
  expect(outcome.errorMessage).toContain("WRITING");
48
  });
49
 
50
- it("marks zero questions as failed", () => {
51
  const outcome = resolveGenerationJobOutcome({
52
  requestedCount: 20,
53
  generatedCount: 0,
@@ -55,6 +65,40 @@ describe("resolveGenerationJobOutcome", () => {
55
  });
56
  expect(outcome.status).toBe("failed");
57
  expect(outcome.errorMessage).toContain("Tidak ada soal");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  });
59
  });
60
 
 
1
  import { describe, expect, it } from "bun:test";
2
  import {
3
+ collectShardFailureCause,
4
+ formatGenerationErrorMessage,
5
  isNonRetryableProviderError,
6
  resolveGenerationJobOutcome,
7
  sectionsWithNoQuestions,
 
25
  it("returns false for JSON parse failures", () => {
26
  expect(isNonRetryableProviderError(new Error("Failed to parse AI response as JSON"))).toBe(false);
27
  });
28
+
29
+ it("detects upstream HTML error pages", () => {
30
+ expect(
31
+ isNonRetryableProviderError(
32
+ new Error("Upstream returned an HTML error page in the stream, not AI JSON."),
33
+ ),
34
+ ).toBe(true);
35
+ });
36
  });
37
 
38
  describe("resolveGenerationJobOutcome", () => {
 
57
  expect(outcome.errorMessage).toContain("WRITING");
58
  });
59
 
60
+ it("marks zero questions as failed without blaming provider when cause is unknown", () => {
61
  const outcome = resolveGenerationJobOutcome({
62
  requestedCount: 20,
63
  generatedCount: 0,
 
65
  });
66
  expect(outcome.status).toBe("failed");
67
  expect(outcome.errorMessage).toContain("Tidak ada soal");
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,
74
+ generatedCount: 0,
75
+ failedSections: ["READING"],
76
+ cause: "Failed to parse AI response as JSON: Unexpected token. Preview: {broken",
77
+ });
78
+ expect(outcome.errorMessage).toContain("tidak valid JSON");
79
+ expect(outcome.errorMessage).toContain("Failed to parse AI response");
80
+ expect(outcome.errorMessage).not.toContain("Periksa model/API provider");
81
+ });
82
+ });
83
+
84
+ describe("formatGenerationErrorMessage", () => {
85
+ it("labels upstream provider failures clearly", () => {
86
+ const message = formatGenerationErrorMessage({
87
+ cause: "OpenAI-compatible API error 500: upstream timeout",
88
+ failedSections: ["READING"],
89
+ });
90
+ expect(message).toContain("Gagal menghubungi API provider");
91
+ expect(message).toContain("500");
92
+ });
93
+
94
+ it("detects legacy false-positive HTML guard on reasoning output", () => {
95
+ const message = formatGenerationErrorMessage({
96
+ cause:
97
+ "Provider returned HTML in stream instead of JSON. Preview: <think>planning",
98
+ failedSections: ["READING"],
99
+ });
100
+ expect(message).toContain("reasoning tidak kompatibel");
101
+ expect(message).not.toContain("Gagal menghubungi API provider");
102
  });
103
  });
104
 
packages/api/src/lib/generation-outcome.ts CHANGED
@@ -6,6 +6,7 @@ export function isNonRetryableProviderError(error: unknown): boolean {
6
  if (/api error 5\d\d/i.test(message)) return true;
7
  if (lower.includes("midstreamfallbackerror")) return true;
8
  if (lower.includes("apiconnectionerror")) return true;
 
9
  if (lower.includes("provider returned html")) return true;
10
  if (/\berror 502\b/.test(lower) || /\berror 503\b/.test(lower) || /\berror 504\b/.test(lower)) {
11
  return true;
@@ -14,32 +15,97 @@ export function isNonRetryableProviderError(error: unknown): boolean {
14
  return false;
15
  }
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  export function resolveGenerationJobOutcome(input: {
18
  requestedCount: number;
19
  generatedCount: number;
20
  failedSections?: string[];
 
21
  }): {
22
  status: "completed" | "completed_partial" | "failed";
23
  progressMessage: string;
24
  errorMessage: string | null;
25
  } {
26
  const failedSections = [...new Set(input.failedSections ?? [])];
27
- const sectionHint =
28
- failedSections.length > 0 ? ` Section gagal: ${failedSections.join(", ")}.` : "";
29
 
30
  if (input.generatedCount <= 0) {
31
  return {
32
  status: "failed",
33
  progressMessage: "Generation failed",
34
- errorMessage: `Tidak ada soal yang berhasil dibuat.${sectionHint} Periksa model/API provider.`,
 
 
 
35
  };
36
  }
37
 
38
  if (input.generatedCount < input.requestedCount) {
 
 
39
  return {
40
  status: "completed_partial",
41
  progressMessage: `Selesai sebagian: ${input.generatedCount}/${input.requestedCount} soal`,
42
- errorMessage: `Hanya ${input.generatedCount} dari ${input.requestedCount} soal berhasil dibuat.${sectionHint}`,
43
  };
44
  }
45
 
@@ -58,3 +124,13 @@ export function sectionsWithNoQuestions(
58
  .filter((split) => !questions.some((q) => q.section === split.section))
59
  .map((split) => split.section);
60
  }
 
 
 
 
 
 
 
 
 
 
 
6
  if (/api error 5\d\d/i.test(message)) return true;
7
  if (lower.includes("midstreamfallbackerror")) return true;
8
  if (lower.includes("apiconnectionerror")) return true;
9
+ if (lower.includes("upstream returned an html error page")) return true;
10
  if (lower.includes("provider returned html")) return true;
11
  if (/\berror 502\b/.test(lower) || /\berror 503\b/.test(lower) || /\berror 504\b/.test(lower)) {
12
  return true;
 
15
  return false;
16
  }
17
 
18
+ function truncate(text: string, maxLen: number): string {
19
+ return text.length <= maxLen ? text : `${text.slice(0, maxLen)}…`;
20
+ }
21
+
22
+ function sectionHint(failedSections: string[]): string {
23
+ return failedSections.length > 0 ? ` Section gagal: ${failedSections.join(", ")}.` : "";
24
+ }
25
+
26
+ /** Turn raw pipeline/client errors into user-facing messages that name the real cause. */
27
+ export function formatGenerationErrorMessage(input: {
28
+ cause?: string;
29
+ failedSections?: string[];
30
+ }): string {
31
+ const failedSections = [...new Set(input.failedSections ?? [])];
32
+ const hint = sectionHint(failedSections);
33
+ const cause = input.cause?.trim();
34
+
35
+ if (!cause) {
36
+ return `Tidak ada soal yang berhasil dibuat.${hint}`;
37
+ }
38
+
39
+ const lower = cause.toLowerCase();
40
+
41
+ if (
42
+ lower.includes("redacted_thinking") ||
43
+ lower.includes("<thinking>") ||
44
+ /<\/?think>/.test(lower)
45
+ ) {
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
+ }
52
+
53
+ if (lower.includes("empty response from ai") || lower.includes("empty response body")) {
54
+ return `Model tidak mengembalikan konten.${hint} Coba model lain atau kurangi jumlah soal.`;
55
+ }
56
+
57
+ if (lower.includes("no valid questions")) {
58
+ return `Model menghasilkan soal, tapi semuanya gagal validasi.${hint} ${truncate(cause, 280)}`;
59
+ }
60
+
61
+ if (
62
+ lower.includes("upstream returned an html error page") ||
63
+ lower.includes("provider returned html") ||
64
+ lower.includes("openai-compatible api error") ||
65
+ /api error 5\d\d/i.test(cause) ||
66
+ lower.includes("apiconnectionerror") ||
67
+ lower.includes("midstreamfallbackerror")
68
+ ) {
69
+ return `Gagal menghubungi API provider.${hint} ${truncate(cause, 280)}`;
70
+ }
71
+
72
+ if (lower.includes("metadata/private network") || lower.includes("invalid base url")) {
73
+ return `Base URL provider tidak valid atau tidak dapat diakses dari server.${hint}`;
74
+ }
75
+
76
+ return `Generasi gagal.${hint} ${truncate(cause, 280)}`;
77
+ }
78
+
79
  export function resolveGenerationJobOutcome(input: {
80
  requestedCount: number;
81
  generatedCount: number;
82
  failedSections?: string[];
83
+ cause?: string;
84
  }): {
85
  status: "completed" | "completed_partial" | "failed";
86
  progressMessage: string;
87
  errorMessage: string | null;
88
  } {
89
  const failedSections = [...new Set(input.failedSections ?? [])];
 
 
90
 
91
  if (input.generatedCount <= 0) {
92
  return {
93
  status: "failed",
94
  progressMessage: "Generation failed",
95
+ errorMessage: formatGenerationErrorMessage({
96
+ cause: input.cause,
97
+ failedSections,
98
+ }),
99
  };
100
  }
101
 
102
  if (input.generatedCount < input.requestedCount) {
103
+ const sectionHintText =
104
+ failedSections.length > 0 ? ` Section gagal: ${failedSections.join(", ")}.` : "";
105
  return {
106
  status: "completed_partial",
107
  progressMessage: `Selesai sebagian: ${input.generatedCount}/${input.requestedCount} soal`,
108
+ errorMessage: `Hanya ${input.generatedCount} dari ${input.requestedCount} soal berhasil dibuat.${sectionHintText}`,
109
  };
110
  }
111
 
 
124
  .filter((split) => !questions.some((q) => q.section === split.section))
125
  .map((split) => split.section);
126
  }
127
+
128
+ export function collectShardFailureCause(
129
+ failures: Iterable<{ error: string } | undefined>,
130
+ ): string | undefined {
131
+ for (const failure of failures) {
132
+ const message = failure?.error?.trim();
133
+ if (message) return message;
134
+ }
135
+ return undefined;
136
+ }
packages/api/src/queue.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
  import { and, eq, notInArray } from "drizzle-orm";
28
  import { encryptApiKey, decryptApiKey } from "./lib/encryption";
29
  import {
 
30
  isNonRetryableProviderError,
31
  resolveGenerationJobOutcome,
32
  sectionsWithNoQuestions,
@@ -955,6 +956,9 @@ export const generationWorker = new Worker<FastJobData>(
955
  requestedCount: input.questionCount,
956
  generatedCount: allQuestions.length,
957
  failedSections: sectionsWithNoQuestions(sectionSplits, allQuestions),
 
 
 
958
  });
959
 
960
  if (outcome.status === "failed") {
 
27
  import { and, eq, notInArray } from "drizzle-orm";
28
  import { encryptApiKey, decryptApiKey } from "./lib/encryption";
29
  import {
30
+ collectShardFailureCause,
31
  isNonRetryableProviderError,
32
  resolveGenerationJobOutcome,
33
  sectionsWithNoQuestions,
 
956
  requestedCount: input.questionCount,
957
  generatedCount: allQuestions.length,
958
  failedSections: sectionsWithNoQuestions(sectionSplits, allQuestions),
959
+ cause: collectShardFailureCause(
960
+ failedShards.map((shard) => shardFailureInfo.get(shardKey(shard))),
961
+ ),
962
  });
963
 
964
  if (outcome.status === "failed") {