From eb9c7fae5ac9c9e8dd5216570fe41a9e40c30bf7 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Thu, 26 Jun 2025 13:08:40 -0400 Subject: [PATCH 1/9] Adding agent support for AI Configs --- .../__tests__/LDAIClientImpl.test.ts | 120 +++++++++++++++++ packages/sdk/server-ai/src/LDAIClientImpl.ts | 124 ++++++++++++++++-- packages/sdk/server-ai/src/api/LDAIClient.ts | 53 ++++++++ .../sdk/server-ai/src/api/agents/LDAIAgent.ts | 26 ++++ .../sdk/server-ai/src/api/agents/index.ts | 1 + packages/sdk/server-ai/src/api/index.ts | 1 + 6 files changed, 315 insertions(+), 10 deletions(-) create mode 100644 packages/sdk/server-ai/src/api/agents/LDAIAgent.ts create mode 100644 packages/sdk/server-ai/src/api/agents/index.ts diff --git a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts index 41d035564a..1cd478147e 100644 --- a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts +++ b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts @@ -1,5 +1,6 @@ import { LDContext } from '@launchdarkly/js-server-sdk-common'; +import { LDAIAgentDefaults } from '../src/api/agents'; import { LDAIDefaults } from '../src/api/config'; import { LDAIClientImpl } from '../src/LDAIClientImpl'; import { LDClientMin } from '../src/LDClientMin'; @@ -129,3 +130,122 @@ it('passes the default value to the underlying client', async () => { expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, defaultValue); }); + +it('returns agent config with interpolated instructions', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'test-flag'; + const defaultValue: LDAIAgentDefaults = { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a helpful assistant.', + enabled: true, + }; + + 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, + }, + }; + + mockLdClient.variation.mockResolvedValue(mockVariation); + + const variables = { name: 'John', score: 42 }; + const result = await client.agents([key], testContext, defaultValue, variables); + + expect(result).toEqual({ + 'test-flag': { + 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, + }, + }); +}); + +it('includes context in variables for agent instructions interpolation', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'test-flag'; + const defaultValue: LDAIAgentDefaults = { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a helpful assistant.', + }; + + 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.agents([key], testContext, defaultValue); + + expect(result[key].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-flag'; + const defaultValue: LDAIAgentDefaults = { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a helpful assistant.', + }; + + const mockVariation = { + model: { name: 'example-provider', parameters: { name: 'imagination' } }, + instructions: 'Hello.', + }; + + mockLdClient.variation.mockResolvedValue(mockVariation); + + const result = await client.agents([key], testContext, defaultValue); + + expect(result).toEqual({ + 'test-flag': { + model: { name: 'example-provider', parameters: { name: 'imagination' } }, + instructions: 'Hello.', + tracker: expect.any(Object), + enabled: false, + }, + }); +}); + +it('passes the default value to the underlying client for agents', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'non-existent-flag'; + const defaultValue: LDAIAgentDefaults = { + model: { name: 'default-model', parameters: { name: 'default' } }, + provider: { name: 'default-provider' }, + instructions: 'Default instructions', + enabled: true, + }; + + mockLdClient.variation.mockResolvedValue(defaultValue); + + const result = await client.agents([key], testContext, defaultValue); + + expect(result).toEqual({ + 'non-existent-flag': { + model: defaultValue.model, + instructions: defaultValue.instructions, + provider: defaultValue.provider, + tracker: expect.any(Object), + enabled: false, + }, + }); + + expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, defaultValue); +}); diff --git a/packages/sdk/server-ai/src/LDAIClientImpl.ts b/packages/sdk/server-ai/src/LDAIClientImpl.ts index bca8431cce..3f481b7b09 100644 --- a/packages/sdk/server-ai/src/LDAIClientImpl.ts +++ b/packages/sdk/server-ai/src/LDAIClientImpl.ts @@ -2,11 +2,21 @@ import * as Mustache from 'mustache'; import { LDContext } from '@launchdarkly/js-server-sdk-common'; -import { LDAIConfig, LDAIDefaults, LDMessage, LDModelConfig, LDProviderConfig } from './api/config'; +import { LDAIAgent, LDAIAgentDefaults, LDAIAgents } from './api/agents'; +import { + LDAIConfig, + LDAIConfigTracker, + LDAIDefaults, + LDMessage, + LDModelConfig, + LDProviderConfig, +} from './api/config'; import { LDAIClient } from './api/LDAIClient'; import { LDAIConfigTrackerImpl } from './LDAIConfigTrackerImpl'; import { LDClientMin } from './LDClientMin'; +type Mode = 'completion' | 'agent'; + /** * Metadata assorted with a model configuration variation. */ @@ -14,6 +24,7 @@ interface LDMeta { variationKey: string; enabled: boolean; version?: number; + mode?: Mode; } /** @@ -23,10 +34,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) {} @@ -34,13 +59,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, @@ -50,24 +75,85 @@ 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 agent: LDAIAgent = { + 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; + } + + async config( + key: string, + context: LDContext, + defaultValue: LDAIDefaults, + variables?: Record, + ): Promise { + const { tracker, enabled, model, provider, messages } = await this._evaluate( + key, + context, + defaultValue, + ); + const config: LDAIConfig = { 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 (provider) { + config.provider = { ...provider }; } 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), })); @@ -75,4 +161,22 @@ export class LDAIClientImpl implements LDAIClient { return config; } + + async agents( + agentKeys: readonly TKey[], + context: LDContext, + defaultValue: LDAIAgentDefaults, + variables?: Record, + ): Promise> { + const agents = {} as LDAIAgents; + + await Promise.all( + agentKeys.map(async (agentKey) => { + const result = await this._evaluateAgent(agentKey, context, defaultValue, variables); + agents[agentKey] = result; + }), + ); + + return agents; + } } diff --git a/packages/sdk/server-ai/src/api/LDAIClient.ts b/packages/sdk/server-ai/src/api/LDAIClient.ts index 4bf5f617e0..f08c684e50 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 { LDAIAgentDefaults, LDAIAgents } from './agents'; import { LDAIConfig, LDAIDefaults } from './config/LDAIConfig'; /** @@ -63,4 +64,56 @@ export interface LDAIClient { defaultValue: LDAIDefaults, variables?: Record, ): Promise; + + /** + * Retrieves and processes an AI Config agents based on the provided keys, LaunchDarkly context, + * and variables. This includes the model configuration and the customized instructions. + * + * @param agentKeys The keys of the AI Config Agents. + * @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 messages. This will + * be used if the configuration is not available from LaunchDarkly. + * @param variables A map of key-value pairs representing dynamic variables to be injected into + * the instruction. The keys correspond to placeholders within the template, and the values + * are the corresponding replacements. + * + * @returns Map of AI `config` agent keys to `agent`, 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 agentKeys = ["agent-key-1", "agent-key-2"]; + * const context = {...}; + * const variables = {username: 'john'}; + * const defaultValue = { + * enabled: false, + * }; + * + * const result = agents(agentKeys, context, defaultValue, variables); + * // Output: + * { + * 'agent-key-1': { + * enabled: true, + * config: { + * modelId: "gpt-4o", + * temperature: 0.2, + * maxTokens: 4096, + * userDefinedKey: "myValue", + * }, + * instructions: "You are an amazing GPT.", + * tracker: ... + * }, + * 'agent-key-2': {...}, + * } + * ``` + */ + agents( + agentKeys: readonly TKey[], + context: LDContext, + defaultValue: LDAIAgentDefaults, + variables?: Record, + ): 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 0000000000..3bc4085752 --- /dev/null +++ b/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts @@ -0,0 +1,26 @@ +import { LDAIConfig } from '../config'; + +/** + * AI Config agent and tracker. + */ +export interface LDAIAgent extends Omit { + /** + * Instructions for the agent. + */ + instructions?: string; +} + +export type LDAIAgents = Record; + +/** + * Default value for a `modelConfig`. This is the same as the LDAIAgent, but it does not include + * a tracker and `enabled` is optional. + */ +export type LDAIAgentDefaults = Omit & { + /** + * Whether the agent configuration is enabled. + * + * defaults to false + */ + enabled?: boolean; +}; 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 0000000000..f68fcd9a24 --- /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 c6c70867bb..cd6333b027 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'; From 1d61859103e85e455a904d5a63eedd479f8e6d43 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Mon, 14 Jul 2025 13:06:40 -0500 Subject: [PATCH 2/9] Updated code based on updated AI config spec --- .../__tests__/LDAIClientImpl.test.ts | 178 ++++++++++++++---- packages/sdk/server-ai/src/LDAIClientImpl.ts | 43 ++++- packages/sdk/server-ai/src/api/LDAIClient.ts | 89 ++++++--- .../sdk/server-ai/src/api/agents/LDAIAgent.ts | 24 ++- 4 files changed, 256 insertions(+), 78 deletions(-) diff --git a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts index 1cd478147e..acde14cbf7 100644 --- a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts +++ b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts @@ -12,6 +12,10 @@ const mockLdClient: jest.Mocked = { const testContext: LDContext = { kind: 'user', key: 'test-user' }; +beforeEach(() => { + jest.clearAllMocks(); +}); + it('returns config with interpolated messagess', async () => { const client = new LDAIClientImpl(mockLdClient); const key = 'test-flag'; @@ -131,9 +135,9 @@ it('passes the default value to the underlying client', async () => { expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, defaultValue); }); -it('returns agent config with interpolated instructions', async () => { +it('returns single agent config with interpolated instructions', async () => { const client = new LDAIClientImpl(mockLdClient); - const key = 'test-flag'; + const key = 'test-agent'; const defaultValue: LDAIAgentDefaults = { model: { name: 'test', parameters: { name: 'test-model' } }, instructions: 'You are a helpful assistant.', @@ -148,57 +152,64 @@ it('returns agent config with interpolated instructions', async () => { provider: { name: 'example-provider', }, - instructions: 'You are a helpful assistant. your name is {{name}} and your score is {{score}}', + 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.agents([key], testContext, defaultValue, variables); + const result = await client.agent(key, testContext, defaultValue, variables); expect(result).toEqual({ - 'test-flag': { - 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, + 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, }); + + // 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-flag'; + const key = 'test-agent'; const defaultValue: LDAIAgentDefaults = { model: { name: 'test', parameters: { name: 'test-model' } }, instructions: 'You are a helpful assistant.', }; const mockVariation = { - instructions: 'You are a helpful assistant. your user key is {{ldctx.key}}', + 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.agents([key], testContext, defaultValue); + const result = await client.agent(key, testContext, defaultValue); - expect(result[key].instructions).toBe('You are a helpful assistant. your user key is test-user'); + 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-flag'; + const key = 'test-agent'; const defaultValue: LDAIAgentDefaults = { model: { name: 'test', parameters: { name: 'test-model' } }, instructions: 'You are a helpful assistant.', @@ -211,21 +222,19 @@ it('handles missing metadata in agent variation', async () => { mockLdClient.variation.mockResolvedValue(mockVariation); - const result = await client.agents([key], testContext, defaultValue); + const result = await client.agent(key, testContext, defaultValue); expect(result).toEqual({ - 'test-flag': { - model: { name: 'example-provider', parameters: { name: 'imagination' } }, - instructions: 'Hello.', - tracker: expect.any(Object), - enabled: false, - }, + model: { name: 'example-provider', parameters: { name: 'imagination' } }, + instructions: 'Hello.', + tracker: expect.any(Object), + enabled: false, }); }); -it('passes the default value to the underlying client for agents', async () => { +it('passes the default value to the underlying client for single agent', async () => { const client = new LDAIClientImpl(mockLdClient); - const key = 'non-existent-flag'; + const key = 'non-existent-agent'; const defaultValue: LDAIAgentDefaults = { model: { name: 'default-model', parameters: { name: 'default' } }, provider: { name: 'default-provider' }, @@ -235,17 +244,114 @@ it('passes the default value to the underlying client for agents', async () => { mockLdClient.variation.mockResolvedValue(defaultValue); - const result = await client.agents([key], testContext, 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, + }); + + expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, defaultValue); +}); + +it('returns multiple agents config with interpolated instructions', async () => { + const client = new LDAIClientImpl(mockLdClient); + + const agentConfigs = [ + { + agentKey: 'research-agent', + defaultConfig: { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a research assistant.', + enabled: true, + }, + variables: { topic: 'climate change' }, + }, + { + agentKey: 'writing-agent', + defaultConfig: { + model: { name: 'test', parameters: { name: 'test-model' } }, + instructions: 'You are a writing assistant.', + enabled: true, + }, + 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({ - 'non-existent-flag': { - model: defaultValue.model, - instructions: defaultValue.instructions, - provider: defaultValue.provider, + '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: false, + enabled: true, + }, + '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, }, }); - expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, defaultValue); + // 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 3f481b7b09..d6bdea91ec 100644 --- a/packages/sdk/server-ai/src/LDAIClientImpl.ts +++ b/packages/sdk/server-ai/src/LDAIClientImpl.ts @@ -2,7 +2,7 @@ import * as Mustache from 'mustache'; import { LDContext } from '@launchdarkly/js-server-sdk-common'; -import { LDAIAgent, LDAIAgentDefaults, LDAIAgents } from './api/agents'; +import { LDAIAgent, LDAIAgentConfig, LDAIAgentDefaults } from './api/agents'; import { LDAIConfig, LDAIConfigTracker, @@ -18,7 +18,7 @@ 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; @@ -107,6 +107,7 @@ export class LDAIClientImpl implements LDAIClient { 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) { @@ -142,6 +143,7 @@ export class LDAIClientImpl implements LDAIClient { 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) { @@ -162,18 +164,41 @@ export class LDAIClientImpl implements LDAIClient { return config; } - async agents( - agentKeys: readonly TKey[], + async agent( + key: string, context: LDContext, defaultValue: LDAIAgentDefaults, variables?: Record, - ): Promise> { - const agents = {} as LDAIAgents; + ): 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: TConfigs, + 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( - agentKeys.map(async (agentKey) => { - const result = await this._evaluateAgent(agentKey, context, defaultValue, variables); - agents[agentKey] = result; + agentConfigs.map(async (config) => { + const agent = await this._evaluateAgent( + config.agentKey, + context, + config.defaultConfig, + config.variables, + ); + agents[config.agentKey as TConfigs[number]['agentKey']] = agent; }), ); diff --git a/packages/sdk/server-ai/src/api/LDAIClient.ts b/packages/sdk/server-ai/src/api/LDAIClient.ts index f08c684e50..520c34e53b 100644 --- a/packages/sdk/server-ai/src/api/LDAIClient.ts +++ b/packages/sdk/server-ai/src/api/LDAIClient.ts @@ -1,6 +1,6 @@ import { LDContext } from '@launchdarkly/js-server-sdk-common'; -import { LDAIAgentDefaults, LDAIAgents } from './agents'; +import { LDAIAgent, LDAIAgentConfig, LDAIAgentDefaults } from './agents'; import { LDAIConfig, LDAIDefaults } from './config/LDAIConfig'; /** @@ -66,54 +66,83 @@ export interface LDAIClient { ): Promise; /** - * Retrieves and processes an AI Config agents based on the provided keys, LaunchDarkly context, + * 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 agentKeys The keys of the AI Config Agents. + * @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 messages. This will + * @param defaultValue A fallback value containing model configuration and instructions. This will * be used if the configuration is not available from LaunchDarkly. * @param variables A map of key-value pairs representing dynamic variables to be injected into - * the instruction. The keys correspond to placeholders within the template, and the values + * the instructions. The keys correspond to placeholders within the template, and the values * are the corresponding replacements. * - * @returns Map of AI `config` agent keys to `agent`, 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.). + * @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 agentKeys = ["agent-key-1", "agent-key-2"]; + * const key = "research_agent"; * const context = {...}; - * const variables = {username: 'john'}; + * const variables = { topic: 'climate change' }; * const defaultValue = { - * enabled: false, + * enabled: true, + * instructions: 'You are a research assistant.', * }; * - * const result = agents(agentKeys, context, defaultValue, variables); - * // Output: - * { - * 'agent-key-1': { - * enabled: true, - * config: { - * modelId: "gpt-4o", - * temperature: 0.2, - * maxTokens: 4096, - * userDefinedKey: "myValue", - * }, - * instructions: "You are an amazing GPT.", - * tracker: ... - * }, - * 'agent-key-2': {...}, - * } + * const agent = await client.agent(key, context, defaultValue, variables); + * const researchResult = agent.instructions; // Interpolated instructions + * agent.tracker.trackSuccess(); * ``` */ - agents( - agentKeys: readonly TKey[], + agent( + key: string, context: LDContext, defaultValue: LDAIAgentDefaults, variables?: Record, - ): Promise>; + ): 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 `defaultConfig`. The returned `tracker` can be used to track AI operation metrics + * (latency, token usage, etc.). + * + * @example + * ``` + * const agentConfigs: LDAIAgentConfig[] = [ + * { + * agentKey: 'research_agent', + * defaultConfig: { enabled: true, instructions: 'You are a research assistant.' }, + * variables: { topic: 'climate change' } + * }, + * { + * agentKey: 'writing_agent', + * defaultConfig: { 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: TConfigs, + 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 index 3bc4085752..71d9cd45a0 100644 --- a/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts +++ b/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts @@ -10,17 +10,35 @@ export interface LDAIAgent extends Omit { instructions?: string; } -export type LDAIAgents = Record; +/** + * Configuration for a single agent request. + */ +export interface LDAIAgentConfig { + /** + * The agent key to retrieve. + */ + agentKey: string; + + /** + * Default configuration for the agent. + */ + defaultConfig: LDAIAgentDefaults; + + /** + * Variables for instructions interpolation. + */ + variables?: Record; +} /** - * Default value for a `modelConfig`. This is the same as the LDAIAgent, but it does not include + * Default value for an agent configuration. This is the same as the LDAIAgent, but it does not include * a tracker and `enabled` is optional. */ export type LDAIAgentDefaults = Omit & { /** * Whether the agent configuration is enabled. * - * defaults to false + * @default false */ enabled?: boolean; }; From a7c1d1d1065567714bbb4676f4c535afc8b54e32 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Tue, 15 Jul 2025 08:55:44 -0500 Subject: [PATCH 3/9] Added default values and updated prop names --- .../__tests__/LDAIClientImpl.test.ts | 121 +++++++++++++++++- packages/sdk/server-ai/src/LDAIClientImpl.ts | 24 ++-- packages/sdk/server-ai/src/api/LDAIClient.ts | 39 ++++-- .../sdk/server-ai/src/api/agents/LDAIAgent.ts | 6 +- 4 files changed, 160 insertions(+), 30 deletions(-) diff --git a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts index acde14cbf7..5cbbdfce21 100644 --- a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts +++ b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts @@ -257,13 +257,71 @@ it('passes the default value to the underlying client for single agent', async ( expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, defaultValue); }); +it('handles single agent with optional defaultValue', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'test-agent'; + + const mockVariation = { + instructions: 'You are a helpful assistant named {{name}}.', + _ldMeta: { variationKey: 'v1', enabled: true, mode: 'agent' }, + }; + + mockLdClient.variation.mockResolvedValue(mockVariation); + + const variables = { name: 'Helper' }; + + // Test without providing defaultValue + const result = await client.agent(key, testContext, undefined, variables); + + expect(result).toEqual({ + instructions: 'You are a helpful assistant named Helper.', + tracker: expect.any(Object), + enabled: true, + }); + + // Verify tracking was called + expect(mockLdClient.track).toHaveBeenCalledWith( + '$ld:ai:agent:function:single', + testContext, + key, + 1, + ); + + // Verify the agent was called with { enabled: false } as default + expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, { enabled: false }); +}); + +it('handles single agent without any optional parameters', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'simple-agent'; + + const mockVariation = { + instructions: 'Simple instructions.', + _ldMeta: { variationKey: 'v1', enabled: false, mode: 'agent' }, + }; + + mockLdClient.variation.mockResolvedValue(mockVariation); + + // Test with only required parameters + const result = await client.agent(key, testContext); + + expect(result).toEqual({ + instructions: 'Simple instructions.', + tracker: expect.any(Object), + enabled: false, + }); + + // Verify the agent was called with { enabled: false } as default and no variables + expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, { enabled: false }); +}); + it('returns multiple agents config with interpolated instructions', async () => { const client = new LDAIClientImpl(mockLdClient); const agentConfigs = [ { - agentKey: 'research-agent', - defaultConfig: { + key: 'research-agent', + defaultValue: { model: { name: 'test', parameters: { name: 'test-model' } }, instructions: 'You are a research assistant.', enabled: true, @@ -271,8 +329,8 @@ it('returns multiple agents config with interpolated instructions', async () => variables: { topic: 'climate change' }, }, { - agentKey: 'writing-agent', - defaultConfig: { + key: 'writing-agent', + defaultValue: { model: { name: 'test', parameters: { name: 'test-model' } }, instructions: 'You are a writing assistant.', enabled: true, @@ -355,3 +413,58 @@ it('handles empty agent configs array', async () => { 0, ); }); + +it('handles agents with optional defaultValue', async () => { + const client = new LDAIClientImpl(mockLdClient); + + const agentConfigs = [ + { + key: 'agent-with-default', + defaultValue: { + instructions: 'You are a helpful assistant.', + enabled: true, + }, + variables: { name: 'Assistant' }, + }, + { + key: 'agent-without-default', + variables: { name: 'Helper' }, + // No defaultValue provided - should default to { enabled: false } + }, + ] as const; + + const mockVariations = { + 'agent-with-default': { + instructions: 'Hello {{name}}!', + _ldMeta: { variationKey: 'v1', enabled: true, mode: 'agent' }, + }, + 'agent-without-default': { + instructions: 'Hi {{name}}!', + _ldMeta: { variationKey: 'v2', enabled: false, mode: 'agent' }, + }, + }; + + mockLdClient.variation.mockImplementation((key) => + Promise.resolve(mockVariations[key as keyof typeof mockVariations]), + ); + + const result = await client.agents(agentConfigs, testContext); + + expect(result).toEqual({ + 'agent-with-default': { + instructions: 'Hello Assistant!', + tracker: expect.any(Object), + enabled: true, + }, + 'agent-without-default': { + instructions: 'Hi Helper!', + tracker: expect.any(Object), + enabled: false, + }, + }); + + // Verify the agent without defaultValue was called with { enabled: false } + expect(mockLdClient.variation).toHaveBeenCalledWith('agent-without-default', testContext, { + enabled: false, + }); +}); diff --git a/packages/sdk/server-ai/src/LDAIClientImpl.ts b/packages/sdk/server-ai/src/LDAIClientImpl.ts index d6bdea91ec..57755a2a55 100644 --- a/packages/sdk/server-ai/src/LDAIClientImpl.ts +++ b/packages/sdk/server-ai/src/LDAIClientImpl.ts @@ -167,19 +167,22 @@ export class LDAIClientImpl implements LDAIClient { async agent( key: string, context: LDContext, - defaultValue: LDAIAgentDefaults, + 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); + // Use provided defaultValue or fallback to { enabled: false } + const resolvedDefaultValue = defaultValue ?? { enabled: false }; + + return this._evaluateAgent(key, context, resolvedDefaultValue, variables); } - async agents( - agentConfigs: TConfigs, + async agents( + agentConfigs: T, context: LDContext, - ): Promise> { + ): Promise> { // Track multiple agents usage this._ldClient.track( '$ld:ai:agent:function:multiple', @@ -188,17 +191,20 @@ export class LDAIClientImpl implements LDAIClient { agentConfigs.length, ); - const agents = {} as Record; + const agents = {} as Record; await Promise.all( agentConfigs.map(async (config) => { + // Use provided defaultValue or fallback to { enabled: false } + const defaultValue = config.defaultValue ?? { enabled: false }; + const agent = await this._evaluateAgent( - config.agentKey, + config.key, context, - config.defaultConfig, + defaultValue, config.variables, ); - agents[config.agentKey as TConfigs[number]['agentKey']] = agent; + agents[config.key as T[number]['key']] = agent; }), ); diff --git a/packages/sdk/server-ai/src/api/LDAIClient.ts b/packages/sdk/server-ai/src/api/LDAIClient.ts index 520c34e53b..bbf71c359f 100644 --- a/packages/sdk/server-ai/src/api/LDAIClient.ts +++ b/packages/sdk/server-ai/src/api/LDAIClient.ts @@ -73,8 +73,8 @@ export interface LDAIClient { * @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. This will - * be used if the configuration is not available from LaunchDarkly. + * @param defaultValue A fallback value containing model configuration and instructions. If not + * provided, defaults to { enabled: false }. * @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. @@ -88,12 +88,18 @@ export interface LDAIClient { * const key = "research_agent"; * const context = {...}; * const variables = { topic: 'climate change' }; - * const defaultValue = { + * + * // With explicit defaultValue + * const agent = await client.agent(key, context, { * enabled: true, * instructions: 'You are a research assistant.', - * }; + * }, variables); + * + * // Without defaultValue (defaults to { enabled: false }) + * const simpleAgent = await client.agent(key, context, undefined, variables); + * // or even simpler: + * const simpleAgent2 = await client.agent(key, context); * - * const agent = await client.agent(key, context, defaultValue, variables); * const researchResult = agent.instructions; // Interpolated instructions * agent.tracker.trackSuccess(); * ``` @@ -101,7 +107,7 @@ export interface LDAIClient { agent( key: string, context: LDContext, - defaultValue: LDAIAgentDefaults, + defaultValue?: LDAIAgentDefaults, variables?: Record, ): Promise; @@ -117,21 +123,26 @@ export interface LDAIClient { * * @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 `defaultConfig`. The returned `tracker` can be used to track AI operation metrics + * from the respective `defaultValue`. The returned `tracker` can be used to track AI operation metrics * (latency, token usage, etc.). * * @example * ``` * const agentConfigs: LDAIAgentConfig[] = [ * { - * agentKey: 'research_agent', - * defaultConfig: { enabled: true, instructions: 'You are a research assistant.' }, + * key: 'research_agent', + * defaultValue: { enabled: true, instructions: 'You are a research assistant.' }, * variables: { topic: 'climate change' } * }, * { - * agentKey: 'writing_agent', - * defaultConfig: { enabled: true, instructions: 'You are a writing assistant.' }, + * key: 'writing_agent', + * defaultValue: { enabled: true, instructions: 'You are a writing assistant.' }, * variables: { style: 'academic' } + * }, + * { + * key: 'simple_agent', + * variables: { name: 'Helper' } + * // defaultValue is optional, will default to { enabled: false } * } * ] as const; * const context = {...}; @@ -141,8 +152,8 @@ export interface LDAIClient { * agents["research_agent"].tracker.trackSuccess(); * ``` */ - agents( - agentConfigs: TConfigs, + agents( + agentConfigs: T, context: LDContext, - ): Promise>; + ): Promise>; } diff --git a/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts b/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts index 71d9cd45a0..ddfcfc6d64 100644 --- a/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts +++ b/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts @@ -17,12 +17,12 @@ export interface LDAIAgentConfig { /** * The agent key to retrieve. */ - agentKey: string; + key: string; /** - * Default configuration for the agent. + * Default configuration for the agent. If not provided, defaults to { enabled: false }. */ - defaultConfig: LDAIAgentDefaults; + defaultValue?: LDAIAgentDefaults; /** * Variables for instructions interpolation. From 6ce77414778e1362494a9d9282d1351af6c10dad Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Mon, 21 Jul 2025 23:50:10 -0500 Subject: [PATCH 4/9] merge from main branch --- .release-please-manifest.json | 2 +- packages/sdk/server-ai/CHANGELOG.md | 7 + .../__tests__/LDAIClientImpl.test.ts | 3 + .../__tests__/LDAIConfigMapper.test.ts | 159 +++++++ .../__tests__/LDAIConfigTrackerImpl.test.ts | 445 ++++++++++++++++++ .../server-ai/__tests__/TokenUsage.test.ts | 45 +- .../server-ai/examples/bedrock/package.json | 2 +- .../server-ai/examples/openai/package.json | 2 +- packages/sdk/server-ai/package.json | 2 +- packages/sdk/server-ai/src/LDAIClientImpl.ts | 69 ++- .../sdk/server-ai/src/LDAIConfigMapper.ts | 64 +++ .../server-ai/src/LDAIConfigTrackerImpl.ts | 75 ++- .../server-ai/src/api/config/LDAIConfig.ts | 23 +- .../src/api/config/LDAIConfigTracker.ts | 49 ++ .../server-ai/src/api/config/VercelAISDK.ts | 20 + .../sdk/server-ai/src/api/config/index.ts | 1 + .../src/api/metrics/VercelAISDKTokenUsage.ts | 13 + .../sdk/server-ai/src/api/metrics/index.ts | 2 + 18 files changed, 957 insertions(+), 26 deletions(-) create mode 100644 packages/sdk/server-ai/__tests__/LDAIConfigMapper.test.ts create mode 100644 packages/sdk/server-ai/src/LDAIConfigMapper.ts create mode 100644 packages/sdk/server-ai/src/api/config/VercelAISDK.ts create mode 100644 packages/sdk/server-ai/src/api/metrics/VercelAISDKTokenUsage.ts diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1c96608e9b..3a26eb07c9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -15,7 +15,7 @@ "packages/sdk/react-native": "10.10.4", "packages/telemetry/node-server-sdk-otel": "1.3.0", "packages/sdk/browser": "0.8.0", - "packages/sdk/server-ai": "0.9.9", + "packages/sdk/server-ai": "0.10.0", "packages/telemetry/browser-telemetry": "1.0.10", "packages/tooling/jest": "0.1.9", "packages/sdk/combined-browser": "0.0.0" diff --git a/packages/sdk/server-ai/CHANGELOG.md b/packages/sdk/server-ai/CHANGELOG.md index 5827b6b619..7635d0171e 100644 --- a/packages/sdk/server-ai/CHANGELOG.md +++ b/packages/sdk/server-ai/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.10.0](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.9.9...server-sdk-ai-v0.10.0) (2025-07-16) + + +### Features + +* Adding Vercel AI SDK mapper ([#895](https://github.com/launchdarkly/js-core/issues/895)) ([0befee0](https://github.com/launchdarkly/js-core/commit/0befee0888d0af03b01c0cf6f46eacc80a3ce8e8)) + ## [0.9.9](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.9.8...server-sdk-ai-v0.9.9) (2025-06-17) diff --git a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts index 5cbbdfce21..59b70dcc75 100644 --- a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts +++ b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts @@ -62,6 +62,7 @@ it('returns config with interpolated messagess', async () => { ], tracker: expect.any(Object), enabled: true, + toVercelAISDK: expect.any(Function), }); }); @@ -107,6 +108,7 @@ it('handles missing metadata in variation', async () => { messages: [{ role: 'system', content: 'Hello' }], tracker: expect.any(Object), enabled: false, + toVercelAISDK: expect.any(Function), }); }); @@ -130,6 +132,7 @@ it('passes the default value to the underlying client', async () => { provider: defaultValue.provider, tracker: expect.any(Object), enabled: false, + toVercelAISDK: expect.any(Function), }); expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, defaultValue); diff --git a/packages/sdk/server-ai/__tests__/LDAIConfigMapper.test.ts b/packages/sdk/server-ai/__tests__/LDAIConfigMapper.test.ts new file mode 100644 index 0000000000..ddee1b26f2 --- /dev/null +++ b/packages/sdk/server-ai/__tests__/LDAIConfigMapper.test.ts @@ -0,0 +1,159 @@ +import { LDMessage, VercelAISDKMapOptions } from '../src/api/config'; +import { LDAIConfigMapper } from '../src/LDAIConfigMapper'; + +describe('_findParameter', () => { + it('handles undefined model and messages', () => { + const mapper = new LDAIConfigMapper(); + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(mapper['_findParameter']('test-param')).toBeUndefined(); + }); + + it('handles parameter not found', () => { + const mapper = new LDAIConfigMapper({ + name: 'test-ai-model', + parameters: { + 'test-param': 123, + }, + custom: { + 'test-param': 456, + }, + }); + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(mapper['_findParameter']('other-param')).toBeUndefined(); + }); + + it('finds parameter from single model parameter', () => { + const mapper = new LDAIConfigMapper({ + name: 'test-ai-model', + parameters: { + 'test-param': 123, + }, + }); + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(mapper['_findParameter']('test-param')).toEqual(123); + }); + + it('finds parameter from multiple model parameters', () => { + const mapper = new LDAIConfigMapper({ + name: 'test-ai-model', + parameters: { + testParam: 123, + }, + }); + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(mapper['_findParameter']('test-param', 'testParam')).toEqual(123); + }); + + it('finds parameter from single model custom parameter', () => { + const mapper = new LDAIConfigMapper({ + name: 'test-ai-model', + custom: { + 'test-param': 123, + }, + }); + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(mapper['_findParameter']('test-param')).toEqual(123); + }); + + it('finds parameter from multiple model custom parameters', () => { + const mapper = new LDAIConfigMapper({ + name: 'test-ai-model', + custom: { + testParam: 123, + }, + }); + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(mapper['_findParameter']('test-param', 'testParam')).toEqual(123); + }); + + it('gives precedence to model parameters over model custom parameters', () => { + const mapper = new LDAIConfigMapper({ + name: 'test-ai-model', + parameters: { + 'test-param': 123, + }, + custom: { + 'test-param': 456, + }, + }); + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(mapper['_findParameter']('test-param', 'testParam')).toEqual(123); + }); +}); + +describe('toVercelAIAISDK', () => { + const mockModel = { name: 'mockModel' }; + const mockMessages: LDMessage[] = [ + { role: 'user', content: 'test prompt' }, + { role: 'system', content: 'test instruction' }, + ]; + const mockOptions: VercelAISDKMapOptions = { + nonInterpolatedMessages: [{ role: 'assistant', content: 'test assistant instruction' }], + }; + const mockProvider = jest.fn().mockReturnValue(mockModel); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('handles undefined model and messages', () => { + const mapper = new LDAIConfigMapper(); + const result = mapper.toVercelAISDK(mockProvider); + + expect(mockProvider).toHaveBeenCalledWith(''); + expect(result).toEqual( + expect.objectContaining({ + model: mockModel, + messages: undefined, + }), + ); + }); + + it('uses additional messages', () => { + const mapper = new LDAIConfigMapper({ name: 'test-ai-model' }); + const result = mapper.toVercelAISDK(mockProvider, mockOptions); + + expect(mockProvider).toHaveBeenCalledWith('test-ai-model'); + expect(result).toEqual( + expect.objectContaining({ + model: mockModel, + messages: mockOptions.nonInterpolatedMessages, + }), + ); + }); + + it('combines config messages and additional messages', () => { + const mapper = new LDAIConfigMapper({ name: 'test-ai-model' }, undefined, mockMessages); + const result = mapper.toVercelAISDK(mockProvider, mockOptions); + + expect(mockProvider).toHaveBeenCalledWith('test-ai-model'); + expect(result).toEqual( + expect.objectContaining({ + model: mockModel, + messages: [...mockMessages, ...(mockOptions.nonInterpolatedMessages ?? [])], + }), + ); + }); + + it('requests parameters correctly', () => { + const mapper = new LDAIConfigMapper({ name: 'test-ai-model' }, undefined, mockMessages); + const findParameterMock = jest.spyOn(mapper as any, '_findParameter'); + const result = mapper.toVercelAISDK(mockProvider); + + expect(mockProvider).toHaveBeenCalledWith('test-ai-model'); + expect(result).toEqual( + expect.objectContaining({ + model: mockModel, + messages: mockMessages, + }), + ); + expect(findParameterMock).toHaveBeenCalledWith('max_tokens', 'maxTokens'); + expect(findParameterMock).toHaveBeenCalledWith('temperature'); + expect(findParameterMock).toHaveBeenCalledWith('top_p', 'topP'); + expect(findParameterMock).toHaveBeenCalledWith('top_k', 'topK'); + expect(findParameterMock).toHaveBeenCalledWith('presence_penalty', 'presencePenalty'); + expect(findParameterMock).toHaveBeenCalledWith('frequency_penalty', 'frequencyPenalty'); + expect(findParameterMock).toHaveBeenCalledWith('stop', 'stop_sequences', 'stopSequences'); + expect(findParameterMock).toHaveBeenCalledWith('seed'); + }); +}); diff --git a/packages/sdk/server-ai/__tests__/LDAIConfigTrackerImpl.test.ts b/packages/sdk/server-ai/__tests__/LDAIConfigTrackerImpl.test.ts index 483d097889..9dd28574cc 100644 --- a/packages/sdk/server-ai/__tests__/LDAIConfigTrackerImpl.test.ts +++ b/packages/sdk/server-ai/__tests__/LDAIConfigTrackerImpl.test.ts @@ -129,6 +129,13 @@ it('tracks success', () => { { configKey, variationKey, version }, 1, ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation:success', + testContext, + { configKey, variationKey, version }, + 1, + ); }); it('tracks OpenAI usage', async () => { @@ -167,6 +174,20 @@ it('tracks OpenAI usage', async () => { 1, ); + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation:success', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).not.toHaveBeenCalledWith( + '$ld:ai:generation:error', + expect.anything(), + expect.anything(), + expect.anything(), + ); + expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:tokens:total', testContext, @@ -226,6 +247,13 @@ it('tracks error when OpenAI metrics function throws', async () => { { configKey, variationKey, version }, 1, ); + + expect(mockTrack).not.toHaveBeenCalledWith( + expect.stringMatching(/^\$ld:ai:tokens:/), + expect.anything(), + expect.anything(), + expect.anything(), + ); }); it('tracks Bedrock conversation with successful response', () => { @@ -260,6 +288,20 @@ it('tracks Bedrock conversation with successful response', () => { 1, ); + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation:success', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).not.toHaveBeenCalledWith( + '$ld:ai:generation:error', + expect.anything(), + expect.anything(), + expect.anything(), + ); + expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:duration:total', testContext, @@ -318,6 +360,409 @@ it('tracks Bedrock conversation with error response', () => { { configKey, variationKey, version }, 1, ); + + expect(mockTrack).not.toHaveBeenCalledWith( + expect.stringMatching(/^\$ld:ai:tokens:/), + expect.anything(), + expect.anything(), + expect.anything(), + ); +}); + +describe('Vercel AI SDK generateText', () => { + it('tracks Vercel AI SDK usage', async () => { + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); + jest.spyOn(global.Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(2000); + + const TOTAL_TOKENS = 100; + const PROMPT_TOKENS = 49; + const COMPLETION_TOKENS = 51; + + await tracker.trackVercelAISDKGenerateTextMetrics(async () => ({ + usage: { + totalTokens: TOTAL_TOKENS, + promptTokens: PROMPT_TOKENS, + completionTokens: COMPLETION_TOKENS, + }, + })); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:duration:total', + testContext, + { configKey, variationKey, version }, + 1000, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation:success', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).not.toHaveBeenCalledWith( + '$ld:ai:generation:error', + expect.anything(), + expect.anything(), + expect.anything(), + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:tokens:total', + testContext, + { configKey, variationKey, version }, + TOTAL_TOKENS, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:tokens:input', + testContext, + { configKey, variationKey, version }, + PROMPT_TOKENS, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:tokens:output', + testContext, + { configKey, variationKey, version }, + COMPLETION_TOKENS, + ); + }); + + it('tracks error when Vercel AI SDK metrics function throws', async () => { + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); + jest.spyOn(global.Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(2000); + + const error = new Error('Vercel AI SDK API error'); + await expect( + tracker.trackVercelAISDKGenerateTextMetrics(async () => { + throw error; + }), + ).rejects.toThrow(error); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:duration:total', + testContext, + { configKey, variationKey, version }, + 1000, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation:error', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).not.toHaveBeenCalledWith( + expect.stringMatching(/^\$ld:ai:tokens:/), + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); +}); + +describe('Vercel AI SDK streamText', () => { + it('tracks Vercel AI SDK usage', async () => { + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); + jest.spyOn(global.Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(2000); + + const TOTAL_TOKENS = 100; + const PROMPT_TOKENS = 49; + const COMPLETION_TOKENS = 51; + + let resolveDone: ((value: boolean) => void) | undefined; + const donePromise = new Promise((resolve) => { + resolveDone = resolve; + }); + + const finishReason = Promise.resolve('stop'); + jest + .spyOn(finishReason, 'then') + .mockImplementationOnce((fn) => finishReason.then(fn).finally(() => resolveDone?.(true))); + + tracker.trackVercelAISDKStreamTextMetrics(() => ({ + finishReason, + usage: Promise.resolve({ + totalTokens: TOTAL_TOKENS, + promptTokens: PROMPT_TOKENS, + completionTokens: COMPLETION_TOKENS, + }), + })); + + await donePromise; + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:duration:total', + testContext, + { configKey, variationKey, version }, + 1000, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation:success', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).not.toHaveBeenCalledWith( + '$ld:ai:generation:error', + expect.anything(), + expect.anything(), + expect.anything(), + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:tokens:total', + testContext, + { configKey, variationKey, version }, + TOTAL_TOKENS, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:tokens:input', + testContext, + { configKey, variationKey, version }, + PROMPT_TOKENS, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:tokens:output', + testContext, + { configKey, variationKey, version }, + COMPLETION_TOKENS, + ); + }); + + it('tracks error when Vercel AI SDK metrics function throws', async () => { + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); + jest.spyOn(global.Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(2000); + + const error = new Error('Vercel AI SDK API error'); + expect(() => + tracker.trackVercelAISDKStreamTextMetrics(() => { + throw error; + }), + ).toThrow(error); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:duration:total', + testContext, + { configKey, variationKey, version }, + 1000, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation:error', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).not.toHaveBeenCalledWith( + expect.stringMatching(/^\$ld:ai:tokens:/), + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); + + it('tracks error when Vercel AI SDK finishes because of an error', async () => { + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); + jest.spyOn(global.Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(2000); + + tracker.trackVercelAISDKStreamTextMetrics(() => ({ + finishReason: Promise.resolve('error'), + })); + + await new Promise(process.nextTick); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:duration:total', + testContext, + { configKey, variationKey, version }, + 1000, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation:error', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).not.toHaveBeenCalledWith( + expect.stringMatching(/^\$ld:ai:tokens:/), + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); + + it('tracks error when Vercel AI SDK finishReason promise rejects', async () => { + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); + jest.spyOn(global.Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(2000); + + tracker.trackVercelAISDKStreamTextMetrics(() => ({ + finishReason: Promise.reject(new Error('Vercel AI SDK API error')), + })); + + await new Promise(process.nextTick); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:duration:total', + testContext, + { configKey, variationKey, version }, + 1000, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation:error', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).not.toHaveBeenCalledWith( + expect.stringMatching(/^\$ld:ai:tokens:/), + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); + + it('squashes error when Vercel AI SDK usage promise rejects', async () => { + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); + jest.spyOn(global.Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(2000); + + tracker.trackVercelAISDKStreamTextMetrics(() => ({ + finishReason: Promise.resolve('stop'), + usage: Promise.reject(new Error('Vercel AI SDK API error')), + })); + + await new Promise(process.nextTick); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:duration:total', + testContext, + { configKey, variationKey, version }, + 1000, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:generation:success', + testContext, + { configKey, variationKey, version }, + 1, + ); + + expect(mockTrack).not.toHaveBeenCalledWith( + '$ld:ai:generation:error', + expect.anything(), + expect.anything(), + expect.anything(), + ); + + expect(mockTrack).not.toHaveBeenCalledWith( + expect.stringMatching(/^\$ld:ai:tokens:/), + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); }); it('tracks tokens', () => { diff --git a/packages/sdk/server-ai/__tests__/TokenUsage.test.ts b/packages/sdk/server-ai/__tests__/TokenUsage.test.ts index 3dbc8bf6b5..4edaa7f237 100644 --- a/packages/sdk/server-ai/__tests__/TokenUsage.test.ts +++ b/packages/sdk/server-ai/__tests__/TokenUsage.test.ts @@ -1,5 +1,8 @@ -import { createBedrockTokenUsage } from '../src/api/metrics/BedrockTokenUsage'; -import { createOpenAiUsage } from '../src/api/metrics/OpenAiUsage'; +import { + createBedrockTokenUsage, + createOpenAiUsage, + createVercelAISDKTokenUsage, +} from '../src/api/metrics'; it('createBedrockTokenUsage should create token usage with all values provided', () => { const usage = createBedrockTokenUsage({ @@ -76,3 +79,41 @@ it('createOpenAiUsage should handle explicitly undefined values', () => { output: 0, }); }); + +it('createVercelAISDKTokenUsage should create token usage with all values provided', () => { + const usage = createVercelAISDKTokenUsage({ + totalTokens: 100, + promptTokens: 40, + completionTokens: 60, + }); + + expect(usage).toEqual({ + total: 100, + input: 40, + output: 60, + }); +}); + +it('createVercelAISDKTokenUsage should default to 0 for missing values', () => { + const usage = createVercelAISDKTokenUsage({}); + + expect(usage).toEqual({ + total: 0, + input: 0, + output: 0, + }); +}); + +it('createVercelAISDKTokenUsage should handle explicitly undefined values', () => { + const usage = createVercelAISDKTokenUsage({ + totalTokens: undefined, + promptTokens: 40, + completionTokens: undefined, + }); + + expect(usage).toEqual({ + total: 0, + input: 40, + output: 0, + }); +}); diff --git a/packages/sdk/server-ai/examples/bedrock/package.json b/packages/sdk/server-ai/examples/bedrock/package.json index c9c1fc4f18..ed47f7ae05 100644 --- a/packages/sdk/server-ai/examples/bedrock/package.json +++ b/packages/sdk/server-ai/examples/bedrock/package.json @@ -24,7 +24,7 @@ "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.679.0", "@launchdarkly/node-server-sdk": "^9.7.1", - "@launchdarkly/server-sdk-ai": "0.9.9" + "@launchdarkly/server-sdk-ai": "0.10.0" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.1.1", diff --git a/packages/sdk/server-ai/examples/openai/package.json b/packages/sdk/server-ai/examples/openai/package.json index d3837617f9..6bb37780d8 100644 --- a/packages/sdk/server-ai/examples/openai/package.json +++ b/packages/sdk/server-ai/examples/openai/package.json @@ -22,7 +22,7 @@ "license": "Apache-2.0", "dependencies": { "@launchdarkly/node-server-sdk": "^9.7.1", - "@launchdarkly/server-sdk-ai": "0.9.9", + "@launchdarkly/server-sdk-ai": "0.10.0", "openai": "^4.58.1" }, "devDependencies": { diff --git a/packages/sdk/server-ai/package.json b/packages/sdk/server-ai/package.json index cbc1b0e333..df134486b3 100644 --- a/packages/sdk/server-ai/package.json +++ b/packages/sdk/server-ai/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/server-sdk-ai", - "version": "0.9.9", + "version": "0.10.0", "description": "LaunchDarkly AI SDK for Server-Side JavaScript", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/server-ai", "repository": { diff --git a/packages/sdk/server-ai/src/LDAIClientImpl.ts b/packages/sdk/server-ai/src/LDAIClientImpl.ts index 57755a2a55..5d9186fce9 100644 --- a/packages/sdk/server-ai/src/LDAIClientImpl.ts +++ b/packages/sdk/server-ai/src/LDAIClientImpl.ts @@ -10,8 +10,12 @@ import { LDMessage, LDModelConfig, LDProviderConfig, + VercelAISDKConfig, + VercelAISDKMapOptions, + VercelAISDKProvider, } from './api/config'; import { LDAIClient } from './api/LDAIClient'; +import { LDAIConfigMapper } from './LDAIConfigMapper'; import { LDAIConfigTrackerImpl } from './LDAIConfigTrackerImpl'; import { LDClientMin } from './LDClientMin'; @@ -103,7 +107,8 @@ export class LDAIClientImpl implements LDAIClient { defaultValue, ); - const agent: LDAIAgent = { + const mapper = new LDAIConfigMapper(model, provider, undefined); + const agent: Omit = { tracker, enabled, }; @@ -124,7 +129,13 @@ export class LDAIClientImpl implements LDAIClient { agent.instructions = this._interpolateTemplate(instructions, allVariables); } - return agent; + return { + ...agent, + toVercelAISDK: ( + sdkProvider: VercelAISDKProvider | Record>, + options?: VercelAISDKMapOptions | undefined, + ): VercelAISDKConfig => mapper.toVercelAISDK(sdkProvider, options), + }; } async config( @@ -133,13 +144,15 @@ export class LDAIClientImpl implements LDAIClient { defaultValue: LDAIDefaults, variables?: Record, ): Promise { - const { tracker, enabled, model, provider, messages } = await this._evaluate( - key, - context, - defaultValue, - ); + const { + tracker, + enabled, + model, + provider: configProvider, + messages, + } = await this._evaluate(key, context, defaultValue); - const config: LDAIConfig = { + const config: Omit = { tracker, enabled, }; @@ -149,8 +162,8 @@ export class LDAIClientImpl implements LDAIClient { if (model) { config.model = { ...model }; } - if (provider) { - config.provider = { ...provider }; + if (configProvider) { + config.provider = { ...configProvider }; } const allVariables = { ...variables, ldctx: context }; @@ -161,7 +174,15 @@ export class LDAIClientImpl implements LDAIClient { })); } - return config; + const mapper = new LDAIConfigMapper(config.model, config.provider, config.messages); + + return { + ...config, + toVercelAISDK: ( + sdkProvider: VercelAISDKProvider | Record>, + options?: VercelAISDKMapOptions | undefined, + ): VercelAISDKConfig => mapper.toVercelAISDK(sdkProvider, options), + }; } async agent( @@ -173,8 +194,17 @@ export class LDAIClientImpl implements LDAIClient { // Track agent usage this._ldClient.track('$ld:ai:agent:function:single', context, key, 1); - // Use provided defaultValue or fallback to { enabled: false } - const resolvedDefaultValue = defaultValue ?? { enabled: false }; + // Use provided defaultValue or create a default one with toVercelAISDK + const resolvedDefaultValue: LDAIAgentDefaults = defaultValue ?? { + enabled: false, + model: { name: 'default-model' }, + toVercelAISDK: ( + sdkProvider: VercelAISDKProvider | Record>, + ): VercelAISDKConfig => ({ + model: sdkProvider as unknown as TMod, + messages: [], + }), + }; return this._evaluateAgent(key, context, resolvedDefaultValue, variables); } @@ -195,8 +225,17 @@ export class LDAIClientImpl implements LDAIClient { await Promise.all( agentConfigs.map(async (config) => { - // Use provided defaultValue or fallback to { enabled: false } - const defaultValue = config.defaultValue ?? { enabled: false }; + // Use provided defaultValue or create a default one with toVercelAISDK + const defaultValue: LDAIAgentDefaults = config.defaultValue ?? { + enabled: false, + model: { name: 'default-model' }, + toVercelAISDK: ( + sdkProvider: VercelAISDKProvider | Record>, + ): VercelAISDKConfig => ({ + model: sdkProvider as unknown as TMod, + messages: [], + }), + }; const agent = await this._evaluateAgent( config.key, diff --git a/packages/sdk/server-ai/src/LDAIConfigMapper.ts b/packages/sdk/server-ai/src/LDAIConfigMapper.ts new file mode 100644 index 0000000000..4992dd21f0 --- /dev/null +++ b/packages/sdk/server-ai/src/LDAIConfigMapper.ts @@ -0,0 +1,64 @@ +import { + LDMessage, + LDModelConfig, + LDProviderConfig, + VercelAISDKConfig, + VercelAISDKMapOptions, + VercelAISDKProvider, +} from './api/config'; + +export class LDAIConfigMapper { + constructor( + private _model?: LDModelConfig | undefined, + private _provider?: LDProviderConfig | undefined, + private _messages?: LDMessage[] | undefined, + ) {} + + private _findParameter(...paramNames: string[]): T | undefined { + for (let i = 0; i < paramNames.length; i += 1) { + const paramName = paramNames[i]; + if (this._model?.parameters?.[paramName] !== undefined) { + return this._model?.parameters?.[paramName] as T; + } + if (this._model?.custom?.[paramName] !== undefined) { + return this._model?.custom?.[paramName] as T; + } + } + return undefined; + } + + toVercelAISDK( + provider: VercelAISDKProvider | Record>, + options?: VercelAISDKMapOptions | undefined, + ): VercelAISDKConfig { + let model: TMod | undefined; + if (typeof provider === 'function') { + model = provider(this._model?.name ?? ''); + } else { + model = provider[this._provider?.name ?? '']?.(this._model?.name ?? ''); + } + if (!model) { + throw new Error( + 'Vercel AI SDK model cannot be determined from the supplied provider parameter.', + ); + } + + let messages: LDMessage[] | undefined; + if (this._messages || options?.nonInterpolatedMessages) { + messages = [...(this._messages ?? []), ...(options?.nonInterpolatedMessages ?? [])]; + } + + return { + model, + messages, + maxTokens: this._findParameter('max_tokens', 'maxTokens'), + temperature: this._findParameter('temperature'), + topP: this._findParameter('top_p', 'topP'), + topK: this._findParameter('top_k', 'topK'), + presencePenalty: this._findParameter('presence_penalty', 'presencePenalty'), + frequencyPenalty: this._findParameter('frequency_penalty', 'frequencyPenalty'), + stopSequences: this._findParameter('stop', 'stop_sequences', 'stopSequences'), + seed: this._findParameter('seed'), + }; + } +} diff --git a/packages/sdk/server-ai/src/LDAIConfigTrackerImpl.ts b/packages/sdk/server-ai/src/LDAIConfigTrackerImpl.ts index 0972a5eee5..73c4bdfbd5 100644 --- a/packages/sdk/server-ai/src/LDAIConfigTrackerImpl.ts +++ b/packages/sdk/server-ai/src/LDAIConfigTrackerImpl.ts @@ -2,8 +2,13 @@ import { LDContext } from '@launchdarkly/js-server-sdk-common'; import { LDAIConfigTracker } from './api/config'; import { LDAIMetricSummary } from './api/config/LDAIConfigTracker'; -import { createBedrockTokenUsage, LDFeedbackKind, LDTokenUsage } from './api/metrics'; -import { createOpenAiUsage } from './api/metrics/OpenAiUsage'; +import { + createBedrockTokenUsage, + createOpenAiUsage, + createVercelAISDKTokenUsage, + LDFeedbackKind, + LDTokenUsage, +} from './api/metrics'; import { LDClientMin } from './LDClientMin'; export class LDAIConfigTrackerImpl implements LDAIConfigTracker { @@ -121,6 +126,72 @@ export class LDAIConfigTrackerImpl implements LDAIConfigTracker { return res; } + async trackVercelAISDKGenerateTextMetrics< + TRes extends { + usage?: { + totalTokens?: number; + promptTokens?: number; + completionTokens?: number; + }; + }, + >(func: () => Promise): Promise { + try { + const result = await this.trackDurationOf(func); + this.trackSuccess(); + if (result.usage) { + this.trackTokens(createVercelAISDKTokenUsage(result.usage)); + } + return result; + } catch (err) { + this.trackError(); + throw err; + } + } + + trackVercelAISDKStreamTextMetrics< + TRes extends { + finishReason?: Promise; + usage?: Promise<{ + totalTokens?: number; + promptTokens?: number; + completionTokens?: number; + }>; + }, + >(func: () => TRes): TRes { + const startTime = Date.now(); + try { + const result = func(); + result.finishReason + ?.then(async (finishReason) => { + const endTime = Date.now(); + this.trackDuration(endTime - startTime); + if (finishReason === 'error') { + this.trackError(); + } else { + this.trackSuccess(); + if (result.usage) { + try { + this.trackTokens(createVercelAISDKTokenUsage(await result.usage)); + } catch { + // Intentionally squashing this error + } + } + } + }) + .catch(() => { + const endTime = Date.now(); + this.trackDuration(endTime - startTime); + this.trackError(); + }); + return result; + } catch (err) { + const endTime = Date.now(); + this.trackDuration(endTime - startTime); + this.trackError(); + throw err; + } + } + trackTokens(tokens: LDTokenUsage): void { this._trackedMetrics.tokens = tokens; const trackData = this._getTrackData(); diff --git a/packages/sdk/server-ai/src/api/config/LDAIConfig.ts b/packages/sdk/server-ai/src/api/config/LDAIConfig.ts index 308ff02529..c5071a6d59 100644 --- a/packages/sdk/server-ai/src/api/config/LDAIConfig.ts +++ b/packages/sdk/server-ai/src/api/config/LDAIConfig.ts @@ -1,4 +1,5 @@ import { LDAIConfigTracker } from './LDAIConfigTracker'; +import { VercelAISDKConfig, VercelAISDKMapOptions, VercelAISDKProvider } from './VercelAISDK'; /** * Configuration related to the model. @@ -22,7 +23,7 @@ export interface LDModelConfig { export interface LDProviderConfig { /** - * The ID of the provider. + * The name of the provider. */ name: string; } @@ -68,13 +69,29 @@ export interface LDAIConfig { * Whether the configuration is enabled. */ enabled: boolean; + + /** + * Maps this AI config to a format usable direcly in Vercel AI SDK generateText() + * and streamText() methods. + * + * WARNING: this method can throw an exception if a Vercel AI SDK model cannot be determined. + * + * @param provider A Vercel AI SDK Provider or a map of provider names to Vercel AI SDK Providers. + * @param options Optional mapping options. + * @returns A configuration directly usable in Vercel AI SDK generateText() and streamText() + * @throws {Error} if a Vercel AI SDK model cannot be determined from the given provider parameter. + */ + toVercelAISDK: ( + provider: VercelAISDKProvider | Record>, + options?: VercelAISDKMapOptions | undefined, + ) => VercelAISDKConfig; } /** * Default value for a `modelConfig`. This is the same as the LDAIConfig, but it does not include - * a tracker and `enabled` is optional. + * a tracker or mapper, and `enabled` is optional. */ -export type LDAIDefaults = Omit & { +export type LDAIDefaults = Omit & { /** * Whether the configuration is enabled. * diff --git a/packages/sdk/server-ai/src/api/config/LDAIConfigTracker.ts b/packages/sdk/server-ai/src/api/config/LDAIConfigTracker.ts index 2f92aa9386..dfed0fa4db 100644 --- a/packages/sdk/server-ai/src/api/config/LDAIConfigTracker.ts +++ b/packages/sdk/server-ai/src/api/config/LDAIConfigTracker.ts @@ -133,6 +133,55 @@ export interface LDAIConfigTracker { res: TRes, ): TRes; + /** + * Track a Vercel AI SDK generateText operation. + * + * This function will track the duration of the operation, the token usage, and the success or error status. + * + * If the provided function throws, then this method will also throw. + * In the case the provided function throws, this function will record the duration and an error. + * A failed operation will not have any token usage data. + * + * @param func Function which executes the operation. + * @returns The result of the operation. + */ + trackVercelAISDKGenerateTextMetrics< + TRes extends { + usage?: { + totalTokens?: number; + promptTokens?: number; + completionTokens?: number; + }; + }, + >( + func: () => Promise, + ): Promise; + + /** + * Track a Vercel AI SDK streamText operation. + * + * This function will track the duration of the operation, the token usage, and the success or error status. + * + * If the provided function throws, then this method will also throw. + * In the case the provided function throws, this function will record the duration and an error. + * A failed operation will not have any token usage data. + * + * @param func Function which executes the operation. + * @returns The result of the operation. + */ + trackVercelAISDKStreamTextMetrics< + TRes extends { + finishReason?: Promise; + usage?: Promise<{ + totalTokens?: number; + promptTokens?: number; + completionTokens?: number; + }>; + }, + >( + func: () => TRes, + ): TRes; + /** * Get a summary of the tracked metrics. */ diff --git a/packages/sdk/server-ai/src/api/config/VercelAISDK.ts b/packages/sdk/server-ai/src/api/config/VercelAISDK.ts new file mode 100644 index 0000000000..4387fba06d --- /dev/null +++ b/packages/sdk/server-ai/src/api/config/VercelAISDK.ts @@ -0,0 +1,20 @@ +import { type LDMessage } from './LDAIConfig'; + +export type VercelAISDKProvider = (modelName: string) => TMod; + +export interface VercelAISDKMapOptions { + nonInterpolatedMessages?: LDMessage[] | undefined; +} + +export interface VercelAISDKConfig { + model: TMod; + messages?: LDMessage[] | undefined; + maxTokens?: number | undefined; + temperature?: number | undefined; + topP?: number | undefined; + topK?: number | undefined; + presencePenalty?: number | undefined; + frequencyPenalty?: number | undefined; + stopSequences?: string[] | undefined; + seed?: number | undefined; +} diff --git a/packages/sdk/server-ai/src/api/config/index.ts b/packages/sdk/server-ai/src/api/config/index.ts index 1c07d5c3a4..a3f3752908 100644 --- a/packages/sdk/server-ai/src/api/config/index.ts +++ b/packages/sdk/server-ai/src/api/config/index.ts @@ -1,2 +1,3 @@ export * from './LDAIConfig'; +export * from './VercelAISDK'; export { LDAIConfigTracker } from './LDAIConfigTracker'; diff --git a/packages/sdk/server-ai/src/api/metrics/VercelAISDKTokenUsage.ts b/packages/sdk/server-ai/src/api/metrics/VercelAISDKTokenUsage.ts new file mode 100644 index 0000000000..dbe83a8bf4 --- /dev/null +++ b/packages/sdk/server-ai/src/api/metrics/VercelAISDKTokenUsage.ts @@ -0,0 +1,13 @@ +import { LDTokenUsage } from './LDTokenUsage'; + +export function createVercelAISDKTokenUsage(data: { + totalTokens?: number; + promptTokens?: number; + completionTokens?: number; +}): LDTokenUsage { + return { + total: data.totalTokens ?? 0, + input: data.promptTokens ?? 0, + output: data.completionTokens ?? 0, + }; +} diff --git a/packages/sdk/server-ai/src/api/metrics/index.ts b/packages/sdk/server-ai/src/api/metrics/index.ts index 9f5e199f59..157fbd593c 100644 --- a/packages/sdk/server-ai/src/api/metrics/index.ts +++ b/packages/sdk/server-ai/src/api/metrics/index.ts @@ -1,3 +1,5 @@ export * from './BedrockTokenUsage'; +export * from './OpenAiUsage'; export * from './LDFeedbackKind'; export * from './LDTokenUsage'; +export * from './VercelAISDKTokenUsage'; From f0822eba5954371c783f8454aa9c9bb8e61730c8 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Mon, 21 Jul 2025 23:50:28 -0500 Subject: [PATCH 5/9] remove optional default value for .agents and .agent --- .../__tests__/LDAIClientImpl.test.ts | 260 ++++++++---------- packages/sdk/server-ai/src/LDAIClientImpl.ts | 32 +-- packages/sdk/server-ai/src/api/LDAIClient.ts | 19 +- .../sdk/server-ai/src/api/agents/LDAIAgent.ts | 14 +- 4 files changed, 130 insertions(+), 195 deletions(-) diff --git a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts index 59b70dcc75..d4654b59e7 100644 --- a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts +++ b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts @@ -1,7 +1,7 @@ import { LDContext } from '@launchdarkly/js-server-sdk-common'; import { LDAIAgentDefaults } from '../src/api/agents'; -import { LDAIDefaults } from '../src/api/config'; +import { LDAIDefaults, VercelAISDKProvider } from '../src/api/config'; import { LDAIClientImpl } from '../src/LDAIClientImpl'; import { LDClientMin } from '../src/LDClientMin'; @@ -21,7 +21,7 @@ it('returns config with interpolated messagess', async () => { const key = 'test-flag'; const defaultValue: LDAIDefaults = { model: { name: 'test', parameters: { name: 'test-model' } }, - messages: [], + messages: [{ role: 'system', content: 'Hello {{name}}' }], enabled: true, }; @@ -34,8 +34,14 @@ it('returns config with interpolated messagess', async () => { name: 'example-provider', }, messages: [ - { role: 'system', content: 'Hello {{name}}' }, - { role: 'user', content: 'Score: {{score}}' }, + { + role: 'system', + content: 'You are a helpful assistant. Your name is {{name}} and your score is {{score}}', + }, + { + role: 'user', + content: 'Tell me about yourself.', + }, ], _ldMeta: { variationKey: 'v1', @@ -57,8 +63,14 @@ it('returns config with interpolated messagess', async () => { name: 'example-provider', }, messages: [ - { role: 'system', content: 'Hello John' }, - { role: 'user', content: 'Score: 42' }, + { + role: 'system', + content: 'You are a helpful assistant. Your name is John and your score is 42', + }, + { + role: 'user', + content: 'Tell me about yourself.', + }, ], tracker: expect.any(Object), enabled: true, @@ -66,32 +78,13 @@ it('returns config with interpolated messagess', async () => { }); }); -it('includes context in variables for messages interpolation', async () => { - const client = new LDAIClientImpl(mockLdClient); - const key = 'test-flag'; - const defaultValue: LDAIDefaults = { - model: { name: 'test', parameters: { name: 'test-model' } }, - messages: [], - }; - - const mockVariation = { - messages: [{ role: 'system', content: 'User key: {{ldctx.key}}' }], - _ldMeta: { variationKey: 'v1', enabled: true }, - }; - - mockLdClient.variation.mockResolvedValue(mockVariation); - - const result = await client.config(key, testContext, defaultValue); - - expect(result.messages?.[0].content).toBe('User key: test-user'); -}); - it('handles missing metadata in variation', async () => { const client = new LDAIClientImpl(mockLdClient); const key = 'test-flag'; const defaultValue: LDAIDefaults = { model: { name: 'test', parameters: { name: 'test-model' } }, - messages: [], + messages: [{ role: 'system', content: 'Hello' }], + enabled: true, }; const mockVariation = { @@ -118,7 +111,7 @@ it('passes the default value to the underlying client', async () => { const defaultValue: LDAIDefaults = { model: { name: 'default-model', parameters: { name: 'default' } }, provider: { name: 'default-provider' }, - messages: [{ role: 'system', content: 'Default messages' }], + messages: [{ role: 'system', content: 'Default message' }], enabled: true, }; @@ -145,6 +138,21 @@ it('returns single agent config with interpolated instructions', async () => { model: { name: 'test', parameters: { name: 'test-model' } }, instructions: 'You are a helpful assistant.', enabled: true, + toVercelAISDK: ( + provider: VercelAISDKProvider | Record>, + options, + ) => { + const modelProvider = typeof provider === 'function' ? provider : provider.test; + return { + model: modelProvider('test-model'), + messages: [], + ...(options?.nonInterpolatedMessages + ? { + messages: options.nonInterpolatedMessages, + } + : {}), + }; + }, }; const mockVariation = { @@ -179,6 +187,7 @@ it('returns single agent config with interpolated instructions', async () => { 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 @@ -196,6 +205,22 @@ it('includes context in variables for agent instructions interpolation', async ( const defaultValue: LDAIAgentDefaults = { model: { name: 'test', parameters: { name: 'test-model' } }, instructions: 'You are a helpful assistant.', + enabled: true, + toVercelAISDK: ( + provider: VercelAISDKProvider | Record>, + options, + ) => { + const modelProvider = typeof provider === 'function' ? provider : provider.test; + return { + model: modelProvider('test-model'), + messages: [], + ...(options?.nonInterpolatedMessages + ? { + messages: options.nonInterpolatedMessages, + } + : {}), + }; + }, }; const mockVariation = { @@ -216,6 +241,22 @@ it('handles missing metadata in agent variation', async () => { const defaultValue: LDAIAgentDefaults = { model: { name: 'test', parameters: { name: 'test-model' } }, instructions: 'You are a helpful assistant.', + enabled: true, + toVercelAISDK: ( + provider: VercelAISDKProvider | Record>, + options, + ) => { + const modelProvider = typeof provider === 'function' ? provider : provider.test; + return { + model: modelProvider('test-model'), + messages: [], + ...(options?.nonInterpolatedMessages + ? { + messages: options.nonInterpolatedMessages, + } + : {}), + }; + }, }; const mockVariation = { @@ -232,6 +273,7 @@ it('handles missing metadata in agent variation', async () => { instructions: 'Hello.', tracker: expect.any(Object), enabled: false, + toVercelAISDK: expect.any(Function), }); }); @@ -243,6 +285,22 @@ it('passes the default value to the underlying client for single agent', async ( provider: { name: 'default-provider' }, instructions: 'Default instructions', enabled: true, + toVercelAISDK: ( + provider: VercelAISDKProvider | Record>, + options, + ) => { + const modelProvider = + typeof provider === 'function' ? provider : provider['default-provider']; + return { + model: modelProvider('default-model'), + messages: [], + ...(options?.nonInterpolatedMessages + ? { + messages: options.nonInterpolatedMessages, + } + : {}), + }; + }, }; mockLdClient.variation.mockResolvedValue(defaultValue); @@ -255,69 +313,12 @@ it('passes the default value to the underlying client for single agent', async ( provider: defaultValue.provider, tracker: expect.any(Object), enabled: false, + toVercelAISDK: expect.any(Function), }); expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, defaultValue); }); -it('handles single agent with optional defaultValue', async () => { - const client = new LDAIClientImpl(mockLdClient); - const key = 'test-agent'; - - const mockVariation = { - instructions: 'You are a helpful assistant named {{name}}.', - _ldMeta: { variationKey: 'v1', enabled: true, mode: 'agent' }, - }; - - mockLdClient.variation.mockResolvedValue(mockVariation); - - const variables = { name: 'Helper' }; - - // Test without providing defaultValue - const result = await client.agent(key, testContext, undefined, variables); - - expect(result).toEqual({ - instructions: 'You are a helpful assistant named Helper.', - tracker: expect.any(Object), - enabled: true, - }); - - // Verify tracking was called - expect(mockLdClient.track).toHaveBeenCalledWith( - '$ld:ai:agent:function:single', - testContext, - key, - 1, - ); - - // Verify the agent was called with { enabled: false } as default - expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, { enabled: false }); -}); - -it('handles single agent without any optional parameters', async () => { - const client = new LDAIClientImpl(mockLdClient); - const key = 'simple-agent'; - - const mockVariation = { - instructions: 'Simple instructions.', - _ldMeta: { variationKey: 'v1', enabled: false, mode: 'agent' }, - }; - - mockLdClient.variation.mockResolvedValue(mockVariation); - - // Test with only required parameters - const result = await client.agent(key, testContext); - - expect(result).toEqual({ - instructions: 'Simple instructions.', - tracker: expect.any(Object), - enabled: false, - }); - - // Verify the agent was called with { enabled: false } as default and no variables - expect(mockLdClient.variation).toHaveBeenCalledWith(key, testContext, { enabled: false }); -}); - it('returns multiple agents config with interpolated instructions', async () => { const client = new LDAIClientImpl(mockLdClient); @@ -328,6 +329,21 @@ it('returns multiple agents config with interpolated instructions', async () => model: { name: 'test', parameters: { name: 'test-model' } }, instructions: 'You are a research assistant.', enabled: true, + toVercelAISDK: ( + provider: VercelAISDKProvider | Record>, + options, + ) => { + const modelProvider = typeof provider === 'function' ? provider : provider.test; + return { + model: modelProvider('test-model'), + messages: [], + ...(options?.nonInterpolatedMessages + ? { + messages: options.nonInterpolatedMessages, + } + : {}), + }; + }, }, variables: { topic: 'climate change' }, }, @@ -337,6 +353,21 @@ it('returns multiple agents config with interpolated instructions', async () => model: { name: 'test', parameters: { name: 'test-model' } }, instructions: 'You are a writing assistant.', enabled: true, + toVercelAISDK: ( + provider: VercelAISDKProvider | Record>, + options, + ) => { + const modelProvider = typeof provider === 'function' ? provider : provider.test; + return { + model: modelProvider('test-model'), + messages: [], + ...(options?.nonInterpolatedMessages + ? { + messages: options.nonInterpolatedMessages, + } + : {}), + }; + }, }, variables: { style: 'academic' }, }, @@ -379,6 +410,7 @@ it('returns multiple agents config with interpolated instructions', async () => instructions: 'You are a research assistant specializing in climate change.', tracker: expect.any(Object), enabled: true, + toVercelAISDK: expect.any(Function), }, 'writing-agent': { model: { @@ -389,6 +421,7 @@ it('returns multiple agents config with interpolated instructions', async () => instructions: 'You are a writing assistant with academic style.', tracker: expect.any(Object), enabled: true, + toVercelAISDK: expect.any(Function), }, }); @@ -416,58 +449,3 @@ it('handles empty agent configs array', async () => { 0, ); }); - -it('handles agents with optional defaultValue', async () => { - const client = new LDAIClientImpl(mockLdClient); - - const agentConfigs = [ - { - key: 'agent-with-default', - defaultValue: { - instructions: 'You are a helpful assistant.', - enabled: true, - }, - variables: { name: 'Assistant' }, - }, - { - key: 'agent-without-default', - variables: { name: 'Helper' }, - // No defaultValue provided - should default to { enabled: false } - }, - ] as const; - - const mockVariations = { - 'agent-with-default': { - instructions: 'Hello {{name}}!', - _ldMeta: { variationKey: 'v1', enabled: true, mode: 'agent' }, - }, - 'agent-without-default': { - instructions: 'Hi {{name}}!', - _ldMeta: { variationKey: 'v2', enabled: false, mode: 'agent' }, - }, - }; - - mockLdClient.variation.mockImplementation((key) => - Promise.resolve(mockVariations[key as keyof typeof mockVariations]), - ); - - const result = await client.agents(agentConfigs, testContext); - - expect(result).toEqual({ - 'agent-with-default': { - instructions: 'Hello Assistant!', - tracker: expect.any(Object), - enabled: true, - }, - 'agent-without-default': { - instructions: 'Hi Helper!', - tracker: expect.any(Object), - enabled: false, - }, - }); - - // Verify the agent without defaultValue was called with { enabled: false } - expect(mockLdClient.variation).toHaveBeenCalledWith('agent-without-default', testContext, { - enabled: false, - }); -}); diff --git a/packages/sdk/server-ai/src/LDAIClientImpl.ts b/packages/sdk/server-ai/src/LDAIClientImpl.ts index 5d9186fce9..331e74800b 100644 --- a/packages/sdk/server-ai/src/LDAIClientImpl.ts +++ b/packages/sdk/server-ai/src/LDAIClientImpl.ts @@ -188,28 +188,16 @@ export class LDAIClientImpl implements LDAIClient { async agent( key: string, context: LDContext, - defaultValue?: LDAIAgentDefaults, + defaultValue: LDAIAgentDefaults, variables?: Record, ): Promise { // Track agent usage this._ldClient.track('$ld:ai:agent:function:single', context, key, 1); - // Use provided defaultValue or create a default one with toVercelAISDK - const resolvedDefaultValue: LDAIAgentDefaults = defaultValue ?? { - enabled: false, - model: { name: 'default-model' }, - toVercelAISDK: ( - sdkProvider: VercelAISDKProvider | Record>, - ): VercelAISDKConfig => ({ - model: sdkProvider as unknown as TMod, - messages: [], - }), - }; - - return this._evaluateAgent(key, context, resolvedDefaultValue, variables); + return this._evaluateAgent(key, context, defaultValue, variables); } - async agents( + async agents( agentConfigs: T, context: LDContext, ): Promise> { @@ -225,22 +213,10 @@ export class LDAIClientImpl implements LDAIClient { await Promise.all( agentConfigs.map(async (config) => { - // Use provided defaultValue or create a default one with toVercelAISDK - const defaultValue: LDAIAgentDefaults = config.defaultValue ?? { - enabled: false, - model: { name: 'default-model' }, - toVercelAISDK: ( - sdkProvider: VercelAISDKProvider | Record>, - ): VercelAISDKConfig => ({ - model: sdkProvider as unknown as TMod, - messages: [], - }), - }; - const agent = await this._evaluateAgent( config.key, context, - defaultValue, + config.defaultValue, config.variables, ); agents[config.key as T[number]['key']] = agent; diff --git a/packages/sdk/server-ai/src/api/LDAIClient.ts b/packages/sdk/server-ai/src/api/LDAIClient.ts index bbf71c359f..be02e887d1 100644 --- a/packages/sdk/server-ai/src/api/LDAIClient.ts +++ b/packages/sdk/server-ai/src/api/LDAIClient.ts @@ -73,8 +73,7 @@ export interface LDAIClient { * @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. If not - * provided, defaults to { enabled: false }. + * @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. @@ -88,18 +87,11 @@ export interface LDAIClient { * const key = "research_agent"; * const context = {...}; * const variables = { topic: 'climate change' }; - * - * // With explicit defaultValue * const agent = await client.agent(key, context, { * enabled: true, * instructions: 'You are a research assistant.', * }, variables); * - * // Without defaultValue (defaults to { enabled: false }) - * const simpleAgent = await client.agent(key, context, undefined, variables); - * // or even simpler: - * const simpleAgent2 = await client.agent(key, context); - * * const researchResult = agent.instructions; // Interpolated instructions * agent.tracker.trackSuccess(); * ``` @@ -107,7 +99,7 @@ export interface LDAIClient { agent( key: string, context: LDContext, - defaultValue?: LDAIAgentDefaults, + defaultValue: LDAIAgentDefaults, variables?: Record, ): Promise; @@ -128,7 +120,7 @@ export interface LDAIClient { * * @example * ``` - * const agentConfigs: LDAIAgentConfig[] = [ + * const agentConfigs = [ * { * key: 'research_agent', * defaultValue: { enabled: true, instructions: 'You are a research assistant.' }, @@ -138,11 +130,6 @@ export interface LDAIClient { * key: 'writing_agent', * defaultValue: { enabled: true, instructions: 'You are a writing assistant.' }, * variables: { style: 'academic' } - * }, - * { - * key: 'simple_agent', - * variables: { name: 'Helper' } - * // defaultValue is optional, will default to { enabled: false } * } * ] as const; * const context = {...}; diff --git a/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts b/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts index ddfcfc6d64..beec28d050 100644 --- a/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts +++ b/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts @@ -20,9 +20,9 @@ export interface LDAIAgentConfig { key: string; /** - * Default configuration for the agent. If not provided, defaults to { enabled: false }. + * Default configuration for the agent. */ - defaultValue?: LDAIAgentDefaults; + defaultValue: LDAIAgentDefaults; /** * Variables for instructions interpolation. @@ -31,14 +31,8 @@ export interface LDAIAgentConfig { } /** - * Default value for an agent configuration. This is the same as the LDAIAgent, but it does not include - * a tracker and `enabled` is optional. + * Default values for an agent. */ export type LDAIAgentDefaults = Omit & { - /** - * Whether the agent configuration is enabled. - * - * @default false - */ - enabled?: boolean; + enabled: boolean; }; From 55be7b191bacd07f783a3ddbaa8f605b6e788913 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Tue, 22 Jul 2025 00:01:33 -0500 Subject: [PATCH 6/9] fix tests --- .../__tests__/LDAIClientImpl.test.ts | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts index d4654b59e7..fd1f8071c8 100644 --- a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts +++ b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts @@ -21,27 +21,21 @@ it('returns config with interpolated messagess', async () => { const key = 'test-flag'; const defaultValue: LDAIDefaults = { model: { name: 'test', parameters: { name: 'test-model' } }, - messages: [{ role: 'system', content: 'Hello {{name}}' }], + messages: [], enabled: true, }; const mockVariation = { model: { name: 'example-model', - parameters: { name: 'imagination', temperature: 0.7, maxTokens: 4096 }, + parameters: { name: 'imagination', temperature: 0.7 }, }, provider: { name: 'example-provider', }, messages: [ - { - role: 'system', - content: 'You are a helpful assistant. Your name is {{name}} and your score is {{score}}', - }, - { - role: 'user', - content: 'Tell me about yourself.', - }, + { role: 'system', content: 'Hello {{name}}' }, + { role: 'user', content: 'Score: {{score}}' }, ], _ldMeta: { variationKey: 'v1', @@ -57,20 +51,14 @@ it('returns config with interpolated messagess', async () => { expect(result).toEqual({ model: { name: 'example-model', - parameters: { name: 'imagination', temperature: 0.7, maxTokens: 4096 }, + parameters: { name: 'imagination', temperature: 0.7 }, }, provider: { name: 'example-provider', }, messages: [ - { - role: 'system', - content: 'You are a helpful assistant. Your name is John and your score is 42', - }, - { - role: 'user', - content: 'Tell me about yourself.', - }, + { role: 'system', content: 'Hello John' }, + { role: 'user', content: 'Score: 42' }, ], tracker: expect.any(Object), enabled: true, @@ -78,13 +66,33 @@ it('returns config with interpolated messagess', async () => { }); }); +it('includes context in variables for messages interpolation', async () => { + const client = new LDAIClientImpl(mockLdClient); + const key = 'test-flag'; + const defaultValue: LDAIDefaults = { + model: { name: 'test', parameters: { name: 'test-model' } }, + messages: [], + }; + + const mockVariation = { + messages: [{ role: 'system', content: 'User key: {{ldctx.key}}' }], + _ldMeta: { variationKey: 'v1', enabled: true }, + }; + + mockLdClient.variation.mockResolvedValue(mockVariation); + + 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 () => { const client = new LDAIClientImpl(mockLdClient); const key = 'test-flag'; const defaultValue: LDAIDefaults = { model: { name: 'test', parameters: { name: 'test-model' } }, - messages: [{ role: 'system', content: 'Hello' }], - enabled: true, + messages: [], }; const mockVariation = { @@ -111,7 +119,7 @@ it('passes the default value to the underlying client', async () => { const defaultValue: LDAIDefaults = { model: { name: 'default-model', parameters: { name: 'default' } }, provider: { name: 'default-provider' }, - messages: [{ role: 'system', content: 'Default message' }], + messages: [{ role: 'system', content: 'Default messages' }], enabled: true, }; @@ -131,6 +139,7 @@ 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'; From 3f5c7347895eab8f925fa384bb40dbe0c363f539 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Tue, 22 Jul 2025 00:03:23 -0500 Subject: [PATCH 7/9] reverted unneccessary change in test --- packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts index fd1f8071c8..096d18d109 100644 --- a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts +++ b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts @@ -12,10 +12,6 @@ const mockLdClient: jest.Mocked = { const testContext: LDContext = { kind: 'user', key: 'test-user' }; -beforeEach(() => { - jest.clearAllMocks(); -}); - it('returns config with interpolated messagess', async () => { const client = new LDAIClientImpl(mockLdClient); const key = 'test-flag'; @@ -28,7 +24,7 @@ it('returns config with interpolated messagess', async () => { const mockVariation = { model: { name: 'example-model', - parameters: { name: 'imagination', temperature: 0.7 }, + parameters: { name: 'imagination', temperature: 0.7, maxTokens: 4096 }, }, provider: { name: 'example-provider', @@ -51,7 +47,7 @@ it('returns config with interpolated messagess', async () => { expect(result).toEqual({ model: { name: 'example-model', - parameters: { name: 'imagination', temperature: 0.7 }, + parameters: { name: 'imagination', temperature: 0.7, maxTokens: 4096 }, }, provider: { name: 'example-provider', From 661627123476f8e94a5f3753bcec130b71504dfd Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Tue, 22 Jul 2025 16:19:09 -0500 Subject: [PATCH 8/9] removed unneccesary override --- packages/sdk/server-ai/src/api/agents/LDAIAgent.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts b/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts index beec28d050..63e3bae68d 100644 --- a/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts +++ b/packages/sdk/server-ai/src/api/agents/LDAIAgent.ts @@ -33,6 +33,4 @@ export interface LDAIAgentConfig { /** * Default values for an agent. */ -export type LDAIAgentDefaults = Omit & { - enabled: boolean; -}; +export type LDAIAgentDefaults = Omit; From 9c582a58d3b2d3b32c242b49f54b411f812001e4 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Tue, 22 Jul 2025 17:09:38 -0500 Subject: [PATCH 9/9] fixed typing issue in LDAIClientImpl.test.ts --- .../__tests__/LDAIClientImpl.test.ts | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts index 096d18d109..6c75df56a3 100644 --- a/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts +++ b/packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts @@ -1,7 +1,12 @@ import { LDContext } from '@launchdarkly/js-server-sdk-common'; import { LDAIAgentDefaults } from '../src/api/agents'; -import { LDAIDefaults, VercelAISDKProvider } from '../src/api/config'; +import { + LDAIDefaults, + VercelAISDKConfig, + VercelAISDKMapOptions, + VercelAISDKProvider, +} from '../src/api/config'; import { LDAIClientImpl } from '../src/LDAIClientImpl'; import { LDClientMin } from '../src/LDClientMin'; @@ -145,8 +150,8 @@ it('returns single agent config with interpolated instructions', async () => { enabled: true, toVercelAISDK: ( provider: VercelAISDKProvider | Record>, - options, - ) => { + options?: VercelAISDKMapOptions, + ): VercelAISDKConfig => { const modelProvider = typeof provider === 'function' ? provider : provider.test; return { model: modelProvider('test-model'), @@ -213,8 +218,8 @@ it('includes context in variables for agent instructions interpolation', async ( enabled: true, toVercelAISDK: ( provider: VercelAISDKProvider | Record>, - options, - ) => { + options?: VercelAISDKMapOptions, + ): VercelAISDKConfig => { const modelProvider = typeof provider === 'function' ? provider : provider.test; return { model: modelProvider('test-model'), @@ -249,8 +254,8 @@ it('handles missing metadata in agent variation', async () => { enabled: true, toVercelAISDK: ( provider: VercelAISDKProvider | Record>, - options, - ) => { + options?: VercelAISDKMapOptions, + ): VercelAISDKConfig => { const modelProvider = typeof provider === 'function' ? provider : provider.test; return { model: modelProvider('test-model'), @@ -292,8 +297,8 @@ it('passes the default value to the underlying client for single agent', async ( enabled: true, toVercelAISDK: ( provider: VercelAISDKProvider | Record>, - options, - ) => { + options?: VercelAISDKMapOptions, + ): VercelAISDKConfig => { const modelProvider = typeof provider === 'function' ? provider : provider['default-provider']; return { @@ -336,8 +341,8 @@ it('returns multiple agents config with interpolated instructions', async () => enabled: true, toVercelAISDK: ( provider: VercelAISDKProvider | Record>, - options, - ) => { + options?: VercelAISDKMapOptions, + ): VercelAISDKConfig => { const modelProvider = typeof provider === 'function' ? provider : provider.test; return { model: modelProvider('test-model'), @@ -360,8 +365,8 @@ it('returns multiple agents config with interpolated instructions', async () => enabled: true, toVercelAISDK: ( provider: VercelAISDKProvider | Record>, - options, - ) => { + options?: VercelAISDKMapOptions, + ): VercelAISDKConfig => { const modelProvider = typeof provider === 'function' ? provider : provider.test; return { model: modelProvider('test-model'),