diff --git a/packages/ai-providers/server-ai-openai/__tests__/OpenAIAgentRunner.test.ts b/packages/ai-providers/server-ai-openai/__tests__/OpenAIAgentRunner.test.ts new file mode 100644 index 0000000000..72d8138aac --- /dev/null +++ b/packages/ai-providers/server-ai-openai/__tests__/OpenAIAgentRunner.test.ts @@ -0,0 +1,134 @@ +import { OpenAI } from 'openai'; + +import type { LDAIAgentConfig } from '@launchdarkly/server-sdk-ai'; + +import { OpenAIAgentRunner } from '../src/OpenAIAgentRunner'; + +jest.mock('openai', () => ({ + OpenAI: jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: jest.fn(), + }, + }, + })), +})); + +const baseAgentConfig: LDAIAgentConfig = { + key: 'agent', + enabled: true, + model: { name: 'gpt-4o' }, + instructions: '', +}; + +describe('OpenAIAgentRunner', () => { + let mockOpenAI: jest.Mocked; + + beforeEach(() => { + mockOpenAI = new OpenAI() as jest.Mocked; + }); + + it('returns content with no toolCalls when the model does not invoke tools', async () => { + (mockOpenAI.chat.completions.create as jest.Mock).mockResolvedValue({ + choices: [{ message: { content: 'Done', tool_calls: [] } }], + usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 }, + } as any); + + const runner = new OpenAIAgentRunner(mockOpenAI, baseAgentConfig, {}); + const result = await runner.run('Say done'); + + expect(result.content).toBe('Done'); + expect(result.metrics.success).toBe(true); + expect(result.metrics.toolCalls).toBeUndefined(); + expect(result.metrics.usage).toEqual({ total: 12, input: 8, output: 4 }); + }); + + it('executes tools, populates toolCalls, and aggregates token usage across iterations', async () => { + const create = mockOpenAI.chat.completions.create as jest.Mock; + create + .mockResolvedValueOnce({ + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: 'call_1', + function: { name: 'lookup', arguments: '{"id":42}' }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 }, + } as any) + .mockResolvedValueOnce({ + choices: [{ message: { content: 'The answer is 42.', tool_calls: [] } }], + usage: { prompt_tokens: 6, completion_tokens: 8, total_tokens: 14 }, + } as any); + + const lookup = jest.fn().mockResolvedValue({ value: 42 }); + const toolDefinitions = [ + { + type: 'function', + function: { name: 'lookup', parameters: { type: 'object' } }, + }, + ]; + const config: LDAIAgentConfig = { + key: 'agent', + enabled: true, + model: { name: 'gpt-4o', parameters: { tools: toolDefinitions } }, + instructions: 'You are an expert.', + }; + const runner = new OpenAIAgentRunner(mockOpenAI, config, { lookup }); + + const result = await runner.run('Look up 42'); + + expect(lookup).toHaveBeenCalledWith({ id: 42 }); + expect(create).toHaveBeenCalledTimes(2); + expect(create.mock.calls[0][0].tools).toBe(toolDefinitions); + expect(create.mock.calls[0][0].messages[0]).toEqual({ + role: 'system', + content: 'You are an expert.', + }); + expect(result.content).toBe('The answer is 42.'); + expect(result.metrics.toolCalls).toEqual(['lookup']); + expect(result.metrics.usage).toEqual({ total: 28, input: 16, output: 12 }); + }); + + it('records the tool call and continues when a tool is missing from the registry', async () => { + const create = mockOpenAI.chat.completions.create as jest.Mock; + create + .mockResolvedValueOnce({ + choices: [ + { + message: { + content: null, + tool_calls: [{ id: 'call_x', function: { name: 'missing', arguments: '{}' } }], + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } as any) + .mockResolvedValueOnce({ + choices: [{ message: { content: 'fallback', tool_calls: [] } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } as any); + + const runner = new OpenAIAgentRunner(mockOpenAI, baseAgentConfig, {}); + const result = await runner.run('go'); + + expect(result.content).toBe('fallback'); + expect(result.metrics.toolCalls).toEqual(['missing']); + }); + + it('returns an unsuccessful RunnerResult when the API call throws', async () => { + (mockOpenAI.chat.completions.create as jest.Mock).mockRejectedValue(new Error('boom')); + + const runner = new OpenAIAgentRunner(mockOpenAI, baseAgentConfig, {}); + const result = await runner.run('Hi'); + + expect(result.content).toBe(''); + expect(result.metrics.success).toBe(false); + }); +}); diff --git a/packages/ai-providers/server-ai-openai/__tests__/OpenAIModelRunner.test.ts b/packages/ai-providers/server-ai-openai/__tests__/OpenAIModelRunner.test.ts new file mode 100644 index 0000000000..018f9a3dac --- /dev/null +++ b/packages/ai-providers/server-ai-openai/__tests__/OpenAIModelRunner.test.ts @@ -0,0 +1,151 @@ +import { OpenAI } from 'openai'; + +import type { LDAICompletionConfig } from '@launchdarkly/server-sdk-ai'; + +import { OpenAIModelRunner } from '../src/OpenAIModelRunner'; + +jest.mock('openai', () => ({ + OpenAI: jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: jest.fn(), + }, + }, + })), +})); + +describe('OpenAIModelRunner', () => { + let mockOpenAI: jest.Mocked; + let runner: OpenAIModelRunner; + + const baseConfig: LDAICompletionConfig = { + key: 'completion', + enabled: true, + model: { name: 'gpt-3.5-turbo' }, + }; + + beforeEach(() => { + mockOpenAI = new OpenAI() as jest.Mocked; + runner = new OpenAIModelRunner(mockOpenAI, baseConfig); + }); + + describe('run (chat completion)', () => { + it('returns a RunnerResult with content, metrics, and raw response', async () => { + const mockResponse = { + choices: [{ message: { content: 'Hello there!' } }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }; + (mockOpenAI.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse as any); + + const result = await runner.run('Hi'); + + expect(mockOpenAI.chat.completions.create).toHaveBeenCalledWith({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: 'Hi' }], + }); + expect(result.content).toBe('Hello there!'); + expect(result.metrics).toEqual({ + success: true, + usage: { total: 15, input: 10, output: 5 }, + }); + expect(result.raw).toBe(mockResponse); + expect(result.parsed).toBeUndefined(); + }); + + it('prepends config messages before the user prompt', async () => { + const mockResponse = { + choices: [{ message: { content: 'reply' } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }; + (mockOpenAI.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse as any); + + const configWithMessages: LDAICompletionConfig = { + ...baseConfig, + messages: [{ role: 'system', content: 'You are X' }], + }; + const r = new OpenAIModelRunner(mockOpenAI, configWithMessages); + await r.run('Hi'); + + expect(mockOpenAI.chat.completions.create).toHaveBeenCalledWith({ + model: 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: 'You are X' }, + { role: 'user', content: 'Hi' }, + ], + }); + }); + + it('marks the result unsuccessful when response has no content', async () => { + const mockResponse = { choices: [{ message: {} }] }; + (mockOpenAI.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse as any); + + const result = await runner.run('Hi'); + + expect(result.content).toBe(''); + expect(result.metrics.success).toBe(false); + }); + + it('returns an unsuccessful RunnerResult when the API call throws', async () => { + (mockOpenAI.chat.completions.create as jest.Mock).mockRejectedValue(new Error('boom')); + + const result = await runner.run('Hi'); + + expect(result.content).toBe(''); + expect(result.metrics.success).toBe(false); + expect(result.raw).toBeUndefined(); + }); + }); + + describe('run (structured output)', () => { + it('parses structured output and exposes it via parsed', async () => { + const mockResponse = { + choices: [{ message: { content: '{"name":"Ada","age":36}' } }], + usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 }, + }; + (mockOpenAI.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse as any); + + const schema = { + type: 'object', + properties: { name: { type: 'string' }, age: { type: 'number' } }, + required: ['name', 'age'], + }; + const result = await runner.run('Tell me about a person', schema); + + expect(mockOpenAI.chat.completions.create).toHaveBeenCalledWith({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: 'Tell me about a person' }], + response_format: { + type: 'json_schema', + json_schema: { + name: 'structured_output', + schema, + strict: true, + }, + }, + }); + expect(result.content).toBe('{"name":"Ada","age":36}'); + expect(result.parsed).toEqual({ name: 'Ada', age: 36 }); + expect(result.metrics.success).toBe(true); + }); + + it('marks the result unsuccessful when structured output is not valid JSON', async () => { + const mockResponse = { + choices: [{ message: { content: 'not json' } }], + usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, + }; + (mockOpenAI.chat.completions.create as jest.Mock).mockResolvedValue(mockResponse as any); + + const result = await runner.run('Hi', { type: 'object' }); + + expect(result.content).toBe('not json'); + expect(result.parsed).toBeUndefined(); + expect(result.metrics.success).toBe(false); + }); + }); + + describe('getClient', () => { + it('returns the underlying OpenAI client', () => { + expect(runner.getClient()).toBe(mockOpenAI); + }); + }); +}); diff --git a/packages/ai-providers/server-ai-openai/__tests__/OpenAIRunnerFactory.test.ts b/packages/ai-providers/server-ai-openai/__tests__/OpenAIRunnerFactory.test.ts new file mode 100644 index 0000000000..323b194f62 --- /dev/null +++ b/packages/ai-providers/server-ai-openai/__tests__/OpenAIRunnerFactory.test.ts @@ -0,0 +1,86 @@ +import { OpenAI } from 'openai'; + +import type { LDAIAgentConfig, LDAICompletionConfig } from '@launchdarkly/server-sdk-ai'; + +import { OpenAIAgentRunner } from '../src/OpenAIAgentRunner'; +import { OpenAIModelRunner } from '../src/OpenAIModelRunner'; +import { OpenAIRunnerFactory } from '../src/OpenAIRunnerFactory'; + +jest.mock('openai', () => ({ + OpenAI: jest.fn().mockImplementation(() => ({ + chat: { completions: { create: jest.fn() } }, + })), +})); + +describe('OpenAIRunnerFactory', () => { + let mockOpenAI: jest.Mocked; + let factory: OpenAIRunnerFactory; + + beforeEach(() => { + mockOpenAI = new OpenAI() as jest.Mocked; + factory = new OpenAIRunnerFactory(mockOpenAI); + }); + + describe('createModel', () => { + it('builds an OpenAIModelRunner that shares the factory client', () => { + const config: LDAICompletionConfig = { + key: 'completion', + enabled: true, + model: { name: 'gpt-4o', parameters: { temperature: 0.5 } }, + }; + + const runner = factory.createModel(config); + + expect(runner).toBeInstanceOf(OpenAIModelRunner); + expect(runner.getClient()).toBe(mockOpenAI); + }); + + it('builds a model runner from a minimal config', () => { + const runner = factory.createModel({ key: 'completion', enabled: true }); + expect(runner).toBeInstanceOf(OpenAIModelRunner); + }); + }); + + describe('createAgent', () => { + it('builds an OpenAIAgentRunner without tools when none are configured', () => { + const config: LDAIAgentConfig = { + key: 'agent', + enabled: true, + model: { name: 'gpt-4o' }, + instructions: 'be helpful', + }; + + const runner = factory.createAgent(config); + + expect(runner).toBeInstanceOf(OpenAIAgentRunner); + }); + + it('extracts tool definitions from model.parameters.tools', () => { + const tools = [{ type: 'function', function: { name: 'lookup' } }]; + const config: LDAIAgentConfig = { + key: 'agent', + enabled: true, + model: { name: 'gpt-4o', parameters: { tools, temperature: 0.7 } }, + instructions: 'be helpful', + }; + + const runner = factory.createAgent(config, { lookup: () => 'ok' }); + + expect(runner).toBeInstanceOf(OpenAIAgentRunner); + }); + }); + + describe('getClient', () => { + it('returns the underlying OpenAI client', () => { + expect(factory.getClient()).toBe(mockOpenAI); + }); + }); + + describe('create', () => { + it('creates an OpenAIRunnerFactory instance', async () => { + const f = await OpenAIRunnerFactory.create(); + expect(f).toBeInstanceOf(OpenAIRunnerFactory); + expect(f.getClient()).toBeDefined(); + }); + }); +}); diff --git a/packages/ai-providers/server-ai-openai/__tests__/openaiHelper.test.ts b/packages/ai-providers/server-ai-openai/__tests__/openaiHelper.test.ts new file mode 100644 index 0000000000..cc5143b13a --- /dev/null +++ b/packages/ai-providers/server-ai-openai/__tests__/openaiHelper.test.ts @@ -0,0 +1,48 @@ +import { + convertMessagesToOpenAI, + getAIMetricsFromResponse, + getAIUsageFromResponse, +} from '../src/openaiHelper'; + +describe('convertMessagesToOpenAI', () => { + it('converts LDMessages to OpenAI message dicts preserving role and content', () => { + const messages = convertMessagesToOpenAI([ + { role: 'system', content: 'You are X' }, + { role: 'user', content: 'Hi' }, + { role: 'assistant', content: 'Hello' }, + ]); + + expect(messages).toEqual([ + { role: 'system', content: 'You are X' }, + { role: 'user', content: 'Hi' }, + { role: 'assistant', content: 'Hello' }, + ]); + }); +}); + +describe('getAIUsageFromResponse', () => { + it('returns undefined when usage is missing', () => { + expect(getAIUsageFromResponse({})).toBeUndefined(); + }); + + it('maps OpenAI prompt/completion/total token fields to LDTokenUsage', () => { + const usage = getAIUsageFromResponse({ + usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 }, + }); + + expect(usage).toEqual({ total: 15, input: 5, output: 10 }); + }); +}); + +describe('getAIMetricsFromResponse', () => { + it('returns success=true with usage extracted from the response', () => { + const metrics = getAIMetricsFromResponse({ + usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 }, + }); + + expect(metrics).toEqual({ + success: true, + usage: { total: 3, input: 1, output: 2 }, + }); + }); +}); diff --git a/packages/ai-providers/server-ai-openai/src/OpenAIAgentRunner.ts b/packages/ai-providers/server-ai-openai/src/OpenAIAgentRunner.ts new file mode 100644 index 0000000000..b1a7488f4d --- /dev/null +++ b/packages/ai-providers/server-ai-openai/src/OpenAIAgentRunner.ts @@ -0,0 +1,179 @@ +import { OpenAI } from 'openai'; + +import type { + LDAIAgentConfig, + LDAIMetrics, + LDLogger, + LDTokenUsage, + Runner, + RunnerResult, +} from '@launchdarkly/server-sdk-ai'; + +/** + * Tool registry mapping tool names to their callable implementations. + * The callable receives the parsed JSON arguments from the model and returns + * a string (or value coercible to string) representing the tool result. + */ +export type ToolRegistry = Record unknown | Promise>; + +const MAX_ITERATIONS = 25; + +/** + * Runner implementation for a single OpenAI agent. + * + * Executes a tool-calling loop using the OpenAI Chat Completions API. Tool + * definitions come from the LD AI config; tool implementations come from the + * caller-supplied {@link ToolRegistry}. Returned by + * {@link OpenAIRunnerFactory.createAgent}. + */ +export class OpenAIAgentRunner implements Runner { + private _client: OpenAI; + private _config: LDAIAgentConfig; + private _modelName: string; + private _parameters: Record; + private _instructions: string; + private _toolDefinitions: any[]; + private _tools: ToolRegistry; + private _logger?: LDLogger; + + constructor( + client: OpenAI, + config: LDAIAgentConfig, + tools: ToolRegistry, + logger?: LDLogger, + ) { + this._client = client; + this._config = config; + this._modelName = config.model?.name ?? ''; + const parameters: Record = { ...(config.model?.parameters ?? {}) }; + this._toolDefinitions = (parameters.tools as any[] | undefined) ?? []; + delete parameters.tools; + this._parameters = parameters; + this._instructions = config.instructions ?? ''; + this._tools = tools; + this._logger = logger; + } + + /** + * Run the agent with the given prompt. + * + * @param input The user prompt to send to the agent. + * @param _outputType Reserved for future structured output support; currently + * ignored by the agent runner. + */ + async run(input: string, _outputType?: Record): Promise { + const messages: any[] = []; + if (this._instructions) { + messages.push({ role: 'system', content: this._instructions }); + } + messages.push({ role: 'user', content: input }); + + const toolCalls: string[] = []; + const totalUsage: LDTokenUsage = { total: 0, input: 0, output: 0 }; + let response: any; + + try { + for (let i = 0; i < MAX_ITERATIONS; i += 1) { + const params: any = { + ...this._parameters, + model: this._modelName, + messages, + }; + if (this._toolDefinitions.length > 0) { + params.tools = this._toolDefinitions; + } + + // eslint-disable-next-line no-await-in-loop + response = await this._client.chat.completions.create(params); + + if (response?.usage) { + totalUsage.total += response.usage.total_tokens || 0; + totalUsage.input += response.usage.prompt_tokens || 0; + totalUsage.output += response.usage.completion_tokens || 0; + } + + const choice = response?.choices?.[0]; + const message = choice?.message; + if (!message) { + break; + } + + const requestedToolCalls = message.tool_calls ?? []; + if (requestedToolCalls.length === 0) { + break; + } + + messages.push(message); + + // eslint-disable-next-line no-restricted-syntax + for (const tc of requestedToolCalls) { + const toolName = tc?.function?.name; + if (toolName) { + toolCalls.push(toolName); + } + // eslint-disable-next-line no-await-in-loop + const toolResult = await this._executeTool(toolName, tc?.function?.arguments); + messages.push({ + role: 'tool', + tool_call_id: tc.id, + content: toolResult, + }); + } + } + + const finalContent = response?.choices?.[0]?.message?.content || ''; + const metrics: LDAIMetrics = { + success: true, + usage: totalUsage, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + }; + + return { content: finalContent, metrics, raw: response }; + } catch (error) { + this._logger?.warn('OpenAI agent run failed:', error); + return { + content: '', + metrics: { success: false }, + }; + } + } + + /** + * Get the underlying OpenAI client instance. + */ + getClient(): OpenAI { + return this._client; + } + + private async _executeTool( + name: string | undefined, + argsJson: string | undefined, + ): Promise { + if (!name) { + return ''; + } + const fn = this._tools[name]; + if (!fn) { + this._logger?.warn( + `Tool '${name}' is defined in the AI config but was not found in ` + + `the tool registry; returning empty result.`, + ); + return ''; + } + let args: any = {}; + if (argsJson) { + try { + args = JSON.parse(argsJson); + } catch (error) { + this._logger?.warn(`Failed to parse tool arguments for '${name}':`, error); + } + } + try { + const result = await fn(args); + return typeof result === 'string' ? result : JSON.stringify(result); + } catch (error) { + this._logger?.warn(`Tool '${name}' execution failed:`, error); + return ''; + } + } +} diff --git a/packages/ai-providers/server-ai-openai/src/OpenAIModelRunner.ts b/packages/ai-providers/server-ai-openai/src/OpenAIModelRunner.ts new file mode 100644 index 0000000000..ae4ace5ac4 --- /dev/null +++ b/packages/ai-providers/server-ai-openai/src/OpenAIModelRunner.ts @@ -0,0 +1,131 @@ +import { OpenAI } from 'openai'; + +import type { + LDAICompletionConfig, + LDLogger, + LDMessage, + Runner, + RunnerResult, +} from '@launchdarkly/server-sdk-ai'; + +import { convertMessagesToOpenAI, getAIMetricsFromResponse } from './openaiHelper'; + +/** + * Runner implementation for OpenAI chat completions. + * + * Implements the unified `Runner` protocol via {@link run}. Returned by + * {@link OpenAIRunnerFactory.createModel}. + */ +export class OpenAIModelRunner implements Runner { + private _client: OpenAI; + private _config: LDAICompletionConfig; + private _modelName: string; + private _parameters: Record; + private _logger?: LDLogger; + + constructor(client: OpenAI, config: LDAICompletionConfig, logger?: LDLogger) { + this._client = client; + this._config = config; + this._modelName = config.model?.name ?? ''; + this._parameters = { ...(config.model?.parameters ?? {}) }; + this._logger = logger; + } + + /** + * Run the OpenAI model with the given prompt. + * + * @param input The user prompt to send to the model. + * @param outputType Optional JSON schema for structured output. When provided, + * the response is parsed and exposed via {@link RunnerResult.parsed}. + */ + async run(input: string, outputType?: Record): Promise { + const messages: LDMessage[] = [ + ...(this._config.messages ?? []), + { role: 'user', content: input }, + ]; + + if (outputType !== undefined) { + return this._runStructured(messages, outputType); + } + return this._runCompletion(messages); + } + + /** + * Get the underlying OpenAI client instance. + */ + getClient(): OpenAI { + return this._client; + } + + private async _runCompletion(messages: LDMessage[]): Promise { + try { + const response = await this._client.chat.completions.create({ + ...this._parameters, + model: this._modelName, + messages: convertMessagesToOpenAI(messages), + }); + + const metrics = getAIMetricsFromResponse(response); + const content = response?.choices?.[0]?.message?.content || ''; + + if (!content) { + this._logger?.warn('OpenAI response has no content available'); + metrics.success = false; + } + + return { content, metrics, raw: response }; + } catch (error) { + this._logger?.warn('OpenAI model invocation failed:', error); + return { + content: '', + metrics: { success: false }, + }; + } + } + + private async _runStructured( + messages: LDMessage[], + outputType: Record, + ): Promise { + let response; + try { + response = await this._client.chat.completions.create({ + ...this._parameters, + model: this._modelName, + messages: convertMessagesToOpenAI(messages), + response_format: { + type: 'json_schema', + json_schema: { + name: 'structured_output', + schema: outputType, + strict: true, + }, + }, + }); + } catch (error) { + this._logger?.warn('OpenAI structured model invocation failed:', error); + return { + content: '', + metrics: { success: false }, + }; + } + + const metrics = getAIMetricsFromResponse(response); + const content = response?.choices?.[0]?.message?.content || ''; + + if (!content) { + this._logger?.warn('OpenAI structured response has no content available'); + metrics.success = false; + return { content: '', metrics, raw: response }; + } + + try { + const parsed = JSON.parse(content) as Record; + return { content, metrics, raw: response, parsed }; + } catch (parseError) { + this._logger?.warn('OpenAI structured response contains invalid JSON:', parseError); + metrics.success = false; + return { content, metrics, raw: response }; + } + } +} diff --git a/packages/ai-providers/server-ai-openai/src/OpenAIRunnerFactory.ts b/packages/ai-providers/server-ai-openai/src/OpenAIRunnerFactory.ts new file mode 100644 index 0000000000..4c9699a54d --- /dev/null +++ b/packages/ai-providers/server-ai-openai/src/OpenAIRunnerFactory.ts @@ -0,0 +1,88 @@ +import { OpenAI } from 'openai'; + +import type { LDAIAgentConfig, LDAICompletionConfig, LDLogger } from '@launchdarkly/server-sdk-ai'; + +import { OpenAIAgentRunner, ToolRegistry } from './OpenAIAgentRunner'; +import { OpenAIModelRunner } from './OpenAIModelRunner'; + +let instrumentPromise: Promise | undefined; + +/** + * Factory for creating OpenAI runners (chat completion and agent). + * + * A single factory shares one `OpenAI` client across all runners it produces + * so connection pooling and instrumentation are preserved. + */ +export class OpenAIRunnerFactory { + private _client: OpenAI; + private _logger?: LDLogger; + + constructor(client?: OpenAI, logger?: LDLogger) { + this._client = client ?? new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + this._logger = logger; + } + + /** + * Static convenience that ensures OpenTelemetry instrumentation is applied + * (when available) before constructing a factory. + */ + static async create(logger?: LDLogger): Promise { + // eslint-disable-next-line no-underscore-dangle + await OpenAIRunnerFactory._ensureInstrumented(logger); + return new OpenAIRunnerFactory(undefined, logger); + } + + /** + * Create a model runner from a completion AI configuration. + */ + createModel(config: LDAICompletionConfig): OpenAIModelRunner { + return new OpenAIModelRunner(this._client, config, this._logger); + } + + /** + * Create an agent runner from an agent AI configuration. + * + * @param config The LaunchDarkly AI agent configuration. Tool definitions + * are sourced from `config.model.parameters.tools` (consistent with the + * completion path). + * @param tools Registry mapping tool names to their callable implementations. + * Tool names referenced by the model that are not present here will be + * logged and return an empty result. + */ + createAgent(config: LDAIAgentConfig, tools?: ToolRegistry): OpenAIAgentRunner { + return new OpenAIAgentRunner(this._client, config, tools ?? {}, this._logger); + } + + /** + * Get the underlying OpenAI client instance. + */ + getClient(): OpenAI { + return this._client; + } + + /** + * Automatically patches the ESM openai module for OpenTelemetry tracing when + * a TracerProvider is active and @traceloop/instrumentation-openai is installed. + */ + private static async _ensureInstrumented(logger?: LDLogger): Promise { + if (instrumentPromise !== undefined) { + return instrumentPromise; + } + + instrumentPromise = (async () => { + try { + const { OpenAIInstrumentation } = await import('@traceloop/instrumentation-openai'); + const instrumentation = new OpenAIInstrumentation(); + instrumentation.manuallyInstrument(OpenAI); + logger?.info('OpenAI ESM module instrumented for OpenTelemetry tracing.'); + } catch { + logger?.debug( + 'OpenTelemetry instrumentation not available for OpenAI provider. ' + + 'Install @traceloop/instrumentation-openai to enable automatic tracing.', + ); + } + })(); + + return instrumentPromise; + } +} diff --git a/packages/ai-providers/server-ai-openai/src/index.ts b/packages/ai-providers/server-ai-openai/src/index.ts index bfdeac9b4b..45a3c6649e 100644 --- a/packages/ai-providers/server-ai-openai/src/index.ts +++ b/packages/ai-providers/server-ai-openai/src/index.ts @@ -1 +1,10 @@ export { OpenAIProvider } from './OpenAIProvider'; +export { OpenAIModelRunner } from './OpenAIModelRunner'; +export { OpenAIAgentRunner, ToolRegistry } from './OpenAIAgentRunner'; +export { OpenAIRunnerFactory } from './OpenAIRunnerFactory'; +export { + convertMessagesToOpenAI, + getAIMetricsFromResponse, + getAIUsageFromResponse, +} from './openaiHelper'; +export type { OpenAIChatMessage } from './openaiHelper'; diff --git a/packages/ai-providers/server-ai-openai/src/openaiHelper.ts b/packages/ai-providers/server-ai-openai/src/openaiHelper.ts new file mode 100644 index 0000000000..35e8ad7ed1 --- /dev/null +++ b/packages/ai-providers/server-ai-openai/src/openaiHelper.ts @@ -0,0 +1,45 @@ +import type { LDAIMetrics, LDMessage, LDTokenUsage } from '@launchdarkly/server-sdk-ai'; + +/** + * OpenAI chat completion message format. + * Mirrors the relevant subset of OpenAI's `ChatCompletionMessageParam`. + */ +export interface OpenAIChatMessage { + role: 'user' | 'assistant' | 'system'; + content: string; +} + +/** + * Convert LaunchDarkly messages to OpenAI chat completion message format. + * + * @param messages Array of LDMessage objects + * @returns Array of OpenAI ChatCompletionMessageParam-compatible objects + */ +export function convertMessagesToOpenAI(messages: LDMessage[]): OpenAIChatMessage[] { + return messages.map((msg) => ({ role: msg.role, content: msg.content })); +} + +/** + * Extract token usage from an OpenAI response. + */ +export function getAIUsageFromResponse(response: any): LDTokenUsage | undefined { + if (!response?.usage) { + return undefined; + } + const { prompt_tokens, completion_tokens, total_tokens } = response.usage; + return { + total: total_tokens || 0, + input: prompt_tokens || 0, + output: completion_tokens || 0, + }; +} + +/** + * Get AI metrics from an OpenAI response. + */ +export function getAIMetricsFromResponse(response: any): LDAIMetrics { + return { + success: true, + usage: getAIUsageFromResponse(response), + }; +}