Skip to content

Commit dc8e30c

Browse files
pescnclaude
andauthored
fix(api): add safe JSON parsing for upstream provider responses (#64)
* fix(api): add safe JSON parsing for upstream provider responses Unhandled JSON.parse calls crash with SyntaxError when upstream LLM providers return malformed JSON (HTML error pages, truncated responses). Adds safeParseToolArgs (returns {} on failure) for tool arguments and parseJsonResponse (descriptive re-throw) for response bodies. Wraps error body parsing in try-catch with format-specific fallbacks. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(api): add type validation and standardize error responses for safe JSON parsing - Add runtime type validation in safeParseToolArgs to reject scalars, arrays, and null - Standardize error response format across all API endpoints with "unparseable_error" code - Add comprehensive logging with full response bodies for debugging - Create 46 unit and integration tests covering malformed JSON scenarios This addresses code review feedback to ensure type safety when parsing tool arguments from LLM responses that may return non-object values. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 57900ef commit dc8e30c

13 files changed

Lines changed: 544 additions & 30 deletions

File tree

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"check:tsgo": "tsgo -p tsconfig.check.json",
1010
"check": "tsc -p tsconfig.check.json",
1111
"dev": "COMMIT_SHA=$(git rev-parse HEAD) CONSOLA_LEVEL=9999 FRONTEND_DIR=../frontend/dist DOCS_DIR=docs bun --watch --hot --no-clear-screen --inspect src/index.ts",
12+
"test": "bun test",
1213
"fmt": "oxfmt .",
1314
"fmt:check": "oxfmt --check .",
1415
"lint": "oxlint --type-aware"

backend/src/adapters/request/openai-chat.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
ToolResultContentBlock,
1616
ToolUseContentBlock,
1717
} from "../types";
18+
import { safeParseToolArgs } from "@/utils/json";
1819

1920
// =============================================================================
2021
// Helper Functions
@@ -191,7 +192,7 @@ function convertToolCalls(
191192
type: "tool_use" as const,
192193
id: tc.id,
193194
name: tc.function.name,
194-
input: JSON.parse(tc.function.arguments) as Record<string, unknown>,
195+
input: safeParseToolArgs(tc.function.arguments),
195196
}));
196197
}
197198

@@ -229,10 +230,7 @@ function convertMessage(msg: OpenAIChatMessage): InternalMessage {
229230
type: "tool_use" as const,
230231
id: "legacy_function_call",
231232
name: msg.function_call.name,
232-
input: JSON.parse(msg.function_call.arguments) as Record<
233-
string,
234-
unknown
235-
>,
233+
input: safeParseToolArgs(msg.function_call.arguments),
236234
},
237235
]
238236
: undefined;

backend/src/adapters/tools.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
JsonSchema,
99
ToolUseContentBlock,
1010
} from "./types";
11+
import { safeParseToolArgs } from "@/utils/json";
1112

1213
// =============================================================================
1314
// OpenAI Tool Types
@@ -135,7 +136,7 @@ export function fromOpenAIToolCalls(
135136
type: "tool_use" as const,
136137
id: tc.id,
137138
name: tc.function.name,
138-
input: parseJsonSafe(tc.function.arguments),
139+
input: safeParseToolArgs(tc.function.arguments),
139140
}));
140141
}
141142

@@ -283,17 +284,6 @@ export function fromAnthropicToolChoice(
283284
// Utility Functions
284285
// =============================================================================
285286

286-
/**
287-
* Safely parse JSON string, returning empty object on failure
288-
*/
289-
function parseJsonSafe(jsonString: string): Record<string, unknown> {
290-
try {
291-
return JSON.parse(jsonString) as Record<string, unknown>;
292-
} catch {
293-
return {};
294-
}
295-
}
296-
297287
/**
298288
* Generate a unique tool call ID
299289
*/

backend/src/adapters/upstream/anthropic.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
ToolUseContentBlock,
1818
UpstreamAdapter,
1919
} from "../types";
20+
import { parseJsonResponse } from "@/utils/json";
2021

2122
// =============================================================================
2223
// Anthropic Request/Response Types
@@ -487,7 +488,7 @@ export const anthropicUpstreamAdapter: UpstreamAdapter = {
487488

488489
async parseResponse(response: Response): Promise<InternalResponse> {
489490
const text = await response.text();
490-
const json = JSON.parse(text) as AnthropicResponse;
491+
const json = parseJsonResponse<AnthropicResponse>(text, "Anthropic");
491492
return convertResponse(json);
492493
},
493494

backend/src/adapters/upstream/openai-responses.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
UpstreamAdapter,
1818
} from "../types";
1919
import { convertImageToUrl, hasImages } from "./utils";
20+
import { safeParseToolArgs, parseJsonResponse } from "@/utils/json";
2021

2122
// =============================================================================
2223
// Response API Types
@@ -237,7 +238,7 @@ function convertResponse(resp: ResponseApiResponse): InternalResponse {
237238
id: output.call_id || output.id || "",
238239
name: output.name || "",
239240
input: output.arguments
240-
? (JSON.parse(output.arguments) as Record<string, unknown>)
241+
? safeParseToolArgs(output.arguments)
241242
: {},
242243
} as ToolUseContentBlock);
243244
}
@@ -365,7 +366,7 @@ export const openaiResponsesUpstreamAdapter: UpstreamAdapter = {
365366

366367
async parseResponse(response: Response): Promise<InternalResponse> {
367368
const text = await response.text();
368-
const json = JSON.parse(text) as ResponseApiResponse;
369+
const json = parseJsonResponse<ResponseApiResponse>(text, "OpenAI Responses");
369370
return convertResponse(json);
370371
},
371372

backend/src/adapters/upstream/openai.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
UpstreamAdapter,
2020
} from "../types";
2121
import { convertImageToUrl, hasImages } from "./utils";
22+
import { safeParseToolArgs, parseJsonResponse } from "@/utils/json";
2223

