diff --git a/src/core/langfuse/LangfusePromptGateway.ts b/src/core/langfuse/LangfusePromptGateway.ts new file mode 100644 index 0000000..9950348 --- /dev/null +++ b/src/core/langfuse/LangfusePromptGateway.ts @@ -0,0 +1,25 @@ +import { Langfuse } from 'langfuse'; +import type { PromptFetcher, RawPrompt } from './PromptGateway'; + +export class LangfusePromptGateway implements PromptFetcher { + private readonly client: Langfuse; + + constructor(opts: { publicKey: string; secretKey: string; baseUrl: string }) { + this.client = new Langfuse({ + publicKey: opts.publicKey, + secretKey: opts.secretKey, + baseUrl: opts.baseUrl, + }); + } + + async fetch(promptName: string): Promise { + const prompt = await this.client.getPrompt(promptName, undefined, { type: 'chat', cacheTtlSeconds: 0 }); + return { + name: prompt.name, + version: prompt.version, + config: (prompt.config ?? {}) as Record, + compile: (variables) => + prompt.compile(variables) as { role: 'system' | 'user' | 'assistant'; content: string }[], + }; + } +} diff --git a/src/core/langfuse/LangfuseTracer.ts b/src/core/langfuse/LangfuseTracer.ts new file mode 100644 index 0000000..8a41bb2 --- /dev/null +++ b/src/core/langfuse/LangfuseTracer.ts @@ -0,0 +1,40 @@ +import { Langfuse } from 'langfuse'; +import { Tracer } from '../llm/Tracer'; +import type { GenerationHandle, GenerationInput, GenerationOutput, TraceHandle } from '../llm/Tracer'; + +export class LangfuseTracer extends Tracer { + private readonly client: Langfuse; + + constructor(opts: { publicKey: string; secretKey: string; baseUrl: string }) { + super(); + this.client = new Langfuse({ + publicKey: opts.publicKey, + secretKey: opts.secretKey, + baseUrl: opts.baseUrl, + }); + } + + startTrace(name: string, metadata?: Record): TraceHandle { + const trace = this.client.trace({ name, metadata }); + return { + id: trace.id, + startGeneration(input: GenerationInput): GenerationHandle { + const generation = trace.generation({ + name: input.name, + model: input.model, + input: input.input, + metadata: input.metadata, + }); + return { + end(output: GenerationOutput) { + generation.end({ output: output.output, usage: output.usage, metadata: output.metadata }); + }, + }; + }, + }; + } + + async flush(): Promise { + await this.client.flushAsync(); + } +} diff --git a/test/core/langfuse/LangfusePromptGateway.test.ts b/test/core/langfuse/LangfusePromptGateway.test.ts new file mode 100644 index 0000000..48f2d71 --- /dev/null +++ b/test/core/langfuse/LangfusePromptGateway.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; + +const compile = vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]); +const getPrompt = vi.fn().mockResolvedValue({ name: 'greet', version: 2, config: { model: 'llama3' }, compile }); + +vi.mock('langfuse', () => ({ + Langfuse: vi.fn().mockImplementation(() => ({ getPrompt })), +})); + +import { LangfusePromptGateway } from '../../../src/core/langfuse/LangfusePromptGateway'; + +describe('LangfusePromptGateway', () => { + it('fetches a chat prompt from Langfuse bypassing the SDK cache, and adapts it to RawPrompt', async () => { + const gateway = new LangfusePromptGateway({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' }); + const raw = await gateway.fetch('greet'); + + expect(getPrompt).toHaveBeenCalledWith('greet', undefined, { type: 'chat', cacheTtlSeconds: 0 }); + expect(raw.name).toBe('greet'); + expect(raw.version).toBe(2); + expect(raw.config).toEqual({ model: 'llama3' }); + expect(raw.compile({ who: 'alice' })).toEqual([{ role: 'user', content: 'hi' }]); + }); +}); diff --git a/test/core/langfuse/LangfuseTracer.test.ts b/test/core/langfuse/LangfuseTracer.test.ts new file mode 100644 index 0000000..2da0af9 --- /dev/null +++ b/test/core/langfuse/LangfuseTracer.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; + +const generationEnd = vi.fn(); +const generation = vi.fn().mockReturnValue({ end: generationEnd }); +const trace = vi.fn().mockReturnValue({ id: 'lf-trace-1', generation }); +const flushAsync = vi.fn().mockResolvedValue(undefined); + +vi.mock('langfuse', () => ({ + Langfuse: vi.fn().mockImplementation(() => ({ trace, flushAsync })), +})); + +import { LangfuseTracer } from '../../../src/core/langfuse/LangfuseTracer'; + +describe('LangfuseTracer', () => { + it('starts a Langfuse trace and exposes its id', () => { + const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' }); + const handle = tracer.startTrace('my-pipeline', { foo: 'bar' }); + + expect(trace).toHaveBeenCalledWith({ name: 'my-pipeline', metadata: { foo: 'bar' } }); + expect(handle.id).toBe('lf-trace-1'); + }); + + it('startGeneration creates a Langfuse generation and end() forwards output/usage', () => { + const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' }); + const handle = tracer.startTrace('my-pipeline'); + const gen = handle.startGeneration({ name: 'step-1', model: 'llama3', input: ['hi'] }); + gen.end({ output: 'done', usage: { totalTokens: 5 } }); + + expect(generation).toHaveBeenCalledWith( + expect.objectContaining({ name: 'step-1', model: 'llama3', input: ['hi'] }) + ); + expect(generationEnd).toHaveBeenCalledWith( + expect.objectContaining({ output: 'done', usage: { totalTokens: 5 } }) + ); + }); + + it('flush() calls flushAsync on the underlying client', async () => { + const tracer = new LangfuseTracer({ publicKey: 'pk', secretKey: 'sk', baseUrl: 'http://lf' }); + await tracer.flush(); + expect(flushAsync).toHaveBeenCalledTimes(1); + }); +});