From 99a6194f68ff1d04f357f26a50abc250ecd63300 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Thu, 30 Oct 2025 17:47:10 +0000 Subject: [PATCH 1/4] feat! : Support invoke with structured output in VercelAI provider --- .../__tests__/VercelProvider.test.ts | 158 +++++++++++++++++- .../server-ai-vercel/package.json | 6 +- .../server-ai-vercel/src/VercelProvider.ts | 98 ++++++++++- 3 files changed, 258 insertions(+), 4 deletions(-) diff --git a/packages/ai-providers/server-ai-vercel/__tests__/VercelProvider.test.ts b/packages/ai-providers/server-ai-vercel/__tests__/VercelProvider.test.ts index 5dc97bb70..02118d29a 100644 --- a/packages/ai-providers/server-ai-vercel/__tests__/VercelProvider.test.ts +++ b/packages/ai-providers/server-ai-vercel/__tests__/VercelProvider.test.ts @@ -1,10 +1,11 @@ -import { generateText } from 'ai'; +import { generateObject, generateText } from 'ai'; import { VercelProvider } from '../src/VercelProvider'; // Mock Vercel AI SDK jest.mock('ai', () => ({ generateText: jest.fn(), + generateObject: jest.fn(), })); describe('VercelProvider', () => { @@ -116,6 +117,76 @@ describe('VercelProvider', () => { }); }); + describe('convertToZodSchema', () => { + it('converts simple object structure to Zod schema', () => { + const responseStructure = { + name: 'string', + age: 0, + isActive: true, + }; + + const schema = VercelProvider.convertToZodSchema(responseStructure); + + expect(schema).toBeDefined(); + expect(typeof schema.parse).toBe('function'); + }); + + it('converts nested object structure to Zod schema', () => { + const responseStructure = { + user: { + name: 'string', + age: 0, + }, + settings: { + theme: 'string', + notifications: true, + }, + }; + + const schema = VercelProvider.convertToZodSchema(responseStructure); + + expect(schema).toBeDefined(); + expect(typeof schema.parse).toBe('function'); + }); + + it('converts array structure to Zod schema', () => { + const responseStructure = { + items: ['string'], + numbers: [0], + booleans: [true], + }; + + const schema = VercelProvider.convertToZodSchema(responseStructure); + + expect(schema).toBeDefined(); + expect(typeof schema.parse).toBe('function'); + }); + + it('handles empty array structure', () => { + const responseStructure = { + items: [], + }; + + const schema = VercelProvider.convertToZodSchema(responseStructure); + + expect(schema).toBeDefined(); + expect(typeof schema.parse).toBe('function'); + }); + + it('handles null and undefined values', () => { + const responseStructure = { + nullable: null, + undefined, + string: 'string', + }; + + const schema = VercelProvider.convertToZodSchema(responseStructure); + + expect(schema).toBeDefined(); + expect(typeof schema.parse).toBe('function'); + }); + }); + describe('invokeModel', () => { it('invokes Vercel AI generateText and returns response', async () => { const mockResponse = { @@ -178,6 +249,91 @@ describe('VercelProvider', () => { }); }); + describe('invokeStructuredModel', () => { + it('invokes Vercel AI generateObject and returns structured response', async () => { + const mockResponse = { + object: { + name: 'John Doe', + age: 30, + isActive: true, + }, + usage: { + promptTokens: 10, + completionTokens: 15, + totalTokens: 25, + }, + }; + + (generateObject as jest.Mock).mockResolvedValue(mockResponse); + + const messages = [{ role: 'user' as const, content: 'Generate user data' }]; + const responseStructure = { + name: 'string', + age: 0, + isActive: true, + }; + + const result = await provider.invokeStructuredModel(messages, responseStructure); + + expect(generateObject).toHaveBeenCalledWith({ + model: mockModel, + messages: [{ role: 'user', content: 'Generate user data' }], + schema: expect.any(Object), // Zod schema + }); + + expect(result).toEqual({ + data: { + name: 'John Doe', + age: 30, + isActive: true, + }, + rawResponse: JSON.stringify({ + name: 'John Doe', + age: 30, + isActive: true, + }), + metrics: { + success: true, + usage: { + total: 25, + input: 10, + output: 15, + }, + }, + }); + }); + + it('handles structured response without usage data', async () => { + const mockResponse = { + object: { + result: 'success', + }, + }; + + (generateObject as jest.Mock).mockResolvedValue(mockResponse); + + const messages = [{ role: 'user' as const, content: 'Generate result' }]; + const responseStructure = { + result: 'string', + }; + + const result = await provider.invokeStructuredModel(messages, responseStructure); + + expect(result).toEqual({ + data: { + result: 'success', + }, + rawResponse: JSON.stringify({ + result: 'success', + }), + metrics: { + success: true, + usage: undefined, + }, + }); + }); + }); + describe('getModel', () => { it('returns the underlying Vercel AI model', () => { const model = provider.getModel(); diff --git a/packages/ai-providers/server-ai-vercel/package.json b/packages/ai-providers/server-ai-vercel/package.json index 7ad92a678..934da66ed 100644 --- a/packages/ai-providers/server-ai-vercel/package.json +++ b/packages/ai-providers/server-ai-vercel/package.json @@ -48,7 +48,8 @@ "jest": "^29.6.1", "prettier": "^3.0.0", "ts-jest": "^29.1.1", - "typescript": "5.1.6" + "typescript": "5.1.6", + "zod": "^3.22.0" }, "peerDependencies": { "@ai-sdk/anthropic": "^2.0.0", @@ -57,7 +58,8 @@ "@ai-sdk/mistral": "^2.0.0", "@ai-sdk/openai": "^2.0.0", "@launchdarkly/server-sdk-ai": "^0.12.2", - "ai": "^4.0.0 || ^5.0.0" + "ai": "^4.0.0 || ^5.0.0", + "zod": "^3.22.0" }, "peerDependenciesMeta": { "@ai-sdk/anthropic": { diff --git a/packages/ai-providers/server-ai-vercel/src/VercelProvider.ts b/packages/ai-providers/server-ai-vercel/src/VercelProvider.ts index fc13ae701..c6c105b98 100644 --- a/packages/ai-providers/server-ai-vercel/src/VercelProvider.ts +++ b/packages/ai-providers/server-ai-vercel/src/VercelProvider.ts @@ -1,4 +1,5 @@ -import { generateText, LanguageModel } from 'ai'; +import { generateObject, generateText, LanguageModel } from 'ai'; +import { z } from 'zod'; import { AIProvider } from '@launchdarkly/server-sdk-ai'; import type { @@ -8,6 +9,7 @@ import type { LDLogger, LDMessage, LDTokenUsage, + StructuredResponse, } from '@launchdarkly/server-sdk-ai'; /** @@ -69,6 +71,34 @@ export class VercelProvider extends AIProvider { }; } + /** + * Invoke the Vercel AI model with structured output support. + */ + async invokeStructuredModel( + messages: LDMessage[], + responseStructure: Record, + ): Promise { + // Convert responseStructure to Zod schema + const schema = VercelProvider.convertToZodSchema(responseStructure); + + // Call Vercel AI generateObject + const result = await generateObject({ + model: this._model, + messages, + schema, + ...this._parameters, + }); + + // Extract metrics including token usage and success status + const metrics = VercelProvider.createAIMetrics(result); + + return { + data: result.object, + rawResponse: JSON.stringify(result.object), + metrics, + }; + } + /** * Get the underlying Vercel AI model instance. */ @@ -121,6 +151,72 @@ export class VercelProvider extends AIProvider { }; } + /** + * Convert a response structure object to a Zod schema. + * This method recursively converts a generic object structure to a Zod schema + * that can be used with Vercel AI SDK's generateObject function. + * + * @param responseStructure The structure object to convert + * @returns A Zod schema representing the structure + */ + static convertToZodSchema(responseStructure: Record): z.ZodSchema { + const shape: Record = {}; + + Object.entries(responseStructure).forEach(([key, value]) => { + // eslint-disable-next-line no-underscore-dangle + shape[key] = VercelProvider._convertValueToZodSchema(value); + }); + + return z.object(shape); + } + + /** + * Convert a single value to a Zod schema. + * This is a helper method for convertToZodSchema that handles different value types. + * + * @param value The value to convert + * @returns A Zod schema for the value + */ + private static _convertValueToZodSchema(value: unknown): z.ZodSchema { + if (value === null || value === undefined) { + return z.any(); + } + + if (typeof value === 'string') { + return z.string(); + } + + if (typeof value === 'number') { + return z.number(); + } + + if (typeof value === 'boolean') { + return z.boolean(); + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return z.array(z.any()); + } + // Use the first element to determine the array type + // eslint-disable-next-line no-underscore-dangle + const elementSchema = VercelProvider._convertValueToZodSchema(value[0]); + return z.array(elementSchema); + } + + if (typeof value === 'object') { + const shape: Record = {}; + Object.entries(value).forEach(([key, val]) => { + // eslint-disable-next-line no-underscore-dangle + shape[key] = VercelProvider._convertValueToZodSchema(val); + }); + return z.object(shape); + } + + // Fallback for any other type + return z.any(); + } + /** * Create a Vercel AI model from an AI configuration. * This method creates a Vercel AI model based on the provider configuration. From 50309758baac3461ce7fd1af52168c7aad0157d8 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Wed, 5 Nov 2025 14:05:43 +0000 Subject: [PATCH 2/4] remove zod dependency and fix object structure --- .../server-ai-vercel/package.json | 6 +- .../server-ai-vercel/src/VercelProvider.ts | 160 ++++++------------ 2 files changed, 58 insertions(+), 108 deletions(-) diff --git a/packages/ai-providers/server-ai-vercel/package.json b/packages/ai-providers/server-ai-vercel/package.json index 934da66ed..7ad92a678 100644 --- a/packages/ai-providers/server-ai-vercel/package.json +++ b/packages/ai-providers/server-ai-vercel/package.json @@ -48,8 +48,7 @@ "jest": "^29.6.1", "prettier": "^3.0.0", "ts-jest": "^29.1.1", - "typescript": "5.1.6", - "zod": "^3.22.0" + "typescript": "5.1.6" }, "peerDependencies": { "@ai-sdk/anthropic": "^2.0.0", @@ -58,8 +57,7 @@ "@ai-sdk/mistral": "^2.0.0", "@ai-sdk/openai": "^2.0.0", "@launchdarkly/server-sdk-ai": "^0.12.2", - "ai": "^4.0.0 || ^5.0.0", - "zod": "^3.22.0" + "ai": "^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { "@ai-sdk/anthropic": { diff --git a/packages/ai-providers/server-ai-vercel/src/VercelProvider.ts b/packages/ai-providers/server-ai-vercel/src/VercelProvider.ts index c6c105b98..e2b7bcca6 100644 --- a/packages/ai-providers/server-ai-vercel/src/VercelProvider.ts +++ b/packages/ai-providers/server-ai-vercel/src/VercelProvider.ts @@ -1,5 +1,4 @@ -import { generateObject, generateText, LanguageModel } from 'ai'; -import { z } from 'zod'; +import { generateObject, generateText, jsonSchema, LanguageModel } from 'ai'; import { AIProvider } from '@launchdarkly/server-sdk-ai'; import type { @@ -47,28 +46,40 @@ export class VercelProvider extends AIProvider { * Invoke the Vercel AI model with an array of messages. */ async invokeModel(messages: LDMessage[]): Promise { - // Call Vercel AI generateText - // Type assertion: our MinLanguageModel is compatible with the expected LanguageModel interface - // The generateText function will work with any object that has the required properties - const result = await generateText({ - model: this._model, - messages, - ...this._parameters, - }); + try { + // Call Vercel AI generateText + const result = await generateText({ + model: this._model, + messages, + ...this._parameters, + }); - // Create the assistant message - const assistantMessage: LDMessage = { - role: 'assistant', - content: result.text, - }; + // Create the assistant message + const assistantMessage: LDMessage = { + role: 'assistant', + content: result.text, + }; - // Extract metrics including token usage and success status - const metrics = VercelProvider.createAIMetrics(result); + // Extract metrics including token usage and success status + const metrics = VercelProvider.createAIMetrics(result); - return { - message: assistantMessage, - metrics, - }; + return { + message: assistantMessage, + metrics, + }; + } catch (error) { + this.logger?.warn('Vercel AI model invocation failed:', error); + + return { + message: { + role: 'assistant', + content: '', + }, + metrics: { + success: false, + }, + }; + } } /** @@ -78,25 +89,32 @@ export class VercelProvider extends AIProvider { messages: LDMessage[], responseStructure: Record, ): Promise { - // Convert responseStructure to Zod schema - const schema = VercelProvider.convertToZodSchema(responseStructure); - - // Call Vercel AI generateObject - const result = await generateObject({ - model: this._model, - messages, - schema, - ...this._parameters, - }); + try { + const result = await generateObject({ + model: this._model, + messages, + schema: jsonSchema(responseStructure), + ...this._parameters, + }); - // Extract metrics including token usage and success status - const metrics = VercelProvider.createAIMetrics(result); + const metrics = VercelProvider.createAIMetrics(result); - return { - data: result.object, - rawResponse: JSON.stringify(result.object), - metrics, - }; + return { + data: result.object as Record, + rawResponse: JSON.stringify(result.object), + metrics, + }; + } catch (error) { + this.logger?.warn('Vercel AI structured model invocation failed:', error); + + return { + data: {}, + rawResponse: '', + metrics: { + success: false, + }, + }; + } } /** @@ -151,72 +169,6 @@ export class VercelProvider extends AIProvider { }; } - /** - * Convert a response structure object to a Zod schema. - * This method recursively converts a generic object structure to a Zod schema - * that can be used with Vercel AI SDK's generateObject function. - * - * @param responseStructure The structure object to convert - * @returns A Zod schema representing the structure - */ - static convertToZodSchema(responseStructure: Record): z.ZodSchema { - const shape: Record = {}; - - Object.entries(responseStructure).forEach(([key, value]) => { - // eslint-disable-next-line no-underscore-dangle - shape[key] = VercelProvider._convertValueToZodSchema(value); - }); - - return z.object(shape); - } - - /** - * Convert a single value to a Zod schema. - * This is a helper method for convertToZodSchema that handles different value types. - * - * @param value The value to convert - * @returns A Zod schema for the value - */ - private static _convertValueToZodSchema(value: unknown): z.ZodSchema { - if (value === null || value === undefined) { - return z.any(); - } - - if (typeof value === 'string') { - return z.string(); - } - - if (typeof value === 'number') { - return z.number(); - } - - if (typeof value === 'boolean') { - return z.boolean(); - } - - if (Array.isArray(value)) { - if (value.length === 0) { - return z.array(z.any()); - } - // Use the first element to determine the array type - // eslint-disable-next-line no-underscore-dangle - const elementSchema = VercelProvider._convertValueToZodSchema(value[0]); - return z.array(elementSchema); - } - - if (typeof value === 'object') { - const shape: Record = {}; - Object.entries(value).forEach(([key, val]) => { - // eslint-disable-next-line no-underscore-dangle - shape[key] = VercelProvider._convertValueToZodSchema(val); - }); - return z.object(shape); - } - - // Fallback for any other type - return z.any(); - } - /** * Create a Vercel AI model from an AI configuration. * This method creates a Vercel AI model based on the provider configuration. From d9b912f45372bb987e60d03e3674c2e24b778a1d Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Wed, 5 Nov 2025 15:02:46 +0000 Subject: [PATCH 3/4] fix unit test --- .../__tests__/VercelProvider.test.ts | 130 ++++++++---------- 1 file changed, 58 insertions(+), 72 deletions(-) diff --git a/packages/ai-providers/server-ai-vercel/__tests__/VercelProvider.test.ts b/packages/ai-providers/server-ai-vercel/__tests__/VercelProvider.test.ts index 02118d29a..9afaad0ad 100644 --- a/packages/ai-providers/server-ai-vercel/__tests__/VercelProvider.test.ts +++ b/packages/ai-providers/server-ai-vercel/__tests__/VercelProvider.test.ts @@ -1,4 +1,4 @@ -import { generateObject, generateText } from 'ai'; +import { generateObject, generateText, jsonSchema } from 'ai'; import { VercelProvider } from '../src/VercelProvider'; @@ -6,6 +6,7 @@ import { VercelProvider } from '../src/VercelProvider'; jest.mock('ai', () => ({ generateText: jest.fn(), generateObject: jest.fn(), + jsonSchema: jest.fn((schema) => schema), })); describe('VercelProvider', () => { @@ -15,6 +16,7 @@ describe('VercelProvider', () => { beforeEach(() => { mockModel = { name: 'test-model' }; provider = new VercelProvider(mockModel, {}); + jest.clearAllMocks(); }); describe('createAIMetrics', () => { @@ -117,76 +119,6 @@ describe('VercelProvider', () => { }); }); - describe('convertToZodSchema', () => { - it('converts simple object structure to Zod schema', () => { - const responseStructure = { - name: 'string', - age: 0, - isActive: true, - }; - - const schema = VercelProvider.convertToZodSchema(responseStructure); - - expect(schema).toBeDefined(); - expect(typeof schema.parse).toBe('function'); - }); - - it('converts nested object structure to Zod schema', () => { - const responseStructure = { - user: { - name: 'string', - age: 0, - }, - settings: { - theme: 'string', - notifications: true, - }, - }; - - const schema = VercelProvider.convertToZodSchema(responseStructure); - - expect(schema).toBeDefined(); - expect(typeof schema.parse).toBe('function'); - }); - - it('converts array structure to Zod schema', () => { - const responseStructure = { - items: ['string'], - numbers: [0], - booleans: [true], - }; - - const schema = VercelProvider.convertToZodSchema(responseStructure); - - expect(schema).toBeDefined(); - expect(typeof schema.parse).toBe('function'); - }); - - it('handles empty array structure', () => { - const responseStructure = { - items: [], - }; - - const schema = VercelProvider.convertToZodSchema(responseStructure); - - expect(schema).toBeDefined(); - expect(typeof schema.parse).toBe('function'); - }); - - it('handles null and undefined values', () => { - const responseStructure = { - nullable: null, - undefined, - string: 'string', - }; - - const schema = VercelProvider.convertToZodSchema(responseStructure); - - expect(schema).toBeDefined(); - expect(typeof schema.parse).toBe('function'); - }); - }); - describe('invokeModel', () => { it('invokes Vercel AI generateText and returns response', async () => { const mockResponse = { @@ -247,6 +179,30 @@ describe('VercelProvider', () => { }, }); }); + + it('handles errors and returns failure metrics', async () => { + const mockError = new Error('API call failed'); + (generateText as jest.Mock).mockRejectedValue(mockError); + + const mockLogger = { + warn: jest.fn(), + }; + provider = new VercelProvider(mockModel, {}, mockLogger as any); + + const messages = [{ role: 'user' as const, content: 'Hello!' }]; + const result = await provider.invokeModel(messages); + + expect(mockLogger.warn).toHaveBeenCalledWith('Vercel AI model invocation failed:', mockError); + expect(result).toEqual({ + message: { + role: 'assistant', + content: '', + }, + metrics: { + success: false, + }, + }); + }); }); describe('invokeStructuredModel', () => { @@ -278,8 +234,9 @@ describe('VercelProvider', () => { expect(generateObject).toHaveBeenCalledWith({ model: mockModel, messages: [{ role: 'user', content: 'Generate user data' }], - schema: expect.any(Object), // Zod schema + schema: responseStructure, }); + expect(jsonSchema).toHaveBeenCalledWith(responseStructure); expect(result).toEqual({ data: { @@ -332,6 +289,35 @@ describe('VercelProvider', () => { }, }); }); + + it('handles errors and returns failure metrics', async () => { + const mockError = new Error('API call failed'); + (generateObject as jest.Mock).mockRejectedValue(mockError); + + const mockLogger = { + warn: jest.fn(), + }; + provider = new VercelProvider(mockModel, {}, mockLogger as any); + + const messages = [{ role: 'user' as const, content: 'Generate result' }]; + const responseStructure = { + result: 'string', + }; + + const result = await provider.invokeStructuredModel(messages, responseStructure); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Vercel AI structured model invocation failed:', + mockError, + ); + expect(result).toEqual({ + data: {}, + rawResponse: '', + metrics: { + success: false, + }, + }); + }); }); describe('getModel', () => { From ba02ced6521fd8e3436b3b4ca667e481fc391779 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Wed, 5 Nov 2025 20:47:26 +0000 Subject: [PATCH 4/4] user parameters as defaults and not overrides --- packages/ai-providers/server-ai-vercel/src/VercelProvider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ai-providers/server-ai-vercel/src/VercelProvider.ts b/packages/ai-providers/server-ai-vercel/src/VercelProvider.ts index bf0e96ffb..542d87dee 100644 --- a/packages/ai-providers/server-ai-vercel/src/VercelProvider.ts +++ b/packages/ai-providers/server-ai-vercel/src/VercelProvider.ts @@ -72,9 +72,9 @@ export class VercelProvider extends AIProvider { try { // Call Vercel AI generateText const result = await generateText({ + ...this._parameters, model: this._model, messages, - ...this._parameters, }); // Create the assistant message @@ -114,10 +114,10 @@ export class VercelProvider extends AIProvider { ): Promise { try { const result = await generateObject({ + ...this._parameters, model: this._model, messages, schema: jsonSchema(responseStructure), - ...this._parameters, }); const metrics = VercelProvider.createAIMetrics(result);