Skip to content

Commit e7a9ec9

Browse files
authored
feat (provider-utils): include raw value in json parse results (vercel#4429)
1 parent 47ccf90 commit e7a9ec9

File tree

6 files changed

+220
-13
lines changed

6 files changed

+220
-13
lines changed

.changeset/gentle-mirrors-repeat.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@ai-sdk/ui-utils': patch
3+
'@ai-sdk/provider-utils': patch
4+
'@ai-sdk/openai': patch
5+
---
6+
7+
feat (provider-utils): include raw value in json parse results

packages/openai/src/openai-error.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ describe('openaiErrorDataSchema', () => {
2121
code: 429,
2222
},
2323
},
24+
rawValue: {
25+
error: {
26+
message:
27+
'{\n "error": {\n "code": 429,\n "message": "Resource has been exhausted (e.g. check quota).",\n "status": "RESOURCE_EXHAUSTED"\n }\n}\n',
28+
code: 429,
29+
},
30+
},
2431
});
2532
});
2633
});
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { parseJSON, safeParseJSON, isParsableJson } from './parse-json';
3+
import { z } from 'zod';
4+
import { JSONParseError, TypeValidationError } from '@ai-sdk/provider';
5+
6+
describe('parseJSON', () => {
7+
it('should parse basic JSON without schema', () => {
8+
const result = parseJSON({ text: '{"foo": "bar"}' });
9+
expect(result).toEqual({ foo: 'bar' });
10+
});
11+
12+
it('should parse JSON with schema validation', () => {
13+
const schema = z.object({ foo: z.string() });
14+
const result = parseJSON({ text: '{"foo": "bar"}', schema });
15+
expect(result).toEqual({ foo: 'bar' });
16+
});
17+
18+
it('should throw JSONParseError for invalid JSON', () => {
19+
expect(() => parseJSON({ text: 'invalid json' })).toThrow(JSONParseError);
20+
});
21+
22+
it('should throw TypeValidationError for schema validation failures', () => {
23+
const schema = z.object({ foo: z.number() });
24+
expect(() => parseJSON({ text: '{"foo": "bar"}', schema })).toThrow(
25+
TypeValidationError,
26+
);
27+
});
28+
});
29+
30+
describe('safeParseJSON', () => {
31+
it('should safely parse basic JSON without schema and include rawValue', () => {
32+
const result = safeParseJSON({ text: '{"foo": "bar"}' });
33+
expect(result).toEqual({
34+
success: true,
35+
value: { foo: 'bar' },
36+
rawValue: { foo: 'bar' },
37+
});
38+
});
39+
40+
it('should preserve rawValue even after schema transformation', () => {
41+
const schema = z.object({
42+
count: z.coerce.number(),
43+
});
44+
const result = safeParseJSON({
45+
text: '{"count": "42"}',
46+
schema,
47+
});
48+
49+
expect(result).toEqual({
50+
success: true,
51+
value: { count: 42 },
52+
rawValue: { count: '42' },
53+
});
54+
});
55+
56+
it('should handle failed parsing with error details', () => {
57+
const result = safeParseJSON({ text: 'invalid json' });
58+
expect(result).toEqual({
59+
success: false,
60+
error: expect.any(JSONParseError),
61+
});
62+
});
63+
64+
it('should handle schema validation failures', () => {
65+
const schema = z.object({ age: z.number() });
66+
const result = safeParseJSON({
67+
text: '{"age": "twenty"}',
68+
schema,
69+
});
70+
71+
expect(result).toEqual({
72+
success: false,
73+
error: expect.any(TypeValidationError),
74+
});
75+
});
76+
77+
it('should handle nested objects and preserve raw values', () => {
78+
const schema = z.object({
79+
user: z.object({
80+
id: z.string().transform(val => parseInt(val, 10)),
81+
name: z.string(),
82+
}),
83+
});
84+
85+
const result = safeParseJSON({
86+
text: '{"user": {"id": "123", "name": "John"}}',
87+
schema: schema as any,
88+
});
89+
90+
expect(result).toEqual({
91+
success: true,
92+
value: { user: { id: 123, name: 'John' } },
93+
rawValue: { user: { id: '123', name: 'John' } },
94+
});
95+
});
96+
97+
it('should handle arrays and preserve raw values', () => {
98+
const schema = z.array(z.string().transform(val => val.toUpperCase()));
99+
const result = safeParseJSON({
100+
text: '["hello", "world"]',
101+
schema,
102+
});
103+
104+
expect(result).toEqual({
105+
success: true,
106+
value: ['HELLO', 'WORLD'],
107+
rawValue: ['hello', 'world'],
108+
});
109+
});
110+
111+
it('should handle discriminated unions in schema', () => {
112+
const schema = z.discriminatedUnion('type', [
113+
z.object({ type: z.literal('text'), content: z.string() }),
114+
z.object({ type: z.literal('number'), value: z.number() }),
115+
]);
116+
117+
const result = safeParseJSON({
118+
text: '{"type": "text", "content": "hello"}',
119+
schema,
120+
});
121+
122+
expect(result).toEqual({
123+
success: true,
124+
value: { type: 'text', content: 'hello' },
125+
rawValue: { type: 'text', content: 'hello' },
126+
});
127+
});
128+
129+
it('should handle nullable fields in schema', () => {
130+
const schema = z.object({
131+
id: z.string().nullish(),
132+
data: z.string(),
133+
});
134+
135+
const result = safeParseJSON({
136+
text: '{"id": null, "data": "test"}',
137+
schema,
138+
});
139+
140+
expect(result).toEqual({
141+
success: true,
142+
value: { id: null, data: 'test' },
143+
rawValue: { id: null, data: 'test' },
144+
});
145+
});
146+
147+
it('should handle union types in schema', () => {
148+
const schema = z.object({
149+
value: z.union([z.string(), z.number()]),
150+
});
151+
152+
const result1 = safeParseJSON({
153+
text: '{"value": "test"}',
154+
schema,
155+
});
156+
157+
const result2 = safeParseJSON({
158+
text: '{"value": 123}',
159+
schema,
160+
});
161+
162+
expect(result1).toEqual({
163+
success: true,
164+
value: { value: 'test' },
165+
rawValue: { value: 'test' },
166+
});
167+
168+
expect(result2).toEqual({
169+
success: true,
170+
value: { value: 123 },
171+
rawValue: { value: 123 },
172+
});
173+
});
174+
});
175+
176+
describe('isParsableJson', () => {
177+
it('should return true for valid JSON', () => {
178+
expect(isParsableJson('{"foo": "bar"}')).toBe(true);
179+
expect(isParsableJson('[1, 2, 3]')).toBe(true);
180+
expect(isParsableJson('"hello"')).toBe(true);
181+
});
182+
183+
it('should return false for invalid JSON', () => {
184+
expect(isParsableJson('invalid')).toBe(false);
185+
expect(isParsableJson('{foo: "bar"}')).toBe(false);
186+
expect(isParsableJson('{"foo": }')).toBe(false);
187+
});
188+
});

packages/provider-utils/src/parse-json.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function parseJSON<T>({
5858
}
5959

6060
export type ParseResult<T> =
61-
| { success: true; value: T }
61+
| { success: true; value: T; rawValue: unknown }
6262
| { success: false; error: JSONParseError | TypeValidationError };
6363

6464
/**
@@ -89,20 +89,19 @@ export function safeParseJSON<T>({
8989
}: {
9090
text: string;
9191
schema?: ZodSchema<T> | Validator<T>;
92-
}):
93-
| { success: true; value: T }
94-
| { success: false; error: JSONParseError | TypeValidationError } {
92+
}): ParseResult<T> {
9593
try {
9694
const value = SecureJSON.parse(text);
9795

9896
if (schema == null) {
99-
return {
100-
success: true,
101-
value: value as T,
102-
};
97+
return { success: true, value: value as T, rawValue: value };
10398
}
10499

105-
return safeValidateTypes({ value, schema });
100+
const validationResult = safeValidateTypes({ value, schema });
101+
102+
return validationResult.success
103+
? { ...validationResult, rawValue: value }
104+
: validationResult;
106105
} catch (error) {
107106
return {
108107
success: false,

packages/provider-utils/src/response-handler.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ describe('createJsonStreamResponseHandler', () => {
2323
});
2424

2525
expect(await convertReadableStreamToArray(stream)).toStrictEqual([
26-
{ success: true, value: { a: 1 } },
27-
{ success: true, value: { a: 2 } },
26+
{ success: true, value: { a: 1 }, rawValue: { a: 1 } },
27+
{ success: true, value: { a: 2 }, rawValue: { a: 2 } },
2828
]);
2929
});
3030

@@ -45,7 +45,7 @@ describe('createJsonStreamResponseHandler', () => {
4545
});
4646

4747
expect(await convertReadableStreamToArray(stream)).toStrictEqual([
48-
{ success: true, value: { a: 1 } },
48+
{ success: true, value: { a: 1 }, rawValue: { a: 1 } },
4949
]);
5050
});
5151
});

packages/ui-utils/src/parse-partial-json.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ it('should handle nullish input', () => {
1616
it('should parse valid JSON', () => {
1717
const validJson = '{"key": "value"}';
1818
const parsedValue = { key: 'value' };
19+
1920
vi.mocked(safeParseJSON).mockReturnValueOnce({
2021
success: true,
2122
value: parsedValue,
23+
rawValue: parsedValue,
2224
});
2325

2426
expect(parsePartialJson(validJson)).toEqual({
@@ -38,7 +40,11 @@ it('should repair and parse partial JSON', () => {
3840
success: false,
3941
error: new JSONParseError({ text: partialJson, cause: undefined }),
4042
})
41-
.mockReturnValueOnce({ success: true, value: parsedValue });
43+
.mockReturnValueOnce({
44+
success: true,
45+
value: parsedValue,
46+
rawValue: parsedValue,
47+
});
4248
vi.mocked(fixJson).mockReturnValueOnce(fixedJson);
4349

4450
expect(parsePartialJson(partialJson)).toEqual({

0 commit comments

Comments
 (0)