diff --git a/.changeset/gentle-mirrors-repeat.md b/.changeset/gentle-mirrors-repeat.md new file mode 100644 index 000000000000..cfa3dba2fb75 --- /dev/null +++ b/.changeset/gentle-mirrors-repeat.md @@ -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 diff --git a/packages/openai/src/openai-error.test.ts b/packages/openai/src/openai-error.test.ts index f7a2e19c4b41..a877639ff366 100644 --- a/packages/openai/src/openai-error.test.ts +++ b/packages/openai/src/openai-error.test.ts @@ -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, + }, + }, }); }); }); diff --git a/packages/provider-utils/src/parse-json.test.ts b/packages/provider-utils/src/parse-json.test.ts new file mode 100644 index 000000000000..d84e68c022d2 --- /dev/null +++ b/packages/provider-utils/src/parse-json.test.ts @@ -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); + }); +}); diff --git a/packages/provider-utils/src/parse-json.ts b/packages/provider-utils/src/parse-json.ts index fb622522491f..8fb2035bb095 100644 --- a/packages/provider-utils/src/parse-json.ts +++ b/packages/provider-utils/src/parse-json.ts @@ -58,7 +58,7 @@ export function parseJSON({ } export type ParseResult = - | { success: true; value: T } + | { success: true; value: T; rawValue: unknown } | { success: false; error: JSONParseError | TypeValidationError }; /** @@ -89,20 +89,19 @@ export function safeParseJSON({ }: { text: string; schema?: ZodSchema | Validator; -}): - | { success: true; value: T } - | { success: false; error: JSONParseError | TypeValidationError } { +}): ParseResult { 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, diff --git a/packages/provider-utils/src/response-handler.test.ts b/packages/provider-utils/src/response-handler.test.ts index 256ecad92d72..c427b1992c4a 100644 --- a/packages/provider-utils/src/response-handler.test.ts +++ b/packages/provider-utils/src/response-handler.test.ts @@ -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 } }, ]); }); @@ -45,7 +45,7 @@ describe('createJsonStreamResponseHandler', () => { }); expect(await convertReadableStreamToArray(stream)).toStrictEqual([ - { success: true, value: { a: 1 } }, + { success: true, value: { a: 1 }, rawValue: { a: 1 } }, ]); }); }); diff --git a/packages/ui-utils/src/parse-partial-json.test.ts b/packages/ui-utils/src/parse-partial-json.test.ts index 928e1fc13c1a..6f16d49ab7b5 100644 --- a/packages/ui-utils/src/parse-partial-json.test.ts +++ b/packages/ui-utils/src/parse-partial-json.test.ts @@ -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({ @@ -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({