Skip to content
90 changes: 90 additions & 0 deletions packages/sdk/server-ai/__tests__/LDAIClientImpl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -915,4 +915,94 @@ describe('tools map support', () => {

expect(result.tools).toBeUndefined();
});

it('parses tools from model.parameters.tools 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 } },
_ldMeta: { variationKey: 'v1', enabled: true, mode: 'completion' },
};
mockLdClient.variation.mockResolvedValue(mockVariation);

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

expect(result.tools).toEqual(modelParamsTools);
});

it('uses root tools over model.parameters.tools 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 } },
tools: rootTools,
_ldMeta: { variationKey: 'v1', enabled: true, mode: 'completion' },
};
mockLdClient.variation.mockResolvedValue(mockVariation);

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

expect(result.tools).toEqual(rootTools);
});

it('returns undefined when model.parameters.tools is 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: [{ type: 'function', function: { name: 'search', description: 'Search' } }],
},
},
_ldMeta: { variationKey: 'v1', enabled: true, mode: 'completion' },
};
mockLdClient.variation.mockResolvedValue(mockVariation);

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

expect(result.tools).toBeUndefined();
});

it('falls back to key name for model.parameters.tools entries missing the name field', 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' },
},
},
},
_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');
});
});
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);
const config = LDAIConfigUtils.fromFlagValue(key, value, trackerFactory, this._logger);

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

import { LDAIConfigTracker } from './LDAIConfigTracker';
import {
LDAIAgentConfig,
Expand Down Expand Up @@ -96,19 +98,20 @@ 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);
return this.toAgentConfig(key, flagValue, trackerFactory, logger);
case 'judge':
return this.toJudgeConfig(key, flagValue, trackerFactory);
case 'completion':
default:
return this.toCompletionConfig(key, flagValue, trackerFactory);
return this.toCompletionConfig(key, flagValue, trackerFactory, logger);
}
}

Expand Down Expand Up @@ -143,6 +146,72 @@ 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;
}
Comment thread
cursor[bot] marked this conversation as resolved.

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

if (rawTools === null || rawTools === undefined) {
return undefined;
}

if (typeof rawTools !== 'object' || Array.isArray(rawTools)) {
return undefined;
Comment thread
cursor[bot] marked this conversation as resolved.
}

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

/**
* Creates the base configuration that all config types share.
*
Expand Down Expand Up @@ -171,13 +240,14 @@ 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: flagValue.tools,
tools: this._resolveTools(flagValue, logger),
};
}

Expand All @@ -193,13 +263,14 @@ 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: flagValue.tools,
tools: this._resolveTools(flagValue, logger),
};
}

Expand Down
Loading