Skip to content

Commit 9cbfb87

Browse files
authored
feat: Add LangChain Provider for AI SDK (#941)
1 parent ea50d19 commit 9cbfb87

File tree

11 files changed

+499
-13
lines changed

11 files changed

+499
-13
lines changed

.github/workflows/release-please.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ jobs:
2727
package-react-universal-release: ${{ steps.release.outputs['packages/sdk/react-universal--release_created'] }}
2828
package-browser-released: ${{ steps.release.outputs['packages/sdk/browser--release_created'] }}
2929
package-server-ai-released: ${{ steps.release.outputs['packages/sdk/server-ai--release_created'] }}
30+
package-server-ai-langchain-released: ${{ steps.release.outputs['packages/ai-providers/server-ai-langchain--release_created'] }}
3031
package-browser-telemetry-released: ${{ steps.release.outputs['packages/telemetry/browser-telemetry--release_created'] }}
3132
package-combined-browser-released: ${{ steps.release.outputs['packages/sdk/combined-browser--release_created'] }}
3233
steps:
@@ -460,3 +461,23 @@ jobs:
460461
with:
461462
workspace_path: packages/sdk/combined-browser
462463
aws_assume_role: ${{ vars.AWS_ROLE_ARN }}
464+
465+
release-server-ai-langchain:
466+
runs-on: ubuntu-latest
467+
needs: ['release-please', 'release-server-ai']
468+
permissions:
469+
id-token: write
470+
contents: write
471+
if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-server-ai-langchain-released == 'true'}}
472+
steps:
473+
- uses: actions/checkout@v4
474+
- uses: actions/setup-node@v4
475+
with:
476+
node-version: 22.x
477+
registry-url: 'https://registry.npmjs.org'
478+
- id: release-server-ai-langchain
479+
name: Full release of packages/ai-providers/server-ai-langchain
480+
uses: ./actions/full-release
481+
with:
482+
workspace_path: packages/ai-providers/server-ai-langchain
483+
aws_assume_role: ${{ vars.AWS_ROLE_ARN }}

