Skip to content

Commit c71f2ee

Browse files
committed
feat: Add OpenAI Provider for AI SDK
1 parent ed8c332 commit c71f2ee

File tree

8 files changed

+407
-0
lines changed

8 files changed

+407
-0
lines changed

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-openai",
45
"packages/shared/common",
56
"packages/shared/sdk-client",
67
"packages/shared/sdk-server",
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { OpenAI } from 'openai';
2+
3+
import { OpenAIProvider } from '../src/OpenAIProvider';
4+
5+
// Mock OpenAI
6+
jest.mock('openai', () => {
7+
return {
8+
OpenAI: jest.fn().mockImplementation(() => ({
9+
chat: {
10+
completions: {
11+
create: jest.fn().mockResolvedValue({
12+
choices: [{ message: { content: 'Test response' } }],
13+
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
14+
}),
15+
},
16+
},
17+
})),
18+
};
19+
});
20+
21+
describe('OpenAIProvider', () => {
22+
let mockOpenAI: jest.Mocked<OpenAI>;
23+
let provider: OpenAIProvider;
24+
25+
beforeEach(() => {
26+
mockOpenAI = new OpenAI() as jest.Mocked<OpenAI>;
27+
provider = new OpenAIProvider(mockOpenAI, 'gpt-3.5-turbo', {});
28+
});
29+
30+
31+
describe('createAIMetrics', () => {
32+
it('creates metrics with success=true and token usage', () => {
33+
const mockResponse = {
34+
usage: {
35+
prompt_tokens: 50,
36+
completion_tokens: 50,
37+
total_tokens: 100,
38+
},
39+
};
40+
41+
const result = OpenAIProvider.createAIMetrics(mockResponse);
42+
43+
expect(result).toEqual({
44+
success: true,
45+
usage: {
46+
total: 100,
47+
input: 50,
48+
output: 50,
49+
},
50+
});
51+
});
52+
53+
it('creates metrics with success=true and no usage when usage is missing', () => {
54+
const mockResponse = {};
55+
56+
const result = OpenAIProvider.createAIMetrics(mockResponse);
57+
58+
expect(result).toEqual({
59+
success: true,
60+
usage: undefined,
61+
});
62+
});
63+
64+
it('handles partial usage data', () => {
65+
const mockResponse = {
66+
usage: {
67+
prompt_tokens: 30,
68+
// completion_tokens and total_tokens missing
69+
},
70+
};
71+
72+
const result = OpenAIProvider.createAIMetrics(mockResponse);
73+
74+
expect(result).toEqual({
75+
success: true,
76+
usage: {
77+
total: 0,
78+
input: 30,
79+
output: 0,
80+
},
81+
});
82+
});
83+
});
84+
85+
describe('invokeModel', () => {
86+
it('invokes OpenAI chat completions and returns response', async () => {
87+
const mockResponse = {
88+
choices: [
89+
{
90+
message: {
91+
content: 'Hello! How can I help you today?',
92+
},
93+
},
94+
],
95+
usage: {
96+
prompt_tokens: 10,
97+
completion_tokens: 15,
98+
total_tokens: 25,
99+
},
100+
};
101+
102+
(mockOpenAI.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse as any);
103+
104+
const messages = [
105+
{ role: 'user' as const, content: 'Hello!' },
106+
];
107+
108+
const result = await provider.invokeModel(messages);
109+
110+
expect(mockOpenAI.chat.completions.create).toHaveBeenCalledWith({
111+
model: 'gpt-3.5-turbo',
112+
messages: [{ role: 'user', content: 'Hello!' }],
113+
});
114+
115+
expect(result).toEqual({
116+
message: {
117+
role: 'assistant',
118+
content: 'Hello! How can I help you today?',
119+
},
120+
metrics: {
121+
success: true,
122+
usage: {
123+
total: 25,
124+
input: 10,
125+
output: 15,
126+
},
127+
},
128+
});
129+
});
130+
131+
it('throws error when no content in response', async () => {
132+
const mockResponse = {
133+
choices: [
134+
{
135+
message: {
136+
// content is missing
137+
},
138+
},
139+
],
140+
};
141+
142+
(mockOpenAI.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse as any);
143+
144+
const messages = [
145+
{ role: 'user' as const, content: 'Hello!' },
146+
];
147+
148+
await expect(provider.invokeModel(messages)).rejects.toThrow('No content in OpenAI response');
149+
});
150+
151+
it('handles empty choices array', async () => {
152+
const mockResponse = {
153+
choices: [],
154+
};
155+
156+
(mockOpenAI.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse as any);
157+
158+
const messages = [
159+
{ role: 'user' as const, content: 'Hello!' },
160+
];
161+
162+
await expect(provider.invokeModel(messages)).rejects.toThrow('No content in OpenAI response');
163+
});
164+
});
165+
166+
describe('getClient', () => {
167+
it('returns the underlying OpenAI client', () => {
168+
const client = provider.getClient();
169+
expect(client).toBe(mockOpenAI);
170+
});
171+
});
172+
173+
describe('create', () => {
174+
it('creates OpenAIProvider with correct model and parameters', async () => {
175+
const mockAiConfig = {
176+
model: {
177+
name: 'gpt-4',
178+
parameters: {
179+
temperature: 0.7,
180+
max_tokens: 1000,
181+
},
182+
},
183+
provider: { name: 'openai' },
184+
enabled: true,
185+
tracker: {} as any,
186+
toVercelAISDK: jest.fn(),
187+
};
188+
189+
const result = await OpenAIProvider.create(mockAiConfig);
190+
191+
expect(result).toBeInstanceOf(OpenAIProvider);
192+
expect(result.getClient()).toBeDefined();
193+
});
194+
});
195+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module.exports = {
2+
preset: 'ts-jest',
3+
testEnvironment: 'node',
4+
roots: ['<rootDir>'],
5+
testMatch: ['**/__tests__/**/*.test.ts'],
6+
collectCoverageFrom: [
7+
'src/**/*.ts',
8+
'!src/**/*.d.ts',
9+
'!src/**/*.test.ts',
10+
'!src/**/*.spec.ts',
11+
],
12+
coverageDirectory: 'coverage',
13+
coverageReporters: ['text', 'lcov', 'html'],
14+
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"name": "@launchdarkly/server-sdk-ai-openai",
3+
"version": "0.0.0",
4+
"description": "LaunchDarkly AI SDK OpenAI Provider for Server-Side JavaScript",
5+
"homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/ai-providers/server-ai-openai",
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+
"openai"
26+
],
27+
"author": "LaunchDarkly",
28+
"license": "Apache-2.0",
29+
"dependencies": {
30+
"@launchdarkly/server-sdk-ai": "0.11.4",
31+
"openai": "^4.0.0"
32+
},
33+
"devDependencies": {
34+
"@launchdarkly/js-server-sdk-common": "2.16.2",
35+
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
36+
"@types/jest": "^29.5.3",
37+
"@typescript-eslint/eslint-plugin": "^6.20.0",
38+
"@typescript-eslint/parser": "^6.20.0",
39+
"eslint": "^8.45.0",
40+
"eslint-config-airbnb-base": "^15.0.0",
41+
"eslint-config-airbnb-typescript": "^17.1.0",
42+
"eslint-config-prettier": "^8.8.0",
43+
"eslint-plugin-import": "^2.27.5",
44+
"eslint-plugin-jest": "^27.6.3",
45+
"eslint-plugin-prettier": "^5.0.0",
46+
"jest": "^29.6.1",
47+
"prettier": "^3.0.0",
48+
"ts-jest": "^29.1.1",
49+
"typescript": "5.1.6"
50+
},
51+
"peerDependencies": {
52+
"@launchdarkly/js-server-sdk-common": "2.x"
53+
}
54+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { OpenAI } from 'openai';
2+
3+
import { LDLogger } from '@launchdarkly/js-server-sdk-common';
4+
import {
5+
AIProvider,
6+
ChatResponse,
7+
LDAIConfig,
8+
LDAIMetrics,
9+
LDMessage,
10+
LDTokenUsage,
11+
} from '@launchdarkly/server-sdk-ai';
12+
13+
/**
14+
* OpenAI implementation of AIProvider.
15+
* This provider integrates OpenAI's chat completions API with LaunchDarkly's tracking capabilities.
16+
*/
17+
export class OpenAIProvider extends AIProvider {
18+
private _client: OpenAI;
19+
private _modelName: string;
20+
private _parameters: Record<string, unknown>;
21+
22+
constructor(client: OpenAI, modelName: string, parameters: Record<string, unknown>, logger?: LDLogger) {
23+
super(logger);
24+
this._client = client;
25+
this._modelName = modelName;
26+
this._parameters = parameters;
27+
}
28+
29+
// =============================================================================
30+
// MAIN FACTORY METHOD
31+
// =============================================================================
32+
33+
/**
34+
* Static factory method to create an OpenAI AIProvider from an AI configuration.
35+
*/
36+
static async create(aiConfig: LDAIConfig, logger?: LDLogger): Promise<OpenAIProvider> {
37+
const client = new OpenAI({
38+
apiKey: process.env.OPENAI_API_KEY,
39+
});
40+
const modelName = aiConfig.model?.name || '';
41+
const parameters = aiConfig.model?.parameters || {};
42+
return new OpenAIProvider(client, modelName, parameters, logger);
43+
}
44+
45+
// =============================================================================
46+
// INSTANCE METHODS (AIProvider Implementation)
47+
// =============================================================================
48+
49+
/**
50+
* Invoke the OpenAI model with an array of messages.
51+
*/
52+
async invokeModel(messages: LDMessage[]): Promise<ChatResponse> {
53+
// Call OpenAI chat completions API
54+
const response = await this._client.chat.completions.create({
55+
model: this._modelName,
56+
messages: messages,
57+
...this._parameters,
58+
});
59+
60+
// Extract the first choice content
61+
const choice = response.choices[0];
62+
if (!choice?.message?.content) {
63+
throw new Error('No content in OpenAI response');
64+
}
65+
66+
// Create the assistant message
67+
const assistantMessage: LDMessage = {
68+
role: 'assistant',
69+
content: choice.message.content,
70+
};
71+
72+
// Extract metrics including token usage and success status
73+
const metrics = OpenAIProvider.createAIMetrics(response);
74+
75+
return {
76+
message: assistantMessage,
77+
metrics,
78+
};
79+
}
80+
81+
/**
82+
* Get the underlying OpenAI client instance.
83+
*/
84+
getClient(): OpenAI {
85+
return this._client;
86+
}
87+
88+
89+
// =============================================================================
90+
// STATIC UTILITY METHODS
91+
// =============================================================================
92+
93+
/**
94+
* Create AI metrics information from an OpenAI response.
95+
* This method extracts token usage information and success status from OpenAI responses
96+
* and returns a LaunchDarkly AIMetrics object.
97+
*/
98+
static createAIMetrics(openaiResponse: any): LDAIMetrics {
99+
// Extract token usage if available
100+
let usage: LDTokenUsage | undefined;
101+
if (openaiResponse?.usage) {
102+
const { prompt_tokens, completion_tokens, total_tokens } = openaiResponse.usage;
103+
usage = {
104+
total: total_tokens || 0,
105+
input: prompt_tokens || 0,
106+
output: completion_tokens || 0,
107+
};
108+
}
109+
110+
// OpenAI responses that complete successfully are considered successful
111+
return {
112+
success: true,
113+
usage,
114+
};
115+
}
116+
117+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { OpenAIProvider } from './OpenAIProvider';
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"include": ["/**/*.ts"],
4+
"exclude": ["node_modules"]
5+
}

0 commit comments

Comments
 (0)