diff --git a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts index e2a18b2c2..6c75df56a 100644 --- a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts +++ b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts @@ -1,6 +1,12 @@ import { LDContext } from '@launchdarkly/js-server-sdk-common'; -import { LDAIDefaults } from '../src/api/config'; +import { LDAIAgentDefaults } from '../src/api/agents'; +import { + LDAIDefaults, + VercelAISDKConfig, + VercelAISDKMapOptions, + VercelAISDKProvider, +} from '../src/api/config'; import { LDAIClientImpl } from '../src/LDAIClientImpl'; import { LDClientMin } from '../src/LDClientMin'; @@ -79,6 +85,7 @@ it('includes context in variables for messages interpolation', async () => { const result = await client.config(key, testContext, defaultValue); expect(result.messages?.[0].content).toBe('User key: test-user'); + expect(result.toVercelAISDK).toEqual(expect.any(Function)); }); it('handles missing metadata in variation', async () => { @@ -132,3 +139,323 @@ it('passes the default value to the underlying client', async () => { expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, defaultValue); }); + +// New agent-related tests +it('returns single agent config with interpolated instructions', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'test-agent'; + const defaultValue: LDAIAgentDefaults = { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a helpful assistant.', + enabled: true, + toVercelAISDK: ( + provider: VercelAISDKProvider | Record>, + options?: VercelAISDKMapOptions, + ): VercelAISDKConfig => { + const modelProvider = typeof provider === 'function' ? provider : provider.test; + return { + model: modelProvider('test-model'), + messages: [], + ...(options?.nonInterpolatedMessages + ? { + messages: options.nonInterpolatedMessages, + } + : {}), + }; + }, + }; + + const mockVariation = { + model: { + name: 'example-model', + parameters: { name: 'imagination', temperature: 0.7, maxTokens: 4096 }, + }, + provider: { + name: 'example-provider', + }, + instructions: 'You are a helpful assistant. Your name is {{name}} and your score is {{score}}', + _ldMeta: { + variationKey: 'v1', + enabled: true, + mode: 'agent', + }, + }; + + mockLdClient.variation.mockResolvedValue(mockVariation); + + const variables = { name: 'John', score: 42 }; + const result = await client.agent(key, testContext, defaultValue, variables); + + expect(result).toEqual({ + model: { + name: 'example-model', + parameters: { name: 'imagination', temperature: 0.7, maxTokens: 4096 }, + }, + provider: { + name: 'example-provider', + }, + instructions: 'You are a helpful assistant. Your name is John and your score is 42', + tracker: expect.any(Object), + enabled: true, + toVercelAISDK: expect.any(Function), + }); + + // Verify tracking was called + expect(mockLdClient.track).toHaveBeenCalledWith( + '$ld:ai:agent:function:single', + testContext, + key, + 1, + ); +}); + +it('includes context in variables for agent instructions interpolation', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'test-agent'; + const defaultValue: LDAIAgentDefaults = { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a helpful assistant.', + enabled: true, + toVercelAISDK: ( + provider: VercelAISDKProvider | Record>, + options?: VercelAISDKMapOptions, + ): VercelAISDKConfig => { + const modelProvider = typeof provider === 'function' ? provider : provider.test; + return { + model: modelProvider('test-model'), + messages: [], + ...(options?.nonInterpolatedMessages + ? { + messages: options.nonInterpolatedMessages, + } + : {}), + }; + }, + }; + + const mockVariation = { + instructions: 'You are a helpful assistant. Your user key is {{ldctx.key}}', + _ldMeta: { variationKey: 'v1', enabled: true, mode: 'agent' }, + }; + + mockLdClient.variation.mockResolvedValue(mockVariation); + + const result = await client.agent(key, testContext, defaultValue); + + expect(result.instructions).toBe('You are a helpful assistant. Your user key is test-user'); +}); + +it('handles missing metadata in agent variation', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'test-agent'; + const defaultValue: LDAIAgentDefaults = { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a helpful assistant.', + enabled: true, + toVercelAISDK: ( + provider: VercelAISDKProvider | Record>, + options?: VercelAISDKMapOptions, + ): VercelAISDKConfig => { + const modelProvider = typeof provider === 'function' ? provider : provider.test; + return { + model: modelProvider('test-model'), + messages: [], + ...(options?.nonInterpolatedMessages + ? { + messages: options.nonInterpolatedMessages, + } + : {}), + }; + }, + }; + + const mockVariation = { + model: { name: 'example-provider', parameters: { name: 'imagination' } }, + instructions: 'Hello.', + }; + + mockLdClient.variation.mockResolvedValue(mockVariation); + + const result = await client.agent(key, testContext, defaultValue); + + expect(result).toEqual({ + model: { name: 'example-provider', parameters: { name: 'imagination' } }, + instructions: 'Hello.', + tracker: expect.any(Object), + enabled: false, + toVercelAISDK: expect.any(Function), + }); +}); + +it('passes the default value to the underlying client for single agent', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'non-existent-agent'; + const defaultValue: LDAIAgentDefaults = { + model: { name: 'default-model', parameters: { name: 'default' } }, + provider: { name: 'default-provider' }, + instructions: 'Default instructions', + enabled: true, + toVercelAISDK: ( + provider: VercelAISDKProvider | Record>, + options?: VercelAISDKMapOptions, + ): VercelAISDKConfig => { + const modelProvider = + typeof provider === 'function' ? provider : provider['default-provider']; + return { + model: modelProvider('default-model'), + messages: [], + ...(options?.nonInterpolatedMessages + ? { + messages: options.nonInterpolatedMessages, + } + : {}), + }; + }, + }; + + mockLdClient.variation.mockResolvedValue(defaultValue); + + const result = await client.agent(key, testContext, defaultValue); + + expect(result).toEqual({ + model: defaultValue.model, + instructions: defaultValue.instructions, + provider: defaultValue.provider, + tracker: expect.any(Object), + enabled: false, + toVercelAISDK: expect.any(Function), + }); + + expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, defaultValue); +}); + +it('returns multiple agents config with interpolated instructions', async () => { + const client = new LDAIClientImpl(mockLdClient); + + const agentConfigs = [ + { + key: 'research-agent', + defaultValue: { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a research assistant.', + enabled: true, + toVercelAISDK: ( + provider: VercelAISDKProvider | Record>, + options?: VercelAISDKMapOptions, + ): VercelAISDKConfig => { + const modelProvider = typeof provider === 'function' ? provider : provider.test; + return { + model: modelProvider('test-model'), + messages: [], + ...(options?.nonInterpolatedMessages + ? { + messages: options.nonInterpolatedMessages, + } + : {}), + }; + }, + }, + variables: { topic: 'climate change' }, + }, + { + key: 'writing-agent', + defaultValue: { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a writing assistant.', + enabled: true, + toVercelAISDK: ( + provider: VercelAISDKProvider | Record>, + options?: VercelAISDKMapOptions, + ): VercelAISDKConfig => { + const modelProvider = typeof provider === 'function' ? provider : provider.test; + return { + model: modelProvider('test-model'), + messages: [], + ...(options?.nonInterpolatedMessages + ? { + messages: options.nonInterpolatedMessages, + } + : {}), + }; + }, + }, + variables: { style: 'academic' }, + }, + ] as const; + + const mockVariations = { + 'research-agent': { + model: { + name: 'research-model', + parameters: { temperature: 0.3, maxTokens: 2048 }, + }, + provider: { name: 'openai' }, + instructions: 'You are a research assistant specializing in {{topic}}.', + _ldMeta: { variationKey: 'v1', enabled: true, mode: 'agent' }, + }, + 'writing-agent': { + model: { + name: 'writing-model', + parameters: { temperature: 0.7, maxTokens: 1024 }, + }, + provider: { name: 'anthropic' }, + instructions: 'You are a writing assistant with {{style}} style.', + _ldMeta: { variationKey: 'v2', enabled: true, mode: 'agent' }, + }, + }; + + mockLdClient.variation.mockImplementation((key) => + Promise.resolve(mockVariations[key as keyof typeof mockVariations]), + ); + + const result = await client.agents(agentConfigs, testContext); + + expect(result).toEqual({ + 'research-agent': { + model: { + name: 'research-model', + parameters: { temperature: 0.3, maxTokens: 2048 }, + }, + provider: { name: 'openai' }, + instructions: 'You are a research assistant specializing in climate change.', + tracker: expect.any(Object), + enabled: true, + toVercelAISDK: expect.any(Function), + }, + 'writing-agent': { + model: { + name: 'writing-model', + parameters: { temperature: 0.7, maxTokens: 1024 }, + }, + provider: { name: 'anthropic' }, + instructions: 'You are a writing assistant with academic style.', + tracker: expect.any(Object), + enabled: true, + toVercelAISDK: expect.any(Function), + }, + }); + + // Verify tracking was called + expect(mockLdClient.track).toHaveBeenCalledWith( + '$ld:ai:agent:function:multiple', + testContext, + agentConfigs.length, + agentConfigs.length, + ); +}); + +it('handles empty agent configs array', async () => { + const client = new LDAIClientImpl(mockLdClient); + + const result = await client.agents([], testContext); + + expect(result).toEqual({}); + + // Verify tracking was called with 0 agents + expect(mockLdClient.track).toHaveBeenCalledWith( + '$ld:ai:agent:function:multiple', + testContext, + 0, + 0, + ); +}); diff --git a/packages/sdk/server-ai/src/LDAIClientImpl.ts b/packages/sdk/server-ai/src/LDAIClientImpl.ts index de2079053..331e74800 100644 --- a/packages/sdk/server-ai/src/LDAIClientImpl.ts +++ b/packages/sdk/server-ai/src/LDAIClientImpl.ts @@ -2,8 +2,10 @@ import * as Mustache from 'mustache'; import { LDContext } from '@launchdarkly/js-server-sdk-common'; +import { LDAIAgent, LDAIAgentConfig, LDAIAgentDefaults } from './api/agents'; import { LDAIConfig, + LDAIConfigTracker, LDAIDefaults, LDMessage, LDModelConfig, @@ -17,13 +19,16 @@ import { LDAIConfigMapper } from './LDAIConfigMapper'; import { LDAIConfigTrackerImpl } from './LDAIConfigTrackerImpl'; import { LDClientMin } from './LDClientMin'; +type Mode = 'completion' | 'agent'; + /** - * Metadata assorted with a model configuration variation. + * Metadata associated with a model configuration variation. */ interface LDMeta { variationKey: string; enabled: boolean; version?: number; + mode?: Mode; } /** @@ -33,10 +38,24 @@ interface LDMeta { interface VariationContent { model?: LDModelConfig; messages?: LDMessage[]; + instructions?: string; provider?: LDProviderConfig; _ldMeta?: LDMeta; } +/** + * The result of evaluating a configuration. + */ +interface EvaluationResult { + tracker: LDAIConfigTracker; + enabled: boolean; + model?: LDModelConfig; + provider?: LDProviderConfig; + messages?: LDMessage[]; + instructions?: string; + mode?: string; +} + export class LDAIClientImpl implements LDAIClient { constructor(private _ldClient: LDClientMin) {} @@ -44,13 +63,13 @@ export class LDAIClientImpl implements LDAIClient { return Mustache.render(template, variables, undefined, { escape: (item: any) => item }); } - async config( + private async _evaluate( key: string, context: LDContext, defaultValue: LDAIDefaults, - variables?: Record, - ): Promise { + ): Promise { const value: VariationContent = await this._ldClient.variation(key, context, defaultValue); + const tracker = new LDAIConfigTrackerImpl( this._ldClient, key, @@ -60,24 +79,96 @@ export class LDAIClientImpl implements LDAIClient { value._ldMeta?.version ?? 1, context, ); + // eslint-disable-next-line no-underscore-dangle const enabled = !!value._ldMeta?.enabled; + + return { + tracker, + enabled, + model: value.model, + provider: value.provider, + messages: value.messages, + instructions: value.instructions, + // eslint-disable-next-line no-underscore-dangle + mode: value._ldMeta?.mode ?? 'completion', + }; + } + + private async _evaluateAgent( + key: string, + context: LDContext, + defaultValue: LDAIAgentDefaults, + variables?: Record, + ): Promise { + const { tracker, enabled, model, provider, instructions } = await this._evaluate( + key, + context, + defaultValue, + ); + + const mapper = new LDAIConfigMapper(model, provider, undefined); + const agent: Omit = { + tracker, + enabled, + }; + + // We are going to modify the contents before returning them, so we make a copy. + // This isn't a deep copy and the application developer should not modify the returned content. + if (model) { + agent.model = { ...model }; + } + + if (provider) { + agent.provider = { ...provider }; + } + + const allVariables = { ...variables, ldctx: context }; + + if (instructions) { + agent.instructions = this._interpolateTemplate(instructions, allVariables); + } + + return { + ...agent, + toVercelAISDK: ( + sdkProvider: VercelAISDKProvider | Record>, + options?: VercelAISDKMapOptions | undefined, + ): VercelAISDKConfig => mapper.toVercelAISDK(sdkProvider, options), + }; + } + + async config( + key: string, + context: LDContext, + defaultValue: LDAIDefaults, + variables?: Record, + ): Promise { + const { + tracker, + enabled, + model, + provider: configProvider, + messages, + } = await this._evaluate(key, context, defaultValue); + const config: Omit = { tracker, enabled, }; + // We are going to modify the contents before returning them, so we make a copy. // This isn't a deep copy and the application developer should not modify the returned content. - if (value.model) { - config.model = { ...value.model }; + if (model) { + config.model = { ...model }; } - if (value.provider) { - config.provider = { ...value.provider }; + if (configProvider) { + config.provider = { ...configProvider }; } const allVariables = { ...variables, ldctx: context }; - if (value.messages) { - config.messages = value.messages.map((entry: any) => ({ + if (messages) { + config.messages = messages.map((entry: any) => ({ ...entry, content: this._interpolateTemplate(entry.content, allVariables), })); @@ -88,9 +179,50 @@ export class LDAIClientImpl implements LDAIClient { return { ...config, toVercelAISDK: ( - provider: VercelAISDKProvider | Record>, + sdkProvider: VercelAISDKProvider | Record>, options?: VercelAISDKMapOptions | undefined, - ): VercelAISDKConfig => mapper.toVercelAISDK(provider, options), + ): VercelAISDKConfig => mapper.toVercelAISDK(sdkProvider, options), }; } + + async agent( + key: string, + context: LDContext, + defaultValue: LDAIAgentDefaults, + variables?: Record, + ): Promise { + // Track agent usage + this._ldClient.track('$ld:ai:agent:function:single', context, key, 1); + + return this._evaluateAgent(key, context, defaultValue, variables); + } + + async agents( + agentConfigs: T, + context: LDContext, + ): Promise> { + // Track multiple agents usage + this._ldClient.track( + '$ld:ai:agent:function:multiple', + context, + agentConfigs.length, + agentConfigs.length, + ); + + const agents = {} as Record; + + await Promise.all( + agentConfigs.map(async (config) => { + const agent = await this._evaluateAgent( + config.key, + context, + config.defaultValue, + config.variables, + ); + agents[config.key as T[number]['key']] = agent; + }), + ); + + return agents; + } } diff --git a/packages/sdk/server-ai/src/api/LDAIClient.ts b/packages/sdk/server-ai/src/api/LDAIClient.ts index 4bf5f617e..be02e887d 100644 --- a/packages/sdk/server-ai/src/api/LDAIClient.ts +++ b/packages/sdk/server-ai/src/api/LDAIClient.ts @@ -1,5 +1,6 @@ import { LDContext } from '@launchdarkly/js-server-sdk-common'; +import { LDAIAgent, LDAIAgentConfig, LDAIAgentDefaults } from './agents'; import { LDAIConfig, LDAIDefaults } from './config/LDAIConfig'; /** @@ -63,4 +64,83 @@ export interface LDAIClient { defaultValue: LDAIDefaults, variables?: Record, ): Promise; + + /** + * Retrieves and processes a single AI Config agent based on the provided key, LaunchDarkly context, + * and variables. This includes the model configuration and the customized instructions. + * + * @param key The key of the AI Config agent. + * @param context The LaunchDarkly context object that contains relevant information about the + * current environment, user, or session. This context may influence how the configuration is + * processed or personalized. + * @param defaultValue A fallback value containing model configuration and instructions. + * @param variables A map of key-value pairs representing dynamic variables to be injected into + * the instructions. The keys correspond to placeholders within the template, and the values + * are the corresponding replacements. + * + * @returns An AI agent with customized `instructions` and a `tracker`. If the configuration + * cannot be accessed from LaunchDarkly, then the return value will include information from the + * `defaultValue`. The returned `tracker` can be used to track AI operation metrics (latency, token usage, etc.). + * + * @example + * ``` + * const key = "research_agent"; + * const context = {...}; + * const variables = { topic: 'climate change' }; + * const agent = await client.agent(key, context, { + * enabled: true, + * instructions: 'You are a research assistant.', + * }, variables); + * + * const researchResult = agent.instructions; // Interpolated instructions + * agent.tracker.trackSuccess(); + * ``` + */ + agent( + key: string, + context: LDContext, + defaultValue: LDAIAgentDefaults, + variables?: Record, + ): Promise; + + /** + * Retrieves and processes multiple AI Config agents based on the provided agent configurations + * and LaunchDarkly context. This includes the model configuration and the customized instructions. + * + * @param agentConfigs An array of agent configurations, each containing the agent key, default configuration, + * and variables for instructions interpolation. + * @param context The LaunchDarkly context object that contains relevant information about the + * current environment, user, or session. This context may influence how the configuration is + * processed or personalized. + * + * @returns A map of agent keys to their respective AI agents with customized `instructions` and `tracker`. + * If a configuration cannot be accessed from LaunchDarkly, then the return value will include information + * from the respective `defaultValue`. The returned `tracker` can be used to track AI operation metrics + * (latency, token usage, etc.). + * + * @example + * ``` + * const agentConfigs = [ + * { + * key: 'research_agent', + * defaultValue: { enabled: true, instructions: 'You are a research assistant.' }, + * variables: { topic: 'climate change' } + * }, + * { + * key: 'writing_agent', + * defaultValue: { enabled: true, instructions: 'You are a writing assistant.' }, + * variables: { style: 'academic' } + * } + * ] as const; + * const context = {...}; + * + * const agents = await client.agents(agentConfigs, context); + * const researchResult = agents["research_agent"].instructions; // Interpolated instructions + * agents["research_agent"].tracker.trackSuccess(); + * ``` + */ + agents( + agentConfigs: T, + context: LDContext, + ): Promise>; } diff --git a/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts b/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts new file mode 100644 index 000000000..63e3bae68 --- /dev/null +++ b/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts @@ -0,0 +1,36 @@ +import { LDAIConfig } from '../config'; + +/** + * AI Config agent and tracker. + */ +export interface LDAIAgent extends Omit { + /** + * Instructions for the agent. + */ + instructions?: string; +} + +/** + * Configuration for a single agent request. + */ +export interface LDAIAgentConfig { + /** + * The agent key to retrieve. + */ + key: string; + + /** + * Default configuration for the agent. + */ + defaultValue: LDAIAgentDefaults; + + /** + * Variables for instructions interpolation. + */ + variables?: Record; +} + +/** + * Default values for an agent. + */ +export type LDAIAgentDefaults = Omit; diff --git a/packages/sdk/server-ai/src/api/agents/index.ts b/packages/sdk/server-ai/src/api/agents/index.ts new file mode 100644 index 000000000..f68fcd9a2 --- /dev/null +++ b/packages/sdk/server-ai/src/api/agents/index.ts @@ -0,0 +1 @@ +export * from './LDAIAgent'; diff --git a/packages/sdk/server-ai/src/api/index.ts b/packages/sdk/server-ai/src/api/index.ts index c6c70867b..cd6333b02 100644 --- a/packages/sdk/server-ai/src/api/index.ts +++ b/packages/sdk/server-ai/src/api/index.ts @@ -1,3 +1,4 @@ export * from './config'; +export * from './agents'; export * from './metrics'; export * from './LDAIClient';