.release-please-manifest.json

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
{
2-
"packages/shared/common": "2.19.0",
3-
"packages/shared/sdk-server": "2.16.2",
4-
"packages/sdk/server-node": "9.10.2",
2+
"packages/ai-providers/server-ai-langchain": "0.0.0",
3+
"packages/sdk/akamai-base": "3.0.10",
4+
"packages/sdk/akamai-edgekv": "1.4.12",
5+
"packages/sdk/browser": "0.8.1",
56
"packages/sdk/cloudflare": "2.7.10",
7+
"packages/sdk/combined-browser": "0.0.0",
68
"packages/sdk/fastly": "0.2.1",
7-
"packages/shared/sdk-server-edge": "2.6.9",
9+
"packages/sdk/react-native": "10.11.0",
10+
"packages/sdk/server-ai": "0.12.0",
11+
"packages/sdk/server-node": "9.10.2",
812
"packages/sdk/vercel": "1.3.34",
9-
"packages/sdk/akamai-base": "3.0.10",
10-
"packages/sdk/akamai-edgekv": "1.4.12",
1113
"packages/shared/akamai-edgeworker-sdk": "2.0.10",
14+
"packages/shared/common": "2.19.0",
15+
"packages/shared/sdk-client": "1.15.1",
16+
"packages/shared/sdk-server": "2.16.2",
17+
"packages/shared/sdk-server-edge": "2.6.9",
1218
"packages/store/node-server-sdk-dynamodb": "6.2.14",
1319
"packages/store/node-server-sdk-redis": "4.2.14",
14-
"packages/shared/sdk-client": "1.15.1",
15-
"packages/sdk/react-native": "10.11.0",
16-
"packages/telemetry/node-server-sdk-otel": "1.3.2",
17-
"packages/sdk/browser": "0.8.1",
18-
"packages/sdk/server-ai": "0.12.0",
1920
"packages/telemetry/browser-telemetry": "1.0.11",
20-
"packages/tooling/jest": "0.1.11",
21-
"packages/sdk/combined-browser": "0.0.0"
21+
"packages/telemetry/node-server-sdk-otel": "1.3.2",
22+
"packages/tooling/jest": "0.1.11"
2223
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"name": "@launchdarkly/js-core",
33
"workspaces": [
4+
"packages/ai-providers/server-ai-langchain",
45
"packages/shared/common",
56
"packages/shared/sdk-client",
67
"packages/shared/sdk-server",
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { AIMessage, HumanMessage, SystemMessage } from '@langchain/core/messages';
2+
3+
import { LangChainProvider } from '../src/LangChainProvider';
4+
5+
// Mock LangChain dependencies
6+
jest.mock('langchain/chat_models/universal', () => ({
7+
initChatModel: jest.fn(),
8+
}));
9+
10+
// Mock logger
11+
const mockLogger = {
12+
warn: jest.fn(),
13+
info: jest.fn(),
14+
error: jest.fn(),
15+
debug: jest.fn(),
16+
};
17+
18+
describe('LangChainProvider', () => {
19+
describe('convertMessagesToLangChain', () => {
20+
it('converts system messages to SystemMessage', () => {
21+
const messages = [{ role: 'system' as const, content: 'You are a helpful assistant.' }];
22+
const result = LangChainProvider.convertMessagesToLangChain(messages);
23+
24+
expect(result).toHaveLength(1);
25+
expect(result[0]).toBeInstanceOf(SystemMessage);
26+
expect(result[0].content).toBe('You are a helpful assistant.');
27+
});
28+
29+
it('converts user messages to HumanMessage', () => {
30+
const messages = [{ role: 'user' as const, content: 'Hello, how are you?' }];
31+
const result = LangChainProvider.convertMessagesToLangChain(messages);
32+
33+
expect(result).toHaveLength(1);
34+
expect(result[0]).toBeInstanceOf(HumanMessage);
35+
expect(result[0].content).toBe('Hello, how are you?');
36+
});
37+
38+
it('converts assistant messages to AIMessage', () => {
39+
const messages = [{ role: 'assistant' as const, content: 'I am doing well, thank you!' }];
40+
const result = LangChainProvider.convertMessagesToLangChain(messages);
41+
42+
expect(result).toHaveLength(1);
43+
expect(result[0]).toBeInstanceOf(AIMessage);
44+
expect(result[0].content).toBe('I am doing well, thank you!');
45+
});
46+
47+
it('converts multiple messages in order', () => {
48+
const messages = [
49+
{ role: 'system' as const, content: 'You are a helpful assistant.' },
50+
{ role: 'user' as const, content: 'What is the weather like?' },
51+
{ role: 'assistant' as const, content: 'I cannot check the weather.' },
52+
];
53+
const result = LangChainProvider.convertMessagesToLangChain(messages);
54+
55+
expect(result).toHaveLength(3);
56+
expect(result[0]).toBeInstanceOf(SystemMessage);
57+
expect(result[1]).toBeInstanceOf(HumanMessage);
58+
expect(result[2]).toBeInstanceOf(AIMessage);
59+
});
60+
61+
it('throws error for unsupported message role', () => {
62+
const messages = [{ role: 'unknown' as any, content: 'Test message' }];
63+
64+
expect(() => LangChainProvider.convertMessagesToLangChain(messages)).toThrow(
65+
'Unsupported message role: unknown',
66+
);
67+
});
68+
69+
it('handles empty message array', () => {
70+
const result = LangChainProvider.convertMessagesToLangChain([]);
71+
72+
expect(result).toHaveLength(0);
73+
});
74+
});
75+
76+
describe('createAIMetrics', () => {
77+
it('creates metrics with success=true and token usage', () => {
78+
const mockResponse = new AIMessage('Test response');
79+
mockResponse.response_metadata = {
80+
tokenUsage: {
81+
totalTokens: 100,
82+
promptTokens: 50,
83+
completionTokens: 50,
84+
},
85+
};
86+
87+
const result = LangChainProvider.createAIMetrics(mockResponse);
88+
89+
expect(result).toEqual({
90+
success: true,
91+
usage: {
92+
total: 100,
93+
input: 50,
94+
output: 50,
95+
},
96+
});
97+
});
98+
99+
it('creates metrics with success=true and no usage when metadata is missing', () => {
100+
const mockResponse = new AIMessage('Test response');
101+
102+
const result = LangChainProvider.createAIMetrics(mockResponse);
103+
104+
expect(result).toEqual({
105+
success: true,
106+
usage: undefined,
107+
});
108+
});
109+
});
110+
111+
describe('invokeModel', () => {
112+
let mockLLM: any;
113+
let provider: LangChainProvider;
114+
115+
beforeEach(() => {
116+
mockLLM = {
117+
invoke: jest.fn(),
118+
};
119+
provider = new LangChainProvider(mockLLM, mockLogger);
120+
jest.clearAllMocks();
121+
});
122+
123+
it('returns success=true for string content', async () => {
124+
const mockResponse = new AIMessage('Test response');
125+
mockLLM.invoke.mockResolvedValue(mockResponse);
126+
127+
const messages = [{ role: 'user' as const, content: 'Hello' }];
128+
const result = await provider.invokeModel(messages);
129+
130+
expect(result.metrics.success).toBe(true);
131+
expect(result.message.content).toBe('Test response');
132+
expect(mockLogger.warn).not.toHaveBeenCalled();
133+
});
134+
135+
it('returns success=false for non-string content and logs warning', async () => {
136+
const mockResponse = new AIMessage({ type: 'image', data: 'base64data' } as any);
137+
mockLLM.invoke.mockResolvedValue(mockResponse);
138+
139+
const messages = [{ role: 'user' as const, content: 'Hello' }];
140+
const result = await provider.invokeModel(messages);
141+
142+
expect(result.metrics.success).toBe(false);
143+
expect(result.message.content).toBe('');
144+
expect(mockLogger.warn).toHaveBeenCalledWith(
145+
'Multimodal response not supported, expecting a string. Content type: object, Content:',
146+
JSON.stringify({ type: 'image', data: 'base64data' }, null, 2),
147+
);
148+
});
149+
150+
it('returns success=false for array content and logs warning', async () => {
151+
const mockResponse = new AIMessage(['text', { type: 'image', data: 'base64data' }] as any);
152+
mockLLM.invoke.mockResolvedValue(mockResponse);
153+
154+
const messages = [{ role: 'user' as const, content: 'Hello' }];
155+
const result = await provider.invokeModel(messages);
156+
157+
expect(result.metrics.success).toBe(false);
158+
expect(result.message.content).toBe('');
159+
expect(mockLogger.warn).toHaveBeenCalledWith(
160+
'Multimodal response not supported, expecting a string. Content type: object, Content:',
161+
JSON.stringify(['text', { type: 'image', data: 'base64data' }], null, 2),
162+
);
163+
});
164+
});
165+
166+
describe('mapProvider', () => {
167+
it('maps gemini to google-genai', () => {
168+
expect(LangChainProvider.mapProvider('gemini')).toBe('google-genai');
169+
expect(LangChainProvider.mapProvider('Gemini')).toBe('google-genai');
170+
expect(LangChainProvider.mapProvider('GEMINI')).toBe('google-genai');
171+
});
172+
173+
it('returns provider name unchanged for unmapped providers', () => {
174+
expect(LangChainProvider.mapProvider('openai')).toBe('openai');
175+
expect(LangChainProvider.mapProvider('anthropic')).toBe('anthropic');
176+
expect(LangChainProvider.mapProvider('unknown')).toBe('unknown');
177+
});
178+
});
179+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module.exports = {
2+
transform: { '^.+\\.ts?$': 'ts-jest' },
3+
testMatch: ['**/__tests__/**/*test.ts?(x)'],
4+
testEnvironment: 'node',
5+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
6+
collectCoverageFrom: ['src/**/*.ts'],
7+
};
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"name": "@launchdarkly/server-sdk-ai-langchain",
3+
"version": "0.0.0",
4+
"description": "LaunchDarkly AI SDK LangChain Provider for Server-Side JavaScript",
5+
"homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/ai-providers/server-ai-langchain",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/launchdarkly/js-core.git"
9+
},
10+
"main": "dist/index.js",
11+
"types": "dist/index.d.ts",
12+
"type": "commonjs",
13+
"scripts": {
14+
"build": "npx tsc",
15+
"lint": "npx eslint . --ext .ts",
16+
"prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore",
17+
"lint:fix": "yarn run lint --fix",
18+
"check": "yarn prettier && yarn lint && yarn build && yarn test",
19+
"test": "jest"
20+
},
21+
"keywords": [
22+
"launchdarkly",
23+
"ai",
24+
"llm",
25+
"langchain"
26+
],
27+
"author": "LaunchDarkly",
28+
"license": "Apache-2.0",
29+
"dependencies": {
30+
"@langchain/core": ">=0.2.21 <0.3.0",
31+
"@launchdarkly/server-sdk-ai": "^0.12.0",
32+
"langchain": "^0.2.11"
33+
},
34+
"devDependencies": {
35+
"@launchdarkly/js-server-sdk-common": "2.16.2",
36+
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
37+
"@types/jest": "^29.5.3",
38+
"@typescript-eslint/eslint-plugin": "^6.20.0",
39+
"@typescript-eslint/parser": "^6.20.0",
40+
"eslint": "^8.45.0",
41+
"eslint-config-airbnb-base": "^15.0.0",
42+
"eslint-config-airbnb-typescript": "^17.1.0",
43+
"eslint-config-prettier": "^8.8.0",
44+
"eslint-plugin-import": "^2.27.5",
45+
"eslint-plugin-jest": "^27.6.3",
46+
"eslint-plugin-prettier": "^5.0.0",
47+
"jest": "^29.6.1",
48+
"prettier": "^3.0.0",
49+
"ts-jest": "^29.1.1",
50+
"typescript": "5.1.6"
51+
},
52+
"peerDependencies": {
53+
"@launchdarkly/js-server-sdk-common": "2.x"
54+
}
55+
}

0 commit comments

Comments
 (0)