mirror of
https://github.com/hanzoai/insights-js.git
synced 2026-10-30 18:34:19 +00:00
We own the ingestion server (hanzoai/insights: Django + rust/capture + nodejs plugin) AND this SDK, so there is no external wire contract to preserve. The server is already 100% posthog-free; this SDK was the last laggard. - $last_posthog_reset -> $last_insights_reset. This FIXES A LIVE MISMATCH: the server taxonomy (insights/taxonomy/taxonomy.py, insights/insightsql/ai.py) already expected the insights form while the SDK emitted the posthog form. - __PosthogExtensions__ -> __InsightsExtensions__; all PostHog/posthog/POSTHOG identifiers -> Insights/insights/INSIGHTS (src, tests, playground, playwright, scripts, rrweb subpackages, package.json keywords). - Hosts: DEFAULT_PLUGIN_HOST + DEFAULT_NUXT_HOST + the core host default pointed at PostHog's cloud (us.i.posthog.com) — a real bug. All -> insights.hanzo.ai. - Removed the us-assets/eu-assets CDN split in getRemoteConfig (PostHog-specific; Hanzo serves remote config from the same host) — it had degenerated to a no-op. - Fixed generate-docs writing to a stray references/@hanzo/ subdir. - Renamed the 3 posthog-named test files; dropped 58MB of stale reference snapshots named for upstream posthog versions we never shipped (they are generated output — references/ is now gitignored). Verified: typecheck errors 42 (HEAD) -> 18 (all pre-existing test-mock looseness); types package 0; every package.json valid with @hanzo/* names. Guard: grep -rioE 'posthog' packages ... | wc -l => 0
944 lines
27 KiB
TypeScript
944 lines
27 KiB
TypeScript
import { Insights } from '@hanzo/insights-node'
|
|
import InsightsOpenAI from '../src/openai'
|
|
import InsightsAnthropic from '../src/anthropic'
|
|
import InsightsGemini from '../src/gemini'
|
|
import { LangChainCallbackHandler } from '../src/langchain/callbacks'
|
|
import { withTracing } from '../src/index'
|
|
import { flushPromises } from './test-utils'
|
|
import openaiModule from 'openai'
|
|
import AnthropicOriginal from '@anthropic-ai/sdk'
|
|
|
|
import type { ChatCompletion, ChatCompletionChunk } from 'openai/resources/chat/completions'
|
|
import type {
|
|
LanguageModelV2,
|
|
LanguageModelV2StreamPart,
|
|
LanguageModelV3,
|
|
LanguageModelV3StreamPart,
|
|
} from '@ai-sdk/provider'
|
|
|
|
// --- Mocks ---
|
|
|
|
jest.mock('@hanzo/insights-node', () => ({
|
|
Insights: jest.fn().mockImplementation(() => ({
|
|
capture: jest.fn(),
|
|
captureImmediate: jest.fn(),
|
|
privacy_mode: false,
|
|
})),
|
|
}))
|
|
|
|
jest.mock('openai', () => {
|
|
class MockCompletions {
|
|
create(..._args: any[]): any {
|
|
return undefined
|
|
}
|
|
}
|
|
class MockChat {
|
|
constructor() {}
|
|
static Completions = MockCompletions
|
|
}
|
|
class MockResponses {
|
|
constructor() {}
|
|
create() {
|
|
return Promise.resolve({})
|
|
}
|
|
parse() {
|
|
return Promise.resolve({})
|
|
}
|
|
}
|
|
class MockEmbeddings {
|
|
constructor() {}
|
|
create() {
|
|
return Promise.resolve({})
|
|
}
|
|
}
|
|
class MockTranscriptions {
|
|
constructor() {}
|
|
create() {
|
|
return Promise.resolve({})
|
|
}
|
|
}
|
|
class MockAudio {
|
|
constructor() {}
|
|
static Transcriptions = MockTranscriptions
|
|
}
|
|
class MockOpenAI {
|
|
chat: any
|
|
embeddings: any
|
|
responses: any
|
|
audio: any
|
|
constructor() {
|
|
this.chat = { completions: { create: jest.fn() } }
|
|
this.embeddings = { create: jest.fn() }
|
|
this.responses = { create: jest.fn() }
|
|
this.audio = { transcriptions: { create: jest.fn() } }
|
|
}
|
|
static Chat = MockChat
|
|
static Responses = MockResponses
|
|
static Embeddings = MockEmbeddings
|
|
static Audio = MockAudio
|
|
}
|
|
return {
|
|
__esModule: true,
|
|
default: MockOpenAI,
|
|
OpenAI: MockOpenAI,
|
|
AzureOpenAI: MockOpenAI,
|
|
Chat: MockChat,
|
|
Responses: MockResponses,
|
|
Embeddings: MockEmbeddings,
|
|
Audio: MockAudio,
|
|
}
|
|
})
|
|
|
|
jest.mock('@anthropic-ai/sdk', () => {
|
|
class MockMessages {
|
|
create(..._args: any[]): any {
|
|
return undefined
|
|
}
|
|
}
|
|
class MockAnthropic {
|
|
messages: any
|
|
constructor() {
|
|
this.messages = new MockMessages()
|
|
}
|
|
static Messages = MockMessages
|
|
}
|
|
return { __esModule: true, default: MockAnthropic }
|
|
})
|
|
|
|
jest.mock('@google/genai', () => {
|
|
class MockGoogleGenAI {
|
|
models: any
|
|
constructor() {
|
|
this.models = {
|
|
generateContent: jest.fn(),
|
|
generateContentStream: jest.fn(),
|
|
}
|
|
}
|
|
}
|
|
return { GoogleGenAI: MockGoogleGenAI }
|
|
})
|
|
|
|
// --- Helpers ---
|
|
|
|
interface MockAsyncIterator<T> {
|
|
[Symbol.asyncIterator](): AsyncIterator<T>
|
|
}
|
|
|
|
const createMockAsyncIterator = <T>(chunks: T[]): MockAsyncIterator<T> => {
|
|
let index = 0
|
|
return {
|
|
async *[Symbol.asyncIterator]() {
|
|
while (index < chunks.length) {
|
|
yield chunks[index++]
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
const getCapturedProperties = (client: Insights): Record<string, any> => {
|
|
const captureMock = client.capture as jest.Mock
|
|
expect(captureMock).toHaveBeenCalledTimes(1)
|
|
return captureMock.mock.calls[0][0].properties
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
describe('$ai_stop_reason extraction', () => {
|
|
let mockInsightsClient: Insights
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
mockInsightsClient = new (Insights as any)()
|
|
})
|
|
|
|
describe('OpenAI Chat Completions', () => {
|
|
let client: InsightsOpenAI
|
|
|
|
beforeEach(() => {
|
|
client = new InsightsOpenAI({
|
|
apiKey: 'test-key',
|
|
insights: mockInsightsClient as any,
|
|
})
|
|
})
|
|
|
|
test('non-streaming: extracts finish_reason', async () => {
|
|
const mockResponse: ChatCompletion = {
|
|
id: 'test-id',
|
|
model: 'gpt-4',
|
|
object: 'chat.completion',
|
|
created: Date.now() / 1000,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
finish_reason: 'stop',
|
|
message: { role: 'assistant', content: 'Hello!', refusal: null },
|
|
logprobs: null,
|
|
},
|
|
],
|
|
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
}
|
|
|
|
const ChatMock: any = openaiModule.Chat
|
|
;(ChatMock.Completions as any).prototype.create = jest.fn().mockResolvedValue(mockResponse)
|
|
|
|
await client.chat.completions.create({
|
|
model: 'gpt-4',
|
|
messages: [{ role: 'user', content: 'Hi' }],
|
|
insightsDistinctId: 'test-user',
|
|
})
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('stop')
|
|
})
|
|
|
|
test('non-streaming: extracts length finish_reason', async () => {
|
|
const mockResponse: ChatCompletion = {
|
|
id: 'test-id',
|
|
model: 'gpt-4',
|
|
object: 'chat.completion',
|
|
created: Date.now() / 1000,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
finish_reason: 'length',
|
|
message: { role: 'assistant', content: 'Truncated...', refusal: null },
|
|
logprobs: null,
|
|
},
|
|
],
|
|
usage: { prompt_tokens: 10, completion_tokens: 100, total_tokens: 110 },
|
|
}
|
|
|
|
const ChatMock: any = openaiModule.Chat
|
|
;(ChatMock.Completions as any).prototype.create = jest.fn().mockResolvedValue(mockResponse)
|
|
|
|
await client.chat.completions.create({
|
|
model: 'gpt-4',
|
|
messages: [{ role: 'user', content: 'Write a long essay' }],
|
|
insightsDistinctId: 'test-user',
|
|
})
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('length')
|
|
})
|
|
|
|
test('streaming: extracts finish_reason from final chunk', async () => {
|
|
const chunks: ChatCompletionChunk[] = [
|
|
{
|
|
id: 'test',
|
|
model: 'gpt-4',
|
|
object: 'chat.completion.chunk',
|
|
created: Date.now() / 1000,
|
|
choices: [{ index: 0, delta: { content: 'Hello!' }, finish_reason: null, logprobs: null }],
|
|
},
|
|
{
|
|
id: 'test',
|
|
model: 'gpt-4',
|
|
object: 'chat.completion.chunk',
|
|
created: Date.now() / 1000,
|
|
choices: [{ index: 0, delta: {}, finish_reason: 'stop', logprobs: null }],
|
|
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
},
|
|
]
|
|
|
|
const ChatMock: any = openaiModule.Chat
|
|
;(ChatMock.Completions as any).prototype.create = jest.fn().mockImplementation(() => {
|
|
const mockStream = {
|
|
tee: jest.fn().mockReturnValue([createMockAsyncIterator(chunks), createMockAsyncIterator(chunks)]),
|
|
}
|
|
return Promise.resolve(mockStream)
|
|
})
|
|
|
|
const stream = await client.chat.completions.create({
|
|
model: 'gpt-4',
|
|
messages: [{ role: 'user', content: 'Hi' }],
|
|
stream: true,
|
|
insightsDistinctId: 'test-user',
|
|
})
|
|
|
|
// Consume the stream
|
|
for await (const _chunk of stream as any) {
|
|
// consume
|
|
}
|
|
|
|
await flushPromises()
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('stop')
|
|
})
|
|
})
|
|
|
|
describe('OpenAI Responses API', () => {
|
|
let client: InsightsOpenAI
|
|
|
|
beforeEach(() => {
|
|
client = new InsightsOpenAI({
|
|
apiKey: 'test-key',
|
|
insights: mockInsightsClient as any,
|
|
})
|
|
})
|
|
|
|
test('non-streaming: extracts status as stop reason', async () => {
|
|
const mockResponse = {
|
|
id: 'resp-test',
|
|
model: 'gpt-4o',
|
|
object: 'response',
|
|
status: 'completed',
|
|
output: [
|
|
{
|
|
id: 'msg-001',
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{ type: 'output_text', text: 'Hello!' }],
|
|
},
|
|
],
|
|
usage: {
|
|
input_tokens: 10,
|
|
output_tokens: 5,
|
|
input_tokens_details: { cached_tokens: 0 },
|
|
output_tokens_details: { reasoning_tokens: 0 },
|
|
total_tokens: 15,
|
|
},
|
|
}
|
|
|
|
const ResponsesMock: any = openaiModule.Responses
|
|
ResponsesMock.prototype.create = jest.fn().mockResolvedValue(mockResponse)
|
|
|
|
await client.responses.create({
|
|
model: 'gpt-4o',
|
|
input: 'Hi',
|
|
insightsDistinctId: 'test-user',
|
|
})
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('completed')
|
|
})
|
|
|
|
test('streaming: extracts status from response.completed event', async () => {
|
|
const chunks = [
|
|
{
|
|
type: 'response.output_item.added',
|
|
response: { model: 'gpt-4o' },
|
|
},
|
|
{
|
|
type: 'response.output_text.delta',
|
|
delta: 'Hello!',
|
|
},
|
|
{
|
|
type: 'response.completed',
|
|
response: {
|
|
model: 'gpt-4o',
|
|
status: 'completed',
|
|
output: [
|
|
{
|
|
id: 'msg-001',
|
|
type: 'message',
|
|
role: 'assistant',
|
|
content: [{ type: 'output_text', text: 'Hello!' }],
|
|
},
|
|
],
|
|
usage: {
|
|
input_tokens: 10,
|
|
output_tokens: 5,
|
|
input_tokens_details: { cached_tokens: 0 },
|
|
output_tokens_details: { reasoning_tokens: 0 },
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
const ResponsesMock: any = openaiModule.Responses
|
|
ResponsesMock.prototype.create = jest.fn().mockImplementation(() => {
|
|
const mockStream = {
|
|
tee: jest.fn().mockReturnValue([createMockAsyncIterator(chunks), createMockAsyncIterator(chunks)]),
|
|
}
|
|
return Promise.resolve(mockStream)
|
|
})
|
|
|
|
const stream = await client.responses.create({
|
|
model: 'gpt-4o',
|
|
input: 'Hi',
|
|
stream: true,
|
|
insightsDistinctId: 'test-user',
|
|
})
|
|
|
|
for await (const _chunk of stream as any) {
|
|
// consume
|
|
}
|
|
|
|
await flushPromises()
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('completed')
|
|
})
|
|
})
|
|
|
|
describe('Anthropic', () => {
|
|
let client: InsightsAnthropic
|
|
|
|
beforeEach(() => {
|
|
client = new InsightsAnthropic({
|
|
apiKey: 'test-key',
|
|
insights: mockInsightsClient as any,
|
|
})
|
|
})
|
|
|
|
test('non-streaming: extracts stop_reason', async () => {
|
|
const mockResponse = {
|
|
id: 'msg_test',
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: 'claude-3-opus-20240229',
|
|
content: [{ type: 'text', text: 'Hello!' }],
|
|
stop_reason: 'end_turn',
|
|
stop_sequence: null,
|
|
usage: { input_tokens: 10, output_tokens: 5 },
|
|
}
|
|
|
|
const MessagesMock = AnthropicOriginal.Messages as jest.MockedClass<typeof AnthropicOriginal.Messages>
|
|
;(MessagesMock.prototype.create as jest.Mock) = jest.fn().mockResolvedValue(mockResponse)
|
|
|
|
await client.messages.create({
|
|
model: 'claude-3-opus-20240229',
|
|
messages: [{ role: 'user', content: 'Hi' }],
|
|
max_tokens: 100,
|
|
insightsDistinctId: 'test-user',
|
|
})
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('end_turn')
|
|
})
|
|
|
|
test('non-streaming: extracts max_tokens stop_reason', async () => {
|
|
const mockResponse = {
|
|
id: 'msg_test',
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: 'claude-3-opus-20240229',
|
|
content: [{ type: 'text', text: 'Truncated...' }],
|
|
stop_reason: 'max_tokens',
|
|
stop_sequence: null,
|
|
usage: { input_tokens: 10, output_tokens: 100 },
|
|
}
|
|
|
|
const MessagesMock = AnthropicOriginal.Messages as jest.MockedClass<typeof AnthropicOriginal.Messages>
|
|
;(MessagesMock.prototype.create as jest.Mock) = jest.fn().mockResolvedValue(mockResponse)
|
|
|
|
await client.messages.create({
|
|
model: 'claude-3-opus-20240229',
|
|
messages: [{ role: 'user', content: 'Write a long essay' }],
|
|
max_tokens: 100,
|
|
insightsDistinctId: 'test-user',
|
|
})
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('max_tokens')
|
|
})
|
|
|
|
test('streaming: extracts stop_reason from message_delta', async () => {
|
|
const chunks = [
|
|
{
|
|
type: 'message_start',
|
|
message: {
|
|
id: 'msg_test',
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: 'claude-3-opus-20240229',
|
|
usage: { input_tokens: 10, output_tokens: 0 },
|
|
},
|
|
},
|
|
{
|
|
type: 'content_block_start',
|
|
index: 0,
|
|
content_block: { type: 'text', text: '' },
|
|
},
|
|
{
|
|
type: 'content_block_delta',
|
|
index: 0,
|
|
delta: { type: 'text_delta', text: 'Hello!' },
|
|
},
|
|
{ type: 'content_block_stop', index: 0 },
|
|
{
|
|
type: 'message_delta',
|
|
delta: { type: 'stop_reason', stop_reason: 'end_turn' },
|
|
usage: { output_tokens: 5 },
|
|
},
|
|
{ type: 'message_stop' },
|
|
]
|
|
|
|
const MessagesMock = AnthropicOriginal.Messages as jest.MockedClass<typeof AnthropicOriginal.Messages>
|
|
;(MessagesMock.prototype.create as jest.Mock) = jest.fn().mockImplementation(() => {
|
|
const mockStream = {
|
|
tee: jest.fn().mockReturnValue([createMockAsyncIterator(chunks), createMockAsyncIterator(chunks)]),
|
|
}
|
|
return Promise.resolve(mockStream)
|
|
})
|
|
|
|
const stream = await client.messages.create({
|
|
model: 'claude-3-opus-20240229',
|
|
messages: [{ role: 'user', content: 'Hi' }],
|
|
max_tokens: 100,
|
|
stream: true,
|
|
insightsDistinctId: 'test-user',
|
|
})
|
|
|
|
for await (const _chunk of stream as any) {
|
|
// consume
|
|
}
|
|
|
|
await new Promise(process.nextTick)
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('end_turn')
|
|
})
|
|
})
|
|
|
|
describe('Gemini', () => {
|
|
let client: InsightsGemini
|
|
|
|
beforeEach(() => {
|
|
client = new InsightsGemini({
|
|
apiKey: 'test-key',
|
|
insights: mockInsightsClient as any,
|
|
})
|
|
})
|
|
|
|
test('non-streaming: extracts finishReason', async () => {
|
|
const mockResponse = {
|
|
text: 'Hello from Gemini!',
|
|
candidates: [
|
|
{
|
|
content: { parts: [{ text: 'Hello from Gemini!' }] },
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
usageMetadata: {
|
|
promptTokenCount: 10,
|
|
candidatesTokenCount: 5,
|
|
totalTokenCount: 15,
|
|
},
|
|
}
|
|
|
|
;(client as any).client.models.generateContent = jest.fn().mockResolvedValue(mockResponse)
|
|
|
|
await client.models.generateContent({
|
|
model: 'gemini-2.0-flash-001',
|
|
contents: 'Hello',
|
|
insightsDistinctId: 'test-user',
|
|
})
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('STOP')
|
|
})
|
|
|
|
test('non-streaming: extracts MAX_TOKENS finishReason', async () => {
|
|
const mockResponse = {
|
|
text: 'Truncated...',
|
|
candidates: [
|
|
{
|
|
content: { parts: [{ text: 'Truncated...' }] },
|
|
finishReason: 'MAX_TOKENS',
|
|
},
|
|
],
|
|
usageMetadata: {
|
|
promptTokenCount: 10,
|
|
candidatesTokenCount: 100,
|
|
totalTokenCount: 110,
|
|
},
|
|
}
|
|
|
|
;(client as any).client.models.generateContent = jest.fn().mockResolvedValue(mockResponse)
|
|
|
|
await client.models.generateContent({
|
|
model: 'gemini-2.0-flash-001',
|
|
contents: 'Write a long essay',
|
|
insightsDistinctId: 'test-user',
|
|
})
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('MAX_TOKENS')
|
|
})
|
|
|
|
test('streaming: extracts finishReason from final chunk', async () => {
|
|
const streamChunks = [
|
|
{
|
|
text: 'Hello ',
|
|
candidates: [{ content: { parts: [{ text: 'Hello ' }] } }],
|
|
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 2 },
|
|
},
|
|
{
|
|
text: 'world!',
|
|
candidates: [
|
|
{
|
|
content: { parts: [{ text: 'world!' }] },
|
|
finishReason: 'STOP',
|
|
},
|
|
],
|
|
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 },
|
|
},
|
|
]
|
|
|
|
;(client as any).client.models.generateContentStream = jest.fn().mockImplementation(() => {
|
|
return (async function* () {
|
|
for (const chunk of streamChunks) {
|
|
yield chunk
|
|
}
|
|
})()
|
|
})
|
|
|
|
const stream = client.models.generateContentStream({
|
|
model: 'gemini-2.0-flash-001',
|
|
contents: 'Hello',
|
|
insightsDistinctId: 'test-user',
|
|
})
|
|
|
|
for await (const _chunk of stream) {
|
|
// consume
|
|
}
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('STOP')
|
|
})
|
|
})
|
|
|
|
describe('Vercel AI SDK', () => {
|
|
test('V2 doGenerate: extracts finishReason string', async () => {
|
|
const baseModel: LanguageModelV2 = {
|
|
specificationVersion: 'v2' as const,
|
|
provider: 'openai',
|
|
modelId: 'gpt-4',
|
|
supportedUrls: {},
|
|
doGenerate: jest.fn().mockResolvedValue({
|
|
text: 'Hello!',
|
|
usage: { inputTokens: 10, outputTokens: 5 },
|
|
content: [{ type: 'text', text: 'Hello!' }],
|
|
response: { modelId: 'gpt-4' },
|
|
providerMetadata: {},
|
|
finishReason: 'stop',
|
|
warnings: [],
|
|
}),
|
|
doStream: jest.fn(),
|
|
}
|
|
|
|
const model = withTracing(baseModel, mockInsightsClient, {
|
|
insightsDistinctId: 'test-user',
|
|
})
|
|
|
|
await model.doGenerate({
|
|
prompt: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }],
|
|
} as any)
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('stop')
|
|
})
|
|
|
|
test('V3 doGenerate: extracts finishReason from object', async () => {
|
|
const baseModel: LanguageModelV3 = {
|
|
specificationVersion: 'v3' as const,
|
|
provider: 'openai',
|
|
modelId: 'gpt-4',
|
|
supportedUrls: {},
|
|
doGenerate: jest.fn().mockResolvedValue({
|
|
text: 'Hello!',
|
|
usage: {
|
|
inputTokens: { total: 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
|
|
outputTokens: { total: 5, text: undefined, reasoning: undefined },
|
|
},
|
|
content: [{ type: 'text', text: 'Hello!' }],
|
|
response: { modelId: 'gpt-4' },
|
|
providerMetadata: {},
|
|
finishReason: { unified: 'stop', raw: undefined },
|
|
warnings: [],
|
|
}),
|
|
doStream: jest.fn(),
|
|
}
|
|
|
|
const model = withTracing(baseModel, mockInsightsClient, {
|
|
insightsDistinctId: 'test-user',
|
|
})
|
|
|
|
await model.doGenerate({
|
|
prompt: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }],
|
|
} as any)
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('stop')
|
|
})
|
|
|
|
test('V2 doStream: extracts finishReason from finish part', async () => {
|
|
const streamParts: LanguageModelV2StreamPart[] = [
|
|
{ type: 'text-delta', id: 'text-1', delta: 'Hello!' },
|
|
{
|
|
type: 'finish',
|
|
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
|
finishReason: 'stop' as const,
|
|
},
|
|
]
|
|
|
|
const baseModel: LanguageModelV2 = {
|
|
specificationVersion: 'v2' as const,
|
|
provider: 'openai',
|
|
modelId: 'gpt-4',
|
|
supportedUrls: {},
|
|
doGenerate: jest.fn(),
|
|
doStream: jest.fn().mockImplementation(async () => {
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
for (const part of streamParts) {
|
|
controller.enqueue(part)
|
|
}
|
|
controller.close()
|
|
},
|
|
})
|
|
return { stream, response: { modelId: 'gpt-4' } }
|
|
}),
|
|
}
|
|
|
|
const model = withTracing(baseModel, mockInsightsClient, {
|
|
insightsDistinctId: 'test-user',
|
|
insightsTraceId: 'test-trace',
|
|
})
|
|
|
|
const result = await model.doStream({
|
|
prompt: [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'Hi' }] }],
|
|
} as any)
|
|
|
|
const reader = result.stream.getReader()
|
|
while (!(await reader.read()).done) {
|
|
// consume
|
|
}
|
|
|
|
await flushPromises()
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('stop')
|
|
})
|
|
|
|
test('V3 doStream: extracts finishReason from finish part', async () => {
|
|
const streamParts: LanguageModelV3StreamPart[] = [
|
|
{ type: 'text-delta', id: 'text-1', delta: 'Hello!' },
|
|
{
|
|
type: 'finish',
|
|
usage: {
|
|
inputTokens: { total: 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
|
|
outputTokens: { total: 5, text: undefined, reasoning: undefined },
|
|
},
|
|
finishReason: { unified: 'stop' as const, raw: undefined },
|
|
},
|
|
]
|
|
|
|
const baseModel: LanguageModelV3 = {
|
|
specificationVersion: 'v3' as const,
|
|
provider: 'openai',
|
|
modelId: 'gpt-4',
|
|
supportedUrls: {},
|
|
doGenerate: jest.fn(),
|
|
doStream: jest.fn().mockImplementation(async () => {
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
for (const part of streamParts) {
|
|
controller.enqueue(part)
|
|
}
|
|
controller.close()
|
|
},
|
|
})
|
|
return { stream, response: { modelId: 'gpt-4' } }
|
|
}),
|
|
}
|
|
|
|
const model = withTracing(baseModel, mockInsightsClient, {
|
|
insightsDistinctId: 'test-user',
|
|
insightsTraceId: 'test-trace',
|
|
})
|
|
|
|
const result = await model.doStream({
|
|
prompt: [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'Hi' }] }],
|
|
} as any)
|
|
|
|
const reader = result.stream.getReader()
|
|
while (!(await reader.read()).done) {
|
|
// consume
|
|
}
|
|
|
|
await flushPromises()
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('stop')
|
|
})
|
|
})
|
|
|
|
describe('LangChain', () => {
|
|
test('extracts finish_reason from generationInfo (OpenAI format)', () => {
|
|
const handler = new LangChainCallbackHandler({
|
|
client: mockInsightsClient,
|
|
})
|
|
|
|
const serialized = {
|
|
lc: 1,
|
|
type: 'constructor' as const,
|
|
id: ['langchain', 'llms', 'openai', 'OpenAI'],
|
|
kwargs: {},
|
|
}
|
|
|
|
handler.handleLLMStart(
|
|
serialized,
|
|
['Test prompt'],
|
|
'run-1',
|
|
'parent-1',
|
|
{
|
|
invocation_params: { temperature: 0.7 },
|
|
},
|
|
undefined,
|
|
{ ls_model_name: 'gpt-4', ls_provider: 'openai' }
|
|
)
|
|
|
|
handler.handleLLMEnd(
|
|
{
|
|
generations: [
|
|
[
|
|
{
|
|
text: 'Hello!',
|
|
|
|
generationInfo: { finish_reason: 'stop' },
|
|
},
|
|
],
|
|
],
|
|
llmOutput: {
|
|
tokenUsage: { promptTokens: 10, completionTokens: 5 },
|
|
},
|
|
},
|
|
'run-1'
|
|
)
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('stop')
|
|
})
|
|
|
|
test('extracts stop_reason from generationInfo (Anthropic format)', () => {
|
|
const handler = new LangChainCallbackHandler({
|
|
client: mockInsightsClient,
|
|
})
|
|
|
|
const serialized = {
|
|
lc: 1,
|
|
type: 'constructor' as const,
|
|
id: ['langchain', 'llms', 'anthropic'],
|
|
kwargs: {},
|
|
}
|
|
|
|
handler.handleLLMStart(
|
|
serialized,
|
|
['Test prompt'],
|
|
'run-2',
|
|
'parent-2',
|
|
{
|
|
invocation_params: {},
|
|
},
|
|
undefined,
|
|
{ ls_model_name: 'claude-3', ls_provider: 'anthropic' }
|
|
)
|
|
|
|
handler.handleLLMEnd(
|
|
{
|
|
generations: [
|
|
[
|
|
{
|
|
text: 'Hello!',
|
|
|
|
generationInfo: { stop_reason: 'end_turn' },
|
|
},
|
|
],
|
|
],
|
|
llmOutput: {
|
|
tokenUsage: { promptTokens: 10, completionTokens: 5 },
|
|
},
|
|
},
|
|
'run-2'
|
|
)
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBe('end_turn')
|
|
})
|
|
|
|
test('does not include $ai_stop_reason when not available', () => {
|
|
const handler = new LangChainCallbackHandler({
|
|
client: mockInsightsClient,
|
|
})
|
|
|
|
const serialized = {
|
|
lc: 1,
|
|
type: 'constructor' as const,
|
|
id: ['langchain', 'llms', 'openai', 'OpenAI'],
|
|
kwargs: {},
|
|
}
|
|
|
|
handler.handleLLMStart(
|
|
serialized,
|
|
['Test prompt'],
|
|
'run-3',
|
|
'parent-3',
|
|
{
|
|
invocation_params: {},
|
|
},
|
|
undefined,
|
|
{ ls_model_name: 'gpt-4', ls_provider: 'openai' }
|
|
)
|
|
|
|
handler.handleLLMEnd(
|
|
{
|
|
generations: [
|
|
[
|
|
{
|
|
text: 'Hello!',
|
|
},
|
|
],
|
|
],
|
|
llmOutput: {
|
|
tokenUsage: { promptTokens: 10, completionTokens: 5 },
|
|
},
|
|
},
|
|
'run-3'
|
|
)
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('stop reason is omitted when absent', () => {
|
|
test('OpenAI non-streaming: no stop reason when finish_reason is null', async () => {
|
|
const mockResponse: ChatCompletion = {
|
|
id: 'test-id',
|
|
model: 'gpt-4',
|
|
object: 'chat.completion',
|
|
created: Date.now() / 1000,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
finish_reason: null as any,
|
|
message: { role: 'assistant', content: 'Hello!', refusal: null },
|
|
logprobs: null,
|
|
},
|
|
],
|
|
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
}
|
|
|
|
const client = new InsightsOpenAI({
|
|
apiKey: 'test-key',
|
|
insights: mockInsightsClient as any,
|
|
})
|
|
|
|
const ChatMock: any = openaiModule.Chat
|
|
;(ChatMock.Completions as any).prototype.create = jest.fn().mockResolvedValue(mockResponse)
|
|
|
|
await client.chat.completions.create({
|
|
model: 'gpt-4',
|
|
messages: [{ role: 'user', content: 'Hi' }],
|
|
insightsDistinctId: 'test-user',
|
|
})
|
|
|
|
const properties = getCapturedProperties(mockInsightsClient)
|
|
expect(properties['$ai_stop_reason']).toBeUndefined()
|
|
})
|
|
})
|
|
})
|