Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 48 additions & 29 deletions packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -916,40 +916,62 @@ describe('tools map support', () => {
expect(result.tools).toBeUndefined();
});

it('parses tools from model.parameters.tools when root tools is absent', async () => {
it('converts model.parameters.tools array to map when root tools is absent', async () => {
const client = new LDAIClientImpl(mockLdClient);
const key = 'test-flag';
const defaultValue: LDAICompletionConfigDefault = { enabled: false };
const modelParamsTools = {
'web-search-tool': {
name: 'web-search-tool',
type: 'function',
parameters: { type: 'object', properties: {}, required: [] },
},
};
const mockVariation = {
model: { name: 'example-model', parameters: { tools: modelParamsTools } },
model: {
name: 'example-model',
parameters: {
tools: [
{
name: 'search',
type: 'function',
description: 'Search the web',
parameters: { type: 'object', properties: {}, required: [] },
},
{
name: 'get_weather',
type: 'function',
description: 'Get weather for a location',
},
],
},
},
_ldMeta: { variationKey: 'v1', enabled: true, mode: 'completion' },
};
mockLdClient.variation.mockResolvedValue(mockVariation);

const result = await client.completionConfig(key, testContext, defaultValue);

expect(result.tools).toEqual(modelParamsTools);
expect(result.tools).toBeDefined();
expect(result.tools!['search'].name).toBe('search');
expect(result.tools!['search'].type).toBe('function');
expect(result.tools!['search'].description).toBe('Search the web');
expect(result.tools!['search'].parameters).toEqual({
type: 'object',
properties: {},
required: [],
});
expect(result.tools!['get_weather'].name).toBe('get_weather');
expect(result.tools!['get_weather'].description).toBe('Get weather for a location');
});

it('uses root tools over model.parameters.tools when both are present', async () => {
it('uses root tools map over model.parameters.tools array when both are present', async () => {
const client = new LDAIClientImpl(mockLdClient);
const key = 'test-flag';
const defaultValue: LDAICompletionConfigDefault = { enabled: false };
const rootTools = {
'root-tool': { name: 'root-tool', type: 'function' },
};
const modelParamsTools = {
'params-tool': { name: 'params-tool', type: 'function' },
};
const mockVariation = {
model: { name: 'example-model', parameters: { tools: modelParamsTools } },
model: {
name: 'example-model',
parameters: {
tools: [{ name: 'params-tool', type: 'function' }],
},
},
tools: rootTools,
_ldMeta: { variationKey: 'v1', enabled: true, mode: 'completion' },
};
Expand All @@ -960,15 +982,18 @@ describe('tools map support', () => {
expect(result.tools).toEqual(rootTools);
});

it('returns undefined when model.parameters.tools is an array', async () => {
it('skips model.parameters.tools array entries without a name', async () => {
const client = new LDAIClientImpl(mockLdClient);
const key = 'test-flag';
const defaultValue: LDAICompletionConfigDefault = { enabled: false };
const mockVariation = {
model: {
name: 'example-model',
parameters: {
tools: [{ type: 'function', function: { name: 'search', description: 'Search' } }],
tools: [
{ name: 'valid-tool', type: 'function' },
{ type: 'function', description: 'no name on this one' },
],
},
},
_ldMeta: { variationKey: 'v1', enabled: true, mode: 'completion' },
Expand All @@ -977,32 +1002,26 @@ describe('tools map support', () => {

const result = await client.completionConfig(key, testContext, defaultValue);

expect(result.tools).toBeUndefined();
expect(result.tools).toBeDefined();
expect(result.tools!['valid-tool'].name).toBe('valid-tool');
expect(Object.keys(result.tools!)).toHaveLength(1);
});

it('falls back to key name for model.parameters.tools entries missing the name field', async () => {
it('returns undefined when model.parameters.tools is not an array', async () => {
const client = new LDAIClientImpl(mockLdClient);
const key = 'test-flag';
const defaultValue: LDAICompletionConfigDefault = { enabled: false };
const mockVariation = {
model: {
name: 'example-model',
parameters: {
tools: {
'valid-tool': { name: 'valid-tool', type: 'function' },
'no-name-tool': { type: 'function' },
},
},
parameters: { tools: 'not-an-array' },
},
_ldMeta: { variationKey: 'v1', enabled: true, mode: 'completion' },
};
mockLdClient.variation.mockResolvedValue(mockVariation);

const result = await client.completionConfig(key, testContext, defaultValue);

expect(result.tools).toBeDefined();
expect(result.tools!['valid-tool'].name).toBe('valid-tool');
expect(result.tools!['no-name-tool']).toBeDefined();
expect(result.tools!['no-name-tool'].name).toBe('no-name-tool');
expect(result.tools).toBeUndefined();
});
});
2 changes: 1 addition & 1 deletion packages/sdk/server-ai/src/LDAIClientImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export class LDAIClientImpl implements LDAIClient {
graphKey,
);

const config = LDAIConfigUtils.fromFlagValue(key, value, trackerFactory, this._logger);
const config = LDAIConfigUtils.fromFlagValue(key, value, trackerFactory);

// Apply variable interpolation (always needed for ldctx)
return this._applyInterpolation(config, context, variables);
Expand Down
75 changes: 13 additions & 62 deletions packages/sdk/server-ai/src/api/config/LDAIConfigUtils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { LDLogger } from '@launchdarkly/js-server-sdk-common';

import { LDAIConfigTracker } from './LDAIConfigTracker';
import {
LDAIAgentConfig,
Expand Down Expand Up @@ -98,20 +96,19 @@ export class LDAIConfigUtils {
key: string,
flagValue: LDAIConfigFlagValue,
trackerFactory: () => LDAIConfigTracker,
logger?: LDLogger,
): LDAIConfigKind {
// Determine the actual mode from flag value
// eslint-disable-next-line no-underscore-dangle
const flagValueMode = flagValue._ldMeta?.mode;

switch (flagValueMode) {
case 'agent':
return this.toAgentConfig(key, flagValue, trackerFactory, logger);
return this.toAgentConfig(key, flagValue, trackerFactory);
case 'judge':
return this.toJudgeConfig(key, flagValue, trackerFactory);
case 'completion':
default:
return this.toCompletionConfig(key, flagValue, trackerFactory, logger);
return this.toCompletionConfig(key, flagValue, trackerFactory);
}
}

Expand Down Expand Up @@ -146,70 +143,26 @@ export class LDAIConfigUtils {
}
}

private static _parseToolsMap(
toolsMap: { [key: string]: unknown },
logger: LDLogger | undefined,
): { [toolName: string]: LDTool } {
const result: { [toolName: string]: LDTool } = {};
for (const [toolName, toolValue] of Object.entries(toolsMap)) {
if (toolValue === null || typeof toolValue !== 'object' || Array.isArray(toolValue)) {
logger?.warn(`LaunchDarkly AI: Skipping tool "${toolName}": expected an object`);
continue;
}
const toolObj = toolValue as { [key: string]: unknown };
result[toolName] = {
name: typeof toolObj['name'] === 'string' ? toolObj['name'] : toolName,
description:
typeof toolObj['description'] === 'string' ? toolObj['description'] : undefined,
type: typeof toolObj['type'] === 'string' ? toolObj['type'] : undefined,
parameters:
toolObj['parameters'] !== null &&
typeof toolObj['parameters'] === 'object' &&
!Array.isArray(toolObj['parameters'])
? (toolObj['parameters'] as LDTool['parameters'])
: undefined,
customParameters:
toolObj['customParameters'] !== null &&
typeof toolObj['customParameters'] === 'object' &&
!Array.isArray(toolObj['customParameters'])
? (toolObj['customParameters'] as LDTool['customParameters'])
: undefined,
};
}
return result;
}

private static _resolveTools(
flagValue: LDAIConfigFlagValue,
logger?: LDLogger,
): { [toolName: string]: LDTool } | undefined {
if (flagValue.tools !== undefined) {
if (
flagValue.tools === null ||
typeof flagValue.tools !== 'object' ||
Array.isArray(flagValue.tools)
) {
logger?.warn(
`LaunchDarkly AI: Skipping tools: expected an object, got ${Array.isArray(flagValue.tools) ? 'array' : typeof flagValue.tools}`,
);
return undefined;
}
const parsed = this._parseToolsMap(flagValue.tools as { [key: string]: unknown }, logger);
return Object.keys(parsed).length > 0 ? parsed : undefined;
return flagValue.tools as { [toolName: string]: LDTool } | undefined;
}

const rawTools = flagValue.model?.parameters?.['tools'];

if (rawTools === null || rawTools === undefined) {
if (!Array.isArray(rawTools)) {
return undefined;
}

if (typeof rawTools !== 'object' || Array.isArray(rawTools)) {
return undefined;
const result: { [toolName: string]: LDTool } = {};
for (const entry of rawTools) {
const tool = entry as LDTool;
if (tool?.name) {
result[tool.name] = tool;
}
}

const parsed = this._parseToolsMap(rawTools as { [key: string]: unknown }, logger);
return Object.keys(parsed).length > 0 ? parsed : undefined;
return Object.keys(result).length > 0 ? result : undefined;
}

/**
Expand Down Expand Up @@ -240,14 +193,13 @@ export class LDAIConfigUtils {
key: string,
flagValue: LDAIConfigFlagValue,
trackerFactory: () => LDAIConfigTracker,
logger?: LDLogger,
): LDAICompletionConfig {
return {
...this._toBaseConfig(key, flagValue),
createTracker: trackerFactory,
messages: flagValue.messages,
judgeConfiguration: flagValue.judgeConfiguration,
tools: this._resolveTools(flagValue, logger),
tools: this._resolveTools(flagValue),
};
}

Expand All @@ -263,14 +215,13 @@ export class LDAIConfigUtils {
key: string,
flagValue: LDAIConfigFlagValue,
trackerFactory: () => LDAIConfigTracker,
logger?: LDLogger,
): LDAIAgentConfig {
return {
...this._toBaseConfig(key, flagValue),
createTracker: trackerFactory,
instructions: flagValue.instructions,
judgeConfiguration: flagValue.judgeConfiguration,
tools: this._resolveTools(flagValue, logger),
tools: this._resolveTools(flagValue),
};
}

Expand Down
Loading