2324
// =============================================================================
2425
// OpenAI Request/Response Types
@@ -321,7 +322,7 @@ function convertResponse(resp: OpenAIChatResponse): InternalResponse {
321322
type: "tool_use",
322323
id: tc.id,
323324
name: tc.function.name,
324-
input: JSON.parse(tc.function.arguments) as Record<string, unknown>,
325+
input: safeParseToolArgs(tc.function.arguments),
325326
} as ToolUseContentBlock);
326327
}
327328
}
@@ -450,7 +451,7 @@ export const openaiUpstreamAdapter: UpstreamAdapter = {
450451

451452
async parseResponse(response: Response): Promise<InternalResponse> {
452453
const text = await response.text();
453-
const json = JSON.parse(text) as OpenAIChatResponse;
454+
const json = parseJsonResponse<OpenAIChatResponse>(text, "OpenAI Chat");
454455
return convertResponse(json);
455456
},
456457

backend/src/api/v1/completions.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -643,7 +643,17 @@ export const completionsApi = new Elysia({
643643

644644
if (errorResult.type === "upstream_error") {
645645
set.status = errorResult.status;
646-
return JSON.parse(errorResult.body) as Record<string, unknown>;
646+
try {
647+
return JSON.parse(errorResult.body) as Record<string, unknown>;
648+
} catch {
649+
return {
650+
error: {
651+
message: errorResult.body,
652+
type: "upstream_error",
653+
code: "unparseable_error",
654+
},
655+
};
656+
}
647657
}
648658

649659
set.status = 502;
@@ -730,7 +740,17 @@ export const completionsApi = new Elysia({
730740

731741
if (errorResult.type === "upstream_error") {
732742
set.status = errorResult.status;
733-
return JSON.parse(errorResult.body) as Record<string, unknown>;
743+
try {
744+
return JSON.parse(errorResult.body) as Record<string, unknown>;
745+
} catch {
746+
return {
747+
error: {
748+
message: errorResult.body,
749+
type: "upstream_error",
750+
code: "unparseable_error",
751+
},
752+
};
753+
}
734754
}
735755

736756
set.status = 502;

backend/src/api/v1/messages.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
type ReqIdContext,
4141
} from "@/utils/reqIdHandler";
4242
import type { CachedResponseType } from "@/db/schema";
43+
import { safeParseToolArgs } from "@/utils/json";
4344

4445
const logger = consola.withTag("messagesApi");
4546

@@ -292,7 +293,7 @@ async function* processStreamingResponse(
292293
type: "tool_use",
293294
id: tc.id,
294295
name: tc.function.name,
295-
input: JSON.parse(tc.function.arguments || "{}"),
296+
input: safeParseToolArgs(tc.function.arguments || "{}"),
296297
});
297298
}
298299
}
@@ -625,7 +626,18 @@ export const messagesApi = new Elysia({
625626

626627
if (errorResult.type === "upstream_error") {
627628
set.status = errorResult.status;
628-
return JSON.parse(errorResult.body) as Record<string, unknown>;
629+
try {
630+
return JSON.parse(errorResult.body) as Record<string, unknown>;
631+
} catch {
632+
return {
633+
type: "error",
634+
error: {
635+
type: "api_error",
636+
message: errorResult.body,
637+
code: "unparseable_error",
638+
},
639+
};
640+
}
629641
}
630642

631643
set.status = 502;
@@ -713,7 +725,18 @@ export const messagesApi = new Elysia({
713725

714726
if (errorResult.type === "upstream_error") {
715727
set.status = errorResult.status;
716-
return JSON.parse(errorResult.body) as Record<string, unknown>;
728+
try {
729+
return JSON.parse(errorResult.body) as Record<string, unknown>;
730+
} catch {
731+
return {
732+
type: "error",
733+
error: {
734+
type: "api_error",
735+
message: errorResult.body,
736+
code: "unparseable_error",
737+
},
738+
};
739+
}
717740
}
718741

719742
set.status = 502;

backend/src/api/v1/responses.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -675,7 +675,18 @@ export const responsesApi = new Elysia({
675675

676676
if (errorResult.type === "upstream_error") {
677677
set.status = errorResult.status;
678-
return JSON.parse(errorResult.body) as Record<string, unknown>;
678+
try {
679+
return JSON.parse(errorResult.body) as Record<string, unknown>;
680+
} catch {
681+
return {
682+
object: "error",
683+
error: {
684+
type: "upstream_error",
685+
message: errorResult.body,
686+
code: "unparseable_error",
687+
},
688+
};
689+
}
679690
}
680691

681692
set.status = 502;
@@ -763,7 +774,18 @@ export const responsesApi = new Elysia({
763774

764775
if (errorResult.type === "upstream_error") {
765776
set.status = errorResult.status;
766-
return JSON.parse(errorResult.body) as Record<string, unknown>;
777+
try {
778+
return JSON.parse(errorResult.body) as Record<string, unknown>;
779+
} catch {
780+
return {
781+
object: "error",
782+
error: {
783+
type: "upstream_error",
784+
message: errorResult.body,
785+
code: "unparseable_error",
786+
},
787+
};
788+
}
767789
}
768790

769791
set.status = 502;

0 commit comments

Comments
 (0)