Skip to content

Commit

Permalink
feat (provider-utils): include raw value in json parse results (verce…
Browse files Browse the repository at this point in the history
  • Loading branch information
shaper authored Jan 17, 2025
1 parent 47ccf90 commit e7a9ec9
Show file tree
Hide file tree
Showing 6 changed files with 220 additions and 13 deletions.
7 changes: 7 additions & 0 deletions .changeset/gentle-mirrors-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@ai-sdk/ui-utils': patch
'@ai-sdk/provider-utils': patch
'@ai-sdk/openai': patch
---

feat (provider-utils): include raw value in json parse results
7 changes: 7 additions & 0 deletions packages/openai/src/openai-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ describe('openaiErrorDataSchema', () => {
code: 429,
},
},
rawValue: {
error: {
message:
'{\n "error": {\n "code": 429,\n "message": "Resource has been exhausted (e.g. check quota).",\n "status": "RESOURCE_EXHAUSTED"\n }\n}\n',
code: 429,
},
},
});
});
});
188 changes: 188 additions & 0 deletions packages/provider-utils/src/parse-json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { describe, it, expect } from 'vitest';
import { parseJSON, safeParseJSON, isParsableJson } from './parse-json';
import { z } from 'zod';
import { JSONParseError, TypeValidationError } from '@ai-sdk/provider';

describe('parseJSON', () => {
it('should parse basic JSON without schema', () => {
const result = parseJSON({ text: '{"foo": "bar"}' });
expect(result).toEqual({ foo: 'bar' });
});

it('should parse JSON with schema validation', () => {
const schema = z.object({ foo: z.string() });
const result = parseJSON({ text: '{"foo": "bar"}', schema });
expect(result).toEqual({ foo: 'bar' });
});

it('should throw JSONParseError for invalid JSON', () => {
expect(() => parseJSON({ text: 'invalid json' })).toThrow(JSONParseError);
});

it('should throw TypeValidationError for schema validation failures', () => {
const schema = z.object({ foo: z.number() });
expect(() => parseJSON({ text: '{"foo": "bar"}', schema })).toThrow(
TypeValidationError,
);
});
});

describe('safeParseJSON', () => {
it('should safely parse basic JSON without schema and include rawValue', () => {
const result = safeParseJSON({ text: '{"foo": "bar"}' });
expect(result).toEqual({
success: true,
value: { foo: 'bar' },
rawValue: { foo: 'bar' },
});
});

it('should preserve rawValue even after schema transformation', () => {
const schema = z.object({
count: z.coerce.number(),
});
const result = safeParseJSON({
text: '{"count": "42"}',
schema,
});

expect(result).toEqual({
success: true,
value: { count: 42 },
rawValue: { count: '42' },
});
});

it('should handle failed parsing with error details', () => {
const result = safeParseJSON({ text: 'invalid json' });
expect(result).toEqual({
success: false,
error: expect.any(JSONParseError),
});
});

it('should handle schema validation failures', () => {
const schema = z.object({ age: z.number() });
const result = safeParseJSON({
text: '{"age": "twenty"}',
schema,
});

expect(result).toEqual({
success: false,
error: expect.any(TypeValidationError),
});
});

it('should handle nested objects and preserve raw values', () => {
const schema = z.object({
user: z.object({
id: z.string().transform(val => parseInt(val, 10)),
name: z.string(),
}),
});

const result = safeParseJSON({
text: '{"user": {"id": "123", "name": "John"}}',
schema: schema as any,
});

expect(result).toEqual({
success: true,
value: { user: { id: 123, name: 'John' } },
rawValue: { user: { id: '123', name: 'John' } },
});
});

it('should handle arrays and preserve raw values', () => {
const schema = z.array(z.string().transform(val => val.toUpperCase()));
const result = safeParseJSON({
text: '["hello", "world"]',
schema,
});

expect(result).toEqual({
success: true,
value: ['HELLO', 'WORLD'],
rawValue: ['hello', 'world'],
});
});

it('should handle discriminated unions in schema', () => {
const schema = z.discriminatedUnion('type', [
z.object({ type: z.literal('text'), content: z.string() }),
z.object({ type: z.literal('number'), value: z.number() }),
]);

const result = safeParseJSON({
text: '{"type": "text", "content": "hello"}',
schema,
});

expect(result).toEqual({
success: true,
value: { type: 'text', content: 'hello' },
rawValue: { type: 'text', content: 'hello' },
});
});

it('should handle nullable fields in schema', () => {
const schema = z.object({
id: z.string().nullish(),
data: z.string(),
});

const result = safeParseJSON({
text: '{"id": null, "data": "test"}',
schema,
});

expect(result).toEqual({
success: true,
value: { id: null, data: 'test' },
rawValue: { id: null, data: 'test' },
});
});

it('should handle union types in schema', () => {
const schema = z.object({
value: z.union([z.string(), z.number()]),
});

const result1 = safeParseJSON({
text: '{"value": "test"}',
schema,
});

const result2 = safeParseJSON({
text: '{"value": 123}',
schema,
});

expect(result1).toEqual({
success: true,
value: { value: 'test' },
rawValue: { value: 'test' },
});

expect(result2).toEqual({
success: true,
value: { value: 123 },
rawValue: { value: 123 },
});
});
});

describe('isParsableJson', () => {
it('should return true for valid JSON', () => {
expect(isParsableJson('{"foo": "bar"}')).toBe(true);
expect(isParsableJson('[1, 2, 3]')).toBe(true);
expect(isParsableJson('"hello"')).toBe(true);
});

it('should return false for invalid JSON', () => {
expect(isParsableJson('invalid')).toBe(false);
expect(isParsableJson('{foo: "bar"}')).toBe(false);
expect(isParsableJson('{"foo": }')).toBe(false);
});
});
17 changes: 8 additions & 9 deletions packages/provider-utils/src/parse-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function parseJSON<T>({
}

export type ParseResult<T> =
| { success: true; value: T }
| { success: true; value: T; rawValue: unknown }
| { success: false; error: JSONParseError | TypeValidationError };

/**
Expand Down Expand Up @@ -89,20 +89,19 @@ export function safeParseJSON<T>({
}: {
text: string;
schema?: ZodSchema<T> | Validator<T>;
}):
| { success: true; value: T }
| { success: false; error: JSONParseError | TypeValidationError } {
}): ParseResult<T> {
try {
const value = SecureJSON.parse(text);

if (schema == null) {
return {
success: true,
value: value as T,
};
return { success: true, value: value as T, rawValue: value };
}

return safeValidateTypes({ value, schema });
const validationResult = safeValidateTypes({ value, schema });

return validationResult.success
? { ...validationResult, rawValue: value }
: validationResult;
} catch (error) {
return {
success: false,
Expand Down
6 changes: 3 additions & 3 deletions packages/provider-utils/src/response-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ describe('createJsonStreamResponseHandler', () => {
});

expect(await convertReadableStreamToArray(stream)).toStrictEqual([
{ success: true, value: { a: 1 } },
{ success: true, value: { a: 2 } },
{ success: true, value: { a: 1 }, rawValue: { a: 1 } },
{ success: true, value: { a: 2 }, rawValue: { a: 2 } },
]);
});

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

expect(await convertReadableStreamToArray(stream)).toStrictEqual([
{ success: true, value: { a: 1 } },
{ success: true, value: { a: 1 }, rawValue: { a: 1 } },
]);
});
});
8 changes: 7 additions & 1 deletion packages/ui-utils/src/parse-partial-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ it('should handle nullish input', () => {
it('should parse valid JSON', () => {
const validJson = '{"key": "value"}';
const parsedValue = { key: 'value' };

vi.mocked(safeParseJSON).mockReturnValueOnce({
success: true,
value: parsedValue,
rawValue: parsedValue,
});

expect(parsePartialJson(validJson)).toEqual({
Expand All @@ -38,7 +40,11 @@ it('should repair and parse partial JSON', () => {
success: false,
error: new JSONParseError({ text: partialJson, cause: undefined }),
})
.mockReturnValueOnce({ success: true, value: parsedValue });
.mockReturnValueOnce({
success: true,
value: parsedValue,
rawValue: parsedValue,
});
vi.mocked(fixJson).mockReturnValueOnce(fixedJson);

expect(parsePartialJson(partialJson)).toEqual({
Expand Down

0 comments on commit e7a9ec9

Please sign in to comment.