UNPKG

@posthog/ai

Version:
373 lines (330 loc) 11.9 kB
import { PostHog } from 'posthog-node' import PostHogOpenAI from '../src/openai' import openaiModule from 'openai' let mockOpenAiChatResponse: any = {} let mockOpenAiParsedResponse: any = {} jest.mock('posthog-node', () => { return { PostHog: jest.fn().mockImplementation(() => { return { capture: jest.fn(), captureImmediate: jest.fn(), privacy_mode: false, } }), } }) jest.mock('openai', () => { // Mock Completions class – `create` is declared on the prototype so that // subclasses can safely `super.create(...)` without it being shadowed by an // instance field (which would overwrite the subclass implementation). class MockCompletions { constructor() {} // eslint-disable-next-line @typescript-eslint/no-unused-vars create(..._args: any[]): any { /* will be stubbed in beforeEach */ return undefined } } // Mock Chat class class MockChat { constructor() {} static Completions = MockCompletions } // Mock Responses class class MockResponses { constructor() {} create = jest.fn() } // Add parse to prototype instead of instance ;(MockResponses.prototype as any).parse = jest.fn() // Mock OpenAI class class MockOpenAI { chat: any embeddings: any responses: any constructor() { this.chat = { completions: { create: jest.fn(), }, } this.embeddings = { create: jest.fn(), } this.responses = { create: jest.fn(), } } static Chat = MockChat static Responses = MockResponses } return { __esModule: true, default: MockOpenAI, OpenAI: MockOpenAI, Chat: MockChat, Responses: MockResponses, } }) describe('PostHogOpenAI - Jest test suite', () => { let mockPostHogClient: PostHog let client: PostHogOpenAI beforeAll(() => { if (!process.env.OPENAI_API_KEY) { console.warn('⚠️ Skipping OpenAI tests: No OPENAI_API_KEY environment variable set') } }) beforeEach(() => { // Skip all tests if no API key is present if (!process.env.OPENAI_API_KEY) { return } jest.clearAllMocks() // Reset the default mocks mockPostHogClient = new (PostHog as any)() client = new PostHogOpenAI({ apiKey: process.env.OPENAI_API_KEY || '', posthog: mockPostHogClient as any, }) // Some default chat completion mock mockOpenAiChatResponse = { id: 'test-response-id', model: 'gpt-4', object: 'chat.completion', created: Date.now() / 1000, choices: [ { index: 0, finish_reason: 'stop', message: { role: 'assistant', content: 'Hello from OpenAI!', }, }, ], usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30, }, } // Some default parsed response mock mockOpenAiParsedResponse = { id: 'test-parsed-response-id', model: 'gpt-4o-2024-08-06', object: 'response', created_at: Date.now(), status: 'completed', output: [ { type: 'output_text', text: '{"name": "Science Fair", "date": "Friday", "participants": ["Alice", "Bob"]}', }, ], output_parsed: { name: 'Science Fair', date: 'Friday', participants: ['Alice', 'Bob'], }, usage: { input_tokens: 15, output_tokens: 20, input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 5 }, total_tokens: 35, }, } const ChatMock: any = openaiModule.Chat ;(ChatMock.Completions as any).prototype.create = jest.fn().mockResolvedValue(mockOpenAiChatResponse) // Mock responses.parse using the same pattern as chat completions const ResponsesMock: any = openaiModule.Responses ResponsesMock.prototype.parse.mockResolvedValue(mockOpenAiParsedResponse) }) // Wrap each test with conditional skip const conditionalTest = process.env.OPENAI_API_KEY ? test : test.skip conditionalTest('basic completion', async () => { // We ensure calls to create a completion return our mock // This is handled by the inherited Chat.Completions mock in openai const response = await client.chat.completions.create({ model: 'gpt-4', messages: [{ role: 'user', content: 'Hello' }], posthogDistinctId: 'test-id', posthogProperties: { foo: 'bar' }, }) expect(response).toEqual(mockOpenAiChatResponse) // We expect 1 capture call expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) // Check the capture arguments const [captureArgs] = (mockPostHogClient.capture as jest.Mock).mock.calls const { distinctId, event, properties } = captureArgs[0] expect(distinctId).toBe('test-id') expect(event).toBe('$ai_generation') expect(properties['$ai_provider']).toBe('openai') expect(properties['$ai_model']).toBe('gpt-4') expect(properties['$ai_input']).toEqual([{ role: 'user', content: 'Hello' }]) expect(properties['$ai_output_choices']).toEqual([{ role: 'assistant', content: 'Hello from OpenAI!' }]) expect(properties['$ai_input_tokens']).toBe(20) expect(properties['$ai_output_tokens']).toBe(10) expect(properties['$ai_http_status']).toBe(200) expect(properties['foo']).toBe('bar') expect(typeof properties['$ai_latency']).toBe('number') }) conditionalTest('groups', async () => { await client.chat.completions.create({ model: 'gpt-4', messages: [{ role: 'user', content: 'Hello' }], posthogDistinctId: 'test-id', posthogGroups: { company: 'test_company' }, }) expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) const [captureArgs] = (mockPostHogClient.capture as jest.Mock).mock.calls const { groups } = captureArgs[0] expect(groups).toEqual({ company: 'test_company' }) }) conditionalTest('privacy mode local', async () => { await client.chat.completions.create({ model: 'gpt-4', messages: [{ role: 'user', content: 'Hello' }], posthogDistinctId: 'test-id', posthogPrivacyMode: true, }) expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) const [captureArgs] = (mockPostHogClient.capture as jest.Mock).mock.calls const { properties } = captureArgs[0] expect(properties['$ai_input']).toBeNull() expect(properties['$ai_output_choices']).toBeNull() }) conditionalTest('privacy mode global', async () => { // override mock to appear globally in privacy mode ;(mockPostHogClient as any).privacy_mode = true await client.chat.completions.create({ model: 'gpt-4', messages: [{ role: 'user', content: 'Hello' }], posthogDistinctId: 'test-id', // we attempt to override locally, but it should still be null if global is true posthogPrivacyMode: false, }) expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) const [captureArgs] = (mockPostHogClient.capture as jest.Mock).mock.calls const { properties } = captureArgs[0] expect(properties['$ai_input']).toBeNull() expect(properties['$ai_output_choices']).toBeNull() }) conditionalTest('core model params', async () => { mockOpenAiChatResponse.usage = { prompt_tokens: 20, completion_tokens: 10, } await client.chat.completions.create({ model: 'gpt-4', // using openai-like params temperature: 0.5, max_completion_tokens: 100, stream: false, messages: [{ role: 'user', content: 'Hello' }], posthogDistinctId: 'test-id', posthogProperties: { foo: 'bar' }, }) expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) const [captureArgs] = (mockPostHogClient.capture as jest.Mock).mock.calls const { properties } = captureArgs[0] expect(properties['$ai_model_parameters']).toEqual({ temperature: 0.5, max_completion_tokens: 100, stream: false, }) expect(properties['foo']).toBe('bar') }) conditionalTest('reasoning and cache tokens', async () => { // Set up mock response with standard token usage mockOpenAiChatResponse.usage = { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30, // Add the detailed token usage that OpenAI would return completion_tokens_details: { reasoning_tokens: 15, }, prompt_tokens_details: { cached_tokens: 5, }, } // Create a completion with additional token tracking await client.chat.completions.create({ model: 'gpt-4', messages: [{ role: 'user', content: 'Hello' }], posthogDistinctId: 'test-id', posthogProperties: { foo: 'bar' }, }) expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) const [captureArgs] = (mockPostHogClient.capture as jest.Mock).mock.calls const { properties } = captureArgs[0] // Check standard token properties expect(properties['$ai_input_tokens']).toBe(20) expect(properties['$ai_output_tokens']).toBe(10) // Check the new token properties expect(properties['$ai_reasoning_tokens']).toBe(15) expect(properties['$ai_cache_read_input_tokens']).toBe(5) }) // New test: ensure captureImmediate is used when flag is set conditionalTest('captureImmediate flag', async () => { await client.chat.completions.create({ model: 'gpt-4', messages: [{ role: 'user', content: 'Hello' }], posthogDistinctId: 'test-id', posthogCaptureImmediate: true, }) // captureImmediate should be called once, and capture should not be called expect(mockPostHogClient.captureImmediate).toHaveBeenCalledTimes(1) expect(mockPostHogClient.capture).toHaveBeenCalledTimes(0) }) conditionalTest('responses parse', async () => { const response = await client.responses.parse({ model: 'gpt-4o-2024-08-06', input: [ { role: 'system', content: 'Extract the event information.' }, { role: 'user', content: 'Alice and Bob are going to a science fair on Friday.' }, ], text: { format: { type: 'json_object', json_schema: { name: 'event', schema: { type: 'object', properties: { name: { type: 'string' }, date: { type: 'string' }, participants: { type: 'array', items: { type: 'string' } }, }, required: ['name', 'date', 'participants'], }, }, }, }, posthogDistinctId: 'test-id', posthogProperties: { foo: 'bar' }, }) expect(response).toEqual(mockOpenAiParsedResponse) expect(mockPostHogClient.capture).toHaveBeenCalledTimes(1) const [captureArgs] = (mockPostHogClient.capture as jest.Mock).mock.calls const { distinctId, event, properties } = captureArgs[0] expect(distinctId).toBe('test-id') expect(event).toBe('$ai_generation') expect(properties['$ai_provider']).toBe('openai') expect(properties['$ai_model']).toBe('gpt-4o-2024-08-06') expect(properties['$ai_input']).toEqual([ { role: 'system', content: 'Extract the event information.' }, { role: 'user', content: 'Alice and Bob are going to a science fair on Friday.' }, ]) expect(properties['$ai_output_choices']).toEqual(mockOpenAiParsedResponse.output) expect(properties['$ai_input_tokens']).toBe(15) expect(properties['$ai_output_tokens']).toBe(20) expect(properties['$ai_reasoning_tokens']).toBe(5) expect(properties['$ai_cache_read_input_tokens']).toBeUndefined() expect(properties['$ai_http_status']).toBe(200) expect(properties['foo']).toBe('bar') expect(typeof properties['$ai_latency']).toBe('number') }) })