Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
94b313d
feat: add Evaluator class for judge orchestration (#1331)
jsonbailey May 1, 2026
dcc8130
feat: introduce ManagedResult, RunnerResult, and LDAIMetricSummary (#…
jsonbailey May 4, 2026
a218528
chore: wire evaluations tracking chain in ManagedModel.run() (#1333)
jsonbailey May 4, 2026
fcadf29
feat: add ManagedAgent with evaluations support (#1334)
jsonbailey May 5, 2026
6bbce5f
feat: add ManagedGraphResult, GraphMetricSummary, and ManagedAgentGra…
jsonbailey May 5, 2026
0ecde68
chore: add Runner and AgentGraphRunner protocol tests (#1336)
jsonbailey May 5, 2026
663c7cf
chore: rename _buildNodeMetrics to _trackNodeMetrics (AIC-2388) (#1354)
jsonbailey May 5, 2026
c09537a
feat: replace VercelProvider with Runner protocol implementation (AIC…
jsonbailey May 5, 2026
6dbae78
chore: skip tracking judge results that were not sampled (#1355)
jsonbailey May 5, 2026
a8e7bbb
chore: Add managed-agent example to server-sdk-ai (#1358)
jsonbailey May 6, 2026
0ab910c
feat: Replace OpenAIProvider with Runner protocol implementation (AIC…
jsonbailey May 6, 2026
288ac65
feat: Replace LangChainProvider with Runner protocol implementation (…
jsonbailey May 6, 2026
ef1d648
chore: fix lint errors in OpenAI and LangChain provider packages
jsonbailey May 6, 2026
bd995e7
chore: fix test type errors after createTracker became required on co…
jsonbailey May 6, 2026
70e4eb9
fix: add zod devDependency to Vercel provider (peer dep of ai v5)
jsonbailey May 7, 2026
c216575
fix!: Use LDAIGraphMetricSummary for graph metric summary (#1362)
jsonbailey May 7, 2026
12a90c9
fix!: Build judge input as string and strip legacy judge config messa…
jsonbailey May 7, 2026
3e4bc96
feat!: Remove AIProvider deprecated methods and create*/init* aliases…
jsonbailey May 7, 2026
ca9ebb2
feat!: Rename LDAIMetrics.usage and LDAIGraphMetrics.usage to .tokens…
jsonbailey May 7, 2026
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"packages/sdk/server-ai/examples/chat-judge",
"packages/sdk/server-ai/examples/direct-judge",
"packages/sdk/server-ai/examples/openai",
"packages/sdk/server-ai/examples/managed-agent",
"packages/sdk/server-ai/examples/tracked-chat",
"packages/sdk/server-ai/examples/chat-observability",
"packages/sdk/server-ai/examples/openai-observability",
Expand Down
18 changes: 9 additions & 9 deletions packages/ai-providers/server-ai-langchain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

## Quick Setup

This package provides LangChain integration for the LaunchDarkly AI SDK. The simplest way to use it is with the LaunchDarkly AI SDK's `initChat` method:
This package provides LangChain integration for the LaunchDarkly AI SDK. The simplest way to use it is with the LaunchDarkly AI SDK's `createModel` method:

1. Install the required packages:

Expand All @@ -30,7 +30,7 @@ npm install @launchdarkly/server-sdk-ai @launchdarkly/server-sdk-ai-langchain --
yarn add @launchdarkly/server-sdk-ai @launchdarkly/server-sdk-ai-langchain
```

2. Create a chat session and use it:
2. Create a managed model and run it:

```typescript
import { init } from '@launchdarkly/node-server-sdk';
Expand All @@ -40,17 +40,17 @@ import { initAi } from '@launchdarkly/server-sdk-ai';
const ldClient = init(sdkKey);
const aiClient = initAi(ldClient);

// Create a chat session
const defaultConfig = {
enabled: true,
// Create a managed model
const defaultConfig = {
enabled: true,
model: { name: 'gpt-4' },
provider: { name: 'openai' }
};
const chat = await aiClient.initChat('my-chat-config', context, defaultConfig);
const model = await aiClient.createModel('my-chat-config', context, defaultConfig);

if (chat) {
const response = await chat.invoke('What is the capital of France?');
console.log(response.message.content);
if (model) {
const result = await model.run('What is the capital of France?');
console.log(result.content);
}
```

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';

import { CompiledAgent, LangChainAgentRunner } from '../src/LangChainAgentRunner';

const mockLogger = {
warn: jest.fn(),
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};

function makeAgent(invoke: jest.Mock): CompiledAgent {
return { invoke };
}

it('returns content with no toolCalls when the agent returns a simple response', async () => {
const finalMsg = new AIMessage('done');
finalMsg.usage_metadata = { total_tokens: 6, input_tokens: 4, output_tokens: 2 };

const agent = makeAgent(jest.fn().mockResolvedValue({ messages: [finalMsg] }));
const runner = new LangChainAgentRunner(agent, mockLogger);
const result = await runner.run('hi');

expect(agent.invoke).toHaveBeenCalledWith({
messages: [{ role: 'user', content: 'hi' }],
});
expect(result.content).toBe('done');
expect(result.metrics.success).toBe(true);
expect(result.metrics.toolCalls).toBeUndefined();
expect(result.metrics.tokens).toEqual({ total: 6, input: 4, output: 2 });
});

it('extracts tool calls and aggregates usage from multi-step agent messages', async () => {
const toolCallMsg = new AIMessage('');
toolCallMsg.tool_calls = [{ id: 'call_1', name: 'lookup', args: { id: 42 } }];
toolCallMsg.usage_metadata = { total_tokens: 14, input_tokens: 10, output_tokens: 4 };

const toolResultMsg = new ToolMessage({ tool_call_id: 'call_1', content: '{"value":42}' });

const finalMsg = new AIMessage('Answer is 42.');
finalMsg.usage_metadata = { total_tokens: 14, input_tokens: 6, output_tokens: 8 };

const agent = makeAgent(
jest.fn().mockResolvedValue({
messages: [
new HumanMessage('Look up 42'),
toolCallMsg,
toolResultMsg,
finalMsg,
],
}),
);

const runner = new LangChainAgentRunner(agent, mockLogger);
const result = await runner.run('Look up 42');

expect(result.content).toBe('Answer is 42.');
expect(result.metrics.toolCalls).toEqual(['lookup']);
expect(result.metrics.tokens).toEqual({ total: 28, input: 16, output: 12 });
});

it('returns success=false when the agent throws', async () => {
const agent = makeAgent(jest.fn().mockRejectedValue(new Error('boom')));
const runner = new LangChainAgentRunner(agent, mockLogger);
const result = await runner.run('hi');

expect(result.content).toBe('');
expect(result.metrics.success).toBe(false);
expect(mockLogger.warn).toHaveBeenCalled();
});

it('returns the underlying agent via getAgent()', () => {
const agent = makeAgent(jest.fn());
const runner = new LangChainAgentRunner(agent, mockLogger);
expect(runner.getAgent()).toBe(agent);
});

it('handles empty messages array gracefully', async () => {
const agent = makeAgent(jest.fn().mockResolvedValue({ messages: [] }));
const runner = new LangChainAgentRunner(agent, mockLogger);
const result = await runner.run('hi');

expect(result.content).toBe('');
expect(result.metrics.success).toBe(true);
expect(result.metrics.toolCalls).toBeUndefined();
expect(result.metrics.tokens).toBeUndefined();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { AIMessage, HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';
import { initChatModel } from 'langchain/chat_models/universal';

import {
buildStructuredTools,
convertMessagesToLangChain,
createLangChainModel,
extractLastMessageContent,
extractToolCalls,
getAIMetricsFromResponse,
getAIUsageFromResponse,
mapProviderName,
sumTokenUsageFromMessages,
} from '../src/LangChainHelper';

jest.mock('langchain/chat_models/universal', () => ({
initChatModel: jest.fn(),
}));

const mockInitChatModel = initChatModel as jest.MockedFunction<typeof initChatModel>;

describe('createLangChainModel', () => {
const fakeLLM = { invoke: jest.fn() };

beforeEach(() => {
mockInitChatModel.mockReset();
mockInitChatModel.mockResolvedValue(fakeLLM as any);
});

it('calls initChatModel with model name and mapped provider', async () => {
await createLangChainModel({
key: 'cfg',
enabled: true,
provider: { name: 'openai' },
model: { name: 'gpt-4o', parameters: { temperature: 0.5 } },
createTracker: jest.fn(),
});

expect(mockInitChatModel).toHaveBeenCalledWith('gpt-4o', {
temperature: 0.5,
modelProvider: 'openai',
});
});

it('maps gemini to google-genai', async () => {
await createLangChainModel({
key: 'cfg',
enabled: true,
provider: { name: 'gemini' },
model: { name: 'gemini-2.0' },
createTracker: jest.fn(),
});

expect(mockInitChatModel).toHaveBeenCalledWith('gemini-2.0', {
modelProvider: 'google-genai',
});
});
});

it('converts system, user, and assistant messages to LangChain instances', () => {
const result = convertMessagesToLangChain([
{ role: 'system', content: 'sys' },
{ role: 'user', content: 'u' },
{ role: 'assistant', content: 'a' },
]);

expect(result).toHaveLength(3);
expect(result[0]).toBeInstanceOf(SystemMessage);
expect(result[1]).toBeInstanceOf(HumanMessage);
expect(result[2]).toBeInstanceOf(AIMessage);
});

it('throws on an unsupported role', () => {
expect(() => convertMessagesToLangChain([{ role: 'tool' as any, content: 'x' }])).toThrow(
'Unsupported message role: tool',
);
});

it('maps gemini to google-genai (case-insensitive)', () => {
expect(mapProviderName('gemini')).toBe('google-genai');
expect(mapProviderName('Gemini')).toBe('google-genai');
expect(mapProviderName('GEMINI')).toBe('google-genai');
});

it('returns the provider unchanged when no mapping exists', () => {
expect(mapProviderName('openai')).toBe('openai');
expect(mapProviderName('anthropic')).toBe('anthropic');
});

it('returns undefined when usage_metadata is absent', () => {
expect(getAIUsageFromResponse(new AIMessage('x'))).toBeUndefined();
});

it('maps usage_metadata to LDTokenUsage', () => {
const message = new AIMessage('x');
message.usage_metadata = { total_tokens: 30, input_tokens: 10, output_tokens: 20 };
expect(getAIUsageFromResponse(message)).toEqual({ total: 30, input: 10, output: 20 });
});

it('returns success=true with usage from the response', () => {
const message = new AIMessage('x');
message.usage_metadata = { total_tokens: 3, input_tokens: 1, output_tokens: 2 };
expect(getAIMetricsFromResponse(message)).toEqual({
success: true,
tokens: { total: 3, input: 1, output: 2 },
});
});

describe('buildStructuredTools', () => {
const mockLogger = { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() };

beforeEach(() => jest.clearAllMocks());

it('builds a StructuredTool from a valid tool definition', () => {
const toolDefs = [{ name: 'lookup', description: 'looks up a value' }];
const registry = { lookup: jest.fn().mockReturnValue('result') };

const result = buildStructuredTools(toolDefs, registry, mockLogger);

expect(result).toHaveLength(1);
expect(result[0].name).toBe('lookup');
expect(result[0].description).toBe('looks up a value');
});

it('skips tools missing from the registry and logs a warning', () => {
const toolDefs = [{ name: 'missing', description: 'not in registry' }];

const result = buildStructuredTools(toolDefs, {}, mockLogger);

expect(result).toHaveLength(0);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining("Tool 'missing'"),
);
});

it('skips non-function built-in tools and logs a warning', () => {
const toolDefs = [{ type: 'code_interpreter', name: 'ci' }];

const result = buildStructuredTools(toolDefs, { ci: jest.fn() }, mockLogger);

expect(result).toHaveLength(0);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining("Built-in tool 'code_interpreter'"),
);
});

it('handles function-style tool definitions with nested function.name', () => {
const toolDefs = [
{ type: 'function', function: { name: 'search', description: 'searches' } },
];
const registry = { search: jest.fn() };

const result = buildStructuredTools(toolDefs, registry, mockLogger);

expect(result).toHaveLength(1);
expect(result[0].name).toBe('search');
});

it('uses a default description when none is provided', () => {
const toolDefs = [{ name: 'mytool' }];
const registry = { mytool: jest.fn() };

const result = buildStructuredTools(toolDefs, registry);

expect(result[0].description).toBe('Tool mytool');
});
});

it('extracts tool call names from AIMessages with tool_calls', () => {
const msg1 = new AIMessage('');
msg1.tool_calls = [
{ id: 'c1', name: 'lookup', args: {} },
{ id: 'c2', name: 'search', args: {} },
];
const msg2 = new AIMessage('done');

expect(extractToolCalls([msg1, msg2])).toEqual(['lookup', 'search']);
});

it('returns an empty array when no tool calls are present', () => {
expect(extractToolCalls([new AIMessage('done')])).toEqual([]);
});

it('handles empty messages for extractToolCalls', () => {
expect(extractToolCalls([])).toEqual([]);
});

it('extracts string content from the last message', () => {
expect(
extractLastMessageContent([new HumanMessage('hi'), new AIMessage('hello')]),
).toBe('hello');
});

it('returns empty string for empty array', () => {
expect(extractLastMessageContent([])).toBe('');
});

it('returns empty string when last message content is not a string', () => {
const msg = new AIMessage({ content: [{ type: 'text', text: 'hi' }] });
expect(extractLastMessageContent([msg])).toBe('');
});

it('sums usage across multiple messages', () => {
const m1 = new AIMessage('');
m1.usage_metadata = { total_tokens: 10, input_tokens: 6, output_tokens: 4 };
const m2 = new AIMessage('done');
m2.usage_metadata = { total_tokens: 8, input_tokens: 3, output_tokens: 5 };
const toolMsg = new ToolMessage({ tool_call_id: 'x', content: 'res' });

expect(sumTokenUsageFromMessages([m1, toolMsg, m2])).toEqual({
total: 18,
input: 9,
output: 9,
});
});

it('returns undefined when no messages have usage', () => {
expect(sumTokenUsageFromMessages([new AIMessage('hi')])).toBeUndefined();
});
Loading
Loading