langfuse-prompt-library-iauro 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,663 @@
1
+ """
2
+ Core Langfuse manager for handling prompts, tracing, and LLM calls.
3
+ """
4
+
5
+ import time
6
+ import atexit
7
+ from typing import Optional, Dict, Any, Literal
8
+ from langfuse import Langfuse, get_client
9
+ from langfuse.openai import OpenAI
10
+
11
+ from .config import LangfuseConfig
12
+ from .models import LLMResponse
13
+ from utils.logger import get_logger
14
+ from .exceptions import (
15
+ ProviderError,
16
+ PromptNotFoundError,
17
+ APITimeoutError,
18
+ TracingError,
19
+ ConfigurationError
20
+ )
21
+ from utils.utility import (
22
+ ThreadSafeCache,
23
+ retry_with_backoff,
24
+ validate_prompt_name,
25
+ validate_model_name,
26
+ validate_temperature,
27
+ validate_max_tokens,
28
+ sanitize_user_input
29
+ )
30
+
31
+ # Try to import Anthropic (optional dependency)
32
+ try:
33
+ from anthropic import Anthropic
34
+ ANTHROPIC_AVAILABLE = True
35
+ except ImportError:
36
+ ANTHROPIC_AVAILABLE = False
37
+ Anthropic = None
38
+
39
+
40
+ class LangfuseManager:
41
+ """Main entry point for Langfuse library.
42
+
43
+ Provides a high-level API for fetching prompts, calling LLMs,
44
+ and automatic tracing/observability.
45
+
46
+ Example:
47
+ >>> lf = LangfuseManager()
48
+ >>> response = lf.call_llm(
49
+ ... prompt_name="customer_support_agent",
50
+ ... user_input="How do I reset my password?",
51
+ ... prompt_label="production"
52
+ ... )
53
+ >>> print(response.content)
54
+ """
55
+
56
+ def __init__(self, config: Optional[LangfuseConfig] = None):
57
+ """Initialize LangfuseManager.
58
+
59
+ Args:
60
+ config: Optional LangfuseConfig. If None, loads from environment.
61
+
62
+ Raises:
63
+ ConfigurationError: If required configuration is missing
64
+ """
65
+ # Load and validate configuration
66
+ self._config = config or LangfuseConfig.from_env()
67
+ self._config.validate()
68
+
69
+ # Initialize logger
70
+ self._logger = get_logger("langfuse_prompt_library", self._config.log_level)
71
+
72
+ self._logger.info("Initializing LangfuseManager", debug_mode=self._config.debug)
73
+
74
+ # Initialize Langfuse client
75
+ try:
76
+ self._langfuse = Langfuse(
77
+ secret_key=self._config.secret_key,
78
+ public_key=self._config.public_key,
79
+ host=self._config.host
80
+ )
81
+ self._logger.debug("Langfuse client initialized", host=self._config.host)
82
+ except Exception as e:
83
+ self._logger.error("Failed to initialize Langfuse client", exc_info=True)
84
+ raise ConfigurationError(f"Failed to initialize Langfuse client: {str(e)}")
85
+
86
+ # Initialize OpenAI client with Langfuse integration (if API key provided)
87
+ self._openai = None
88
+ if self._config.openai_api_key:
89
+ try:
90
+ self._openai = OpenAI(api_key=self._config.openai_api_key)
91
+ self._logger.debug("OpenAI client initialized")
92
+ except Exception as e:
93
+ self._logger.warning(f"Failed to initialize OpenAI client: {str(e)}")
94
+
95
+ # Initialize Anthropic client (if API key provided and library available)
96
+ self._anthropic = None
97
+ if self._config.anthropic_api_key and ANTHROPIC_AVAILABLE:
98
+ try:
99
+ self._anthropic = Anthropic(api_key=self._config.anthropic_api_key)
100
+ self._logger.debug("Anthropic client initialized")
101
+ except Exception as e:
102
+ self._logger.warning(f"Failed to initialize Anthropic client: {str(e)}")
103
+
104
+ # Initialize thread-safe prompt cache
105
+ self._prompt_cache = ThreadSafeCache(
106
+ default_ttl=self._config.cache_ttl
107
+ ) if self._config.enable_caching else None
108
+
109
+ # Register cleanup handler
110
+ if self._config.flush_at_shutdown:
111
+ atexit.register(self._cleanup)
112
+ self._logger.debug("Registered cleanup handler for shutdown")
113
+
114
+ def get_prompt(
115
+ self,
116
+ name: str,
117
+ version: Optional[int] = None,
118
+ label: Optional[str] = None,
119
+ cache: bool = True
120
+ ):
121
+ """Fetch a prompt from Langfuse with optional caching.
122
+
123
+ Args:
124
+ name: Prompt name
125
+ version: Specific version number (optional)
126
+ label: Prompt label like "production" (optional)
127
+ cache: Whether to use cache (default: True)
128
+
129
+ Returns:
130
+ Langfuse prompt object
131
+
132
+ Raises:
133
+ PromptNotFoundError: If prompt is not found
134
+ ValidationError: If parameters are invalid
135
+
136
+ Example:
137
+ >>> prompt = lf.get_prompt("my_prompt", label="production")
138
+ >>> messages = prompt.compile(user_input="Hello")
139
+ """
140
+ # Validate inputs
141
+ validate_prompt_name(name)
142
+
143
+ # Build cache key
144
+ cache_key = f"{name}:{version or label or 'latest'}"
145
+
146
+ # Check cache if enabled
147
+ if cache and self._prompt_cache is not None:
148
+ cached_prompt = self._prompt_cache.get(cache_key)
149
+ if cached_prompt is not None:
150
+ self._logger.debug("Using cached prompt", prompt_name=name, cache_key=cache_key)
151
+ return cached_prompt
152
+
153
+ # Fetch from Langfuse with retry logic
154
+ try:
155
+ self._logger.debug(
156
+ "Fetching prompt from Langfuse",
157
+ prompt_name=name,
158
+ version=version,
159
+ label=label
160
+ )
161
+
162
+ if version is not None:
163
+ prompt = self._langfuse.get_prompt(name, version=version)
164
+ elif label:
165
+ prompt = self._langfuse.get_prompt(name, label=label)
166
+ else:
167
+ prompt = self._langfuse.get_prompt(name)
168
+
169
+ self._logger.info(
170
+ "Fetched prompt from Langfuse",
171
+ prompt_name=name,
172
+ version=prompt.version
173
+ )
174
+
175
+ # Update cache if enabled
176
+ if cache and self._prompt_cache is not None:
177
+ self._prompt_cache.set(cache_key, prompt)
178
+
179
+ return prompt
180
+
181
+ except Exception as e:
182
+ self._logger.error(
183
+ "Failed to fetch prompt",
184
+ exc_info=True,
185
+ prompt_name=name,
186
+ version=version,
187
+ label=label
188
+ )
189
+ raise PromptNotFoundError(name, version, label)
190
+
191
+ def compile_prompt(self, prompt, **variables) -> list:
192
+ """Compile a prompt with variables.
193
+
194
+ Args:
195
+ prompt: Langfuse prompt object
196
+ **variables: Variables to substitute in the prompt
197
+
198
+ Returns:
199
+ List of compiled messages ready for LLM call
200
+
201
+ Example:
202
+ >>> prompt = lf.get_prompt("assistant")
203
+ >>> messages = lf.compile_prompt(prompt, user_input="Hello")
204
+ """
205
+ return prompt.compile(**variables)
206
+
207
+ def _detect_provider(self, model: str) -> Literal["openai", "anthropic"]:
208
+ """Detect provider from model name.
209
+
210
+ Args:
211
+ model: Model name
212
+
213
+ Returns:
214
+ Provider name ("openai" or "anthropic")
215
+ """
216
+ model_lower = model.lower()
217
+
218
+ # Anthropic models
219
+ if any(name in model_lower for name in ["claude", "anthropic"]):
220
+ return "anthropic"
221
+
222
+ # Default to OpenAI (gpt-*, etc.)
223
+ return "openai"
224
+
225
+ @retry_with_backoff(max_retries=3, initial_delay=1.0)
226
+ def call_llm(
227
+ self,
228
+ prompt_name: str,
229
+ user_input: str,
230
+ prompt_version: Optional[int] = None,
231
+ prompt_label: Optional[str] = None,
232
+ model: str = "gpt-3.5-turbo",
233
+ provider: Optional[Literal["openai", "anthropic"]] = None,
234
+ temperature: float = 0.7,
235
+ max_tokens: int = 500,
236
+ **kwargs
237
+ ) -> LLMResponse:
238
+ """High-level method to fetch prompt, call LLM, and handle tracing.
239
+
240
+ This method automatically:
241
+ - Validates all inputs
242
+ - Fetches the prompt from Langfuse
243
+ - Compiles it with user_input
244
+ - Creates trace and generation observations
245
+ - Calls LLM API (OpenAI or Anthropic) with retry logic
246
+ - Tracks tokens and metadata
247
+ - Handles errors gracefully
248
+
249
+ Args:
250
+ prompt_name: Name of the Langfuse prompt
251
+ user_input: User's query/input to be substituted in prompt
252
+ prompt_version: Optional specific version number
253
+ prompt_label: Optional prompt label (e.g., "production")
254
+ model: Model name (e.g., "gpt-3.5-turbo" or "claude-sonnet-4")
255
+ provider: LLM provider ("openai" or "anthropic"). Auto-detected if None
256
+ temperature: Model temperature (default: 0.7)
257
+ max_tokens: Maximum tokens in response (default: 500)
258
+ **kwargs: Additional parameters for LLM API
259
+
260
+ Returns:
261
+ LLMResponse with content, usage, and metadata
262
+
263
+ Raises:
264
+ ProviderError: If provider is not configured or encounters an error
265
+ ValidationError: If input validation fails
266
+ PromptNotFoundError: If prompt is not found
267
+ Exception: Re-raises any errors after logging to Langfuse
268
+
269
+ Example (OpenAI):
270
+ >>> response = lf.call_llm(
271
+ ... prompt_name="assistant",
272
+ ... user_input="What is Python?",
273
+ ... model="gpt-3.5-turbo"
274
+ ... )
275
+
276
+ Example (Anthropic):
277
+ >>> response = lf.call_llm(
278
+ ... prompt_name="assistant",
279
+ ... user_input="What is Python?",
280
+ ... model="claude-sonnet-4"
281
+ ... )
282
+ """
283
+ # Validate inputs
284
+ validate_prompt_name(prompt_name)
285
+ validate_model_name(model)
286
+ validate_temperature(temperature)
287
+ validate_max_tokens(max_tokens)
288
+ user_input = sanitize_user_input(user_input)
289
+
290
+ self._logger.info(
291
+ "Starting LLM call",
292
+ prompt_name=prompt_name,
293
+ model=model,
294
+ temperature=temperature,
295
+ max_tokens=max_tokens
296
+ )
297
+
298
+ # Step 1: Determine provider
299
+ detected_provider = provider or self._detect_provider(model)
300
+
301
+ # Validate provider is configured
302
+ if detected_provider == "openai" and not self._openai:
303
+ raise ProviderError(
304
+ "openai",
305
+ "OpenAI provider not configured. Please set OPENAI_API_KEY environment variable."
306
+ )
307
+ if detected_provider == "anthropic":
308
+ if not ANTHROPIC_AVAILABLE:
309
+ raise ProviderError(
310
+ "anthropic",
311
+ "Anthropic library not installed. Install with: pip install anthropic"
312
+ )
313
+ if not self._anthropic:
314
+ raise ProviderError(
315
+ "anthropic",
316
+ "Anthropic provider not configured. Please set ANTHROPIC_API_KEY environment variable."
317
+ )
318
+
319
+ # Step 2: Fetch prompt
320
+ prompt = self.get_prompt(
321
+ prompt_name,
322
+ version=prompt_version,
323
+ label=prompt_label
324
+ )
325
+
326
+ # Step 3: Compile prompt with user input
327
+ messages = prompt.compile(user_input=user_input)
328
+
329
+ # Step 4: Get Langfuse client for tracing
330
+ lf_client = get_client()
331
+
332
+ # Step 5: Update trace metadata
333
+ lf_client.update_current_trace(
334
+ metadata={
335
+ "prompt_name": prompt_name,
336
+ "prompt_version": prompt.version,
337
+ "user_query": user_input,
338
+ "model": model,
339
+ "provider": detected_provider
340
+ }
341
+ )
342
+
343
+ # Step 6: Call appropriate provider
344
+ if detected_provider == "openai":
345
+ return self._call_openai(
346
+ lf_client, messages, model, temperature, max_tokens,
347
+ prompt_name, prompt.version, **kwargs
348
+ )
349
+ else: # anthropic
350
+ return self._call_anthropic(
351
+ lf_client, messages, model, temperature, max_tokens,
352
+ prompt_name, prompt.version, **kwargs
353
+ )
354
+
355
+ def flush(self) -> None:
356
+ """Flush pending traces to Langfuse.
357
+
358
+ Call this before application shutdown to ensure all traces are sent.
359
+
360
+ Example:
361
+ >>> lf = LangfuseManager()
362
+ >>> # ... make LLM calls ...
363
+ >>> lf.flush() # Ensure all traces are sent
364
+ """
365
+ try:
366
+ self._logger.info("Flushing traces to Langfuse")
367
+ self._langfuse.flush()
368
+ self._logger.info("Successfully flushed traces")
369
+ except Exception as e:
370
+ self._logger.error("Failed to flush traces", exc_info=True)
371
+ raise TracingError("Failed to flush traces", e)
372
+
373
+ def _cleanup(self) -> None:
374
+ """Cleanup handler called at shutdown."""
375
+ try:
376
+ self._logger.info("Running cleanup at shutdown")
377
+
378
+ # Log cache statistics if caching is enabled
379
+ if self._prompt_cache is not None:
380
+ stats = self._prompt_cache.get_stats()
381
+ self._logger.info("Cache statistics", **stats)
382
+
383
+ # Flush traces
384
+ self.flush()
385
+
386
+ self._logger.info("Cleanup completed successfully")
387
+ except Exception as e:
388
+ self._logger.error("Error during cleanup", exc_info=True)
389
+
390
+ def _call_openai(
391
+ self,
392
+ lf_client,
393
+ messages: list,
394
+ model: str,
395
+ temperature: float,
396
+ max_tokens: int,
397
+ prompt_name: str,
398
+ prompt_version: int,
399
+ **kwargs
400
+ ) -> LLMResponse:
401
+ """Call OpenAI API with Langfuse tracing and error handling."""
402
+ self._logger.debug("Calling OpenAI API", model=model)
403
+
404
+ with lf_client.start_as_current_observation(
405
+ as_type="generation",
406
+ name=f"{model}-call",
407
+ model=model,
408
+ model_parameters={
409
+ "temperature": temperature,
410
+ "max_tokens": max_tokens,
411
+ **kwargs
412
+ },
413
+ input=messages
414
+ ) as generation:
415
+ try:
416
+ response = self._openai.chat.completions.create(
417
+ model=model,
418
+ messages=messages,
419
+ temperature=temperature,
420
+ max_tokens=max_tokens,
421
+ timeout=self._config.request_timeout,
422
+ **kwargs
423
+ )
424
+
425
+ content = response.choices[0].message.content
426
+ usage = self._extract_openai_usage(response)
427
+
428
+ self._logger.info(
429
+ "OpenAI API call successful",
430
+ model=model,
431
+ tokens_used=usage["total"]
432
+ )
433
+
434
+ generation.update(
435
+ output=content,
436
+ usage_details={
437
+ "input": usage["input"],
438
+ "output": usage["output"],
439
+ "total": usage["total"]
440
+ }
441
+ )
442
+
443
+ return LLMResponse(
444
+ content=content,
445
+ model=model,
446
+ usage=usage,
447
+ metadata={
448
+ "prompt_name": prompt_name,
449
+ "prompt_version": prompt_version,
450
+ "provider": "openai",
451
+ "temperature": temperature,
452
+ "max_tokens": max_tokens
453
+ },
454
+ raw_response=response
455
+ )
456
+
457
+ except Exception as e:
458
+ self._logger.error(
459
+ "OpenAI API call failed",
460
+ exc_info=True,
461
+ model=model,
462
+ error=str(e)
463
+ )
464
+ generation.update(level="ERROR", status_message=str(e))
465
+ raise ProviderError("openai", str(e), e)
466
+
467
+ def _call_anthropic(
468
+ self,
469
+ lf_client,
470
+ messages: list,
471
+ model: str,
472
+ temperature: float,
473
+ max_tokens: int,
474
+ prompt_name: str,
475
+ prompt_version: int,
476
+ **kwargs
477
+ ) -> LLMResponse:
478
+ """Call Anthropic API with Langfuse tracing and error handling."""
479
+ self._logger.debug("Calling Anthropic API", model=model)
480
+
481
+ with lf_client.start_as_current_observation(
482
+ as_type="generation",
483
+ name=f"{model}-call",
484
+ model=model,
485
+ model_parameters={
486
+ "temperature": temperature,
487
+ "max_tokens": max_tokens,
488
+ **kwargs
489
+ },
490
+ input=messages
491
+ ) as generation:
492
+ try:
493
+ # Convert OpenAI format to Anthropic format
494
+ system_message = next((m["content"] for m in messages if m["role"] == "system"), "")
495
+ user_messages = [m for m in messages if m["role"] != "system"]
496
+
497
+ response = self._anthropic.messages.create(
498
+ model=model,
499
+ system=system_message,
500
+ messages=user_messages,
501
+ temperature=temperature,
502
+ max_tokens=max_tokens,
503
+ timeout=self._config.request_timeout,
504
+ **kwargs
505
+ )
506
+
507
+ content = response.content[0].text
508
+ usage = self._extract_anthropic_usage(response)
509
+
510
+ self._logger.info(
511
+ "Anthropic API call successful",
512
+ model=model,
513
+ tokens_used=usage["total"]
514
+ )
515
+
516
+ generation.update(
517
+ output=content,
518
+ usage_details={
519
+ "input": usage["input"],
520
+ "output": usage["output"],
521
+ "total": usage["total"]
522
+ }
523
+ )
524
+
525
+ return LLMResponse(
526
+ content=content,
527
+ model=model,
528
+ usage=usage,
529
+ metadata={
530
+ "prompt_name": prompt_name,
531
+ "prompt_version": prompt_version,
532
+ "provider": "anthropic",
533
+ "temperature": temperature,
534
+ "max_tokens": max_tokens
535
+ },
536
+ raw_response=response
537
+ )
538
+
539
+ except Exception as e:
540
+ self._logger.error(
541
+ "Anthropic API call failed",
542
+ exc_info=True,
543
+ model=model,
544
+ error=str(e)
545
+ )
546
+ generation.update(level="ERROR", status_message=str(e))
547
+ raise ProviderError("anthropic", str(e), e)
548
+
549
+ def _extract_openai_usage(self, response) -> Dict[str, int]:
550
+ """Extract token usage from OpenAI response."""
551
+ return {
552
+ "input": response.usage.prompt_tokens,
553
+ "output": response.usage.completion_tokens,
554
+ "total": response.usage.total_tokens
555
+ }
556
+
557
+ def _extract_anthropic_usage(self, response) -> Dict[str, int]:
558
+ """Extract token usage from Anthropic response."""
559
+ return {
560
+ "input": response.usage.input_tokens,
561
+ "output": response.usage.output_tokens,
562
+ "total": response.usage.input_tokens + response.usage.output_tokens
563
+ }
564
+
565
+
566
+ def get_prompt_by_version_and_label(
567
+ self,
568
+ name: str,
569
+ version: int,
570
+ expected_label: str,
571
+ cache: bool = True
572
+ ):
573
+ """Get prompt by version and validate it has the expected label.
574
+
575
+ This method fetches a specific prompt version and validates that it has
576
+ the expected label. Raises an error if the validation fails.
577
+
578
+ Args:
579
+ name: Prompt name
580
+ version: Specific version number
581
+ expected_label: Label that the version must have
582
+ cache: Whether to use cache (default: True)
583
+
584
+ Returns:
585
+ Langfuse prompt object if validation passes
586
+
587
+ Raises:
588
+ ValidationError: If the version doesn't have the expected label
589
+ PromptNotFoundError: If prompt is not found
590
+
591
+ Example:
592
+ >>> lf = LangfuseManager()
593
+ >>> prompt = lf.get_prompt_by_version_and_label(
594
+ ... name="customer_support_agent",
595
+ ... version=3,
596
+ ... expected_label="production"
597
+ ... )
598
+ """
599
+ from .exceptions import ValidationError
600
+
601
+ # Get prompt by version
602
+ prompt = self.get_prompt(name=name, version=version, cache=cache)
603
+
604
+ # Validate it has the expected label
605
+ if expected_label not in prompt.labels:
606
+ error_msg = (
607
+ f"Validation failed: Version {version} of '{name}' does not have label '{expected_label}'. "
608
+ f"Available labels: {prompt.labels}"
609
+ )
610
+ self._logger.error(error_msg, prompt_name=name, version=version)
611
+ raise ValidationError("prompt_label", error_msg)
612
+
613
+ self._logger.info(
614
+ "Validation passed",
615
+ prompt_name=name,
616
+ version=version,
617
+ label=expected_label
618
+ )
619
+
620
+ return prompt
621
+
622
+ def get_cache_stats(self) -> Optional[Dict[str, Any]]:
623
+ """Get cache statistics.
624
+
625
+ Returns:
626
+ Dictionary with cache statistics or None if caching is disabled
627
+ """
628
+ if self._prompt_cache is not None:
629
+ return self._prompt_cache.get_stats()
630
+ return None
631
+
632
+ def clear_cache(self) -> None:
633
+ """Clear the prompt cache."""
634
+ if self._prompt_cache is not None:
635
+ self._prompt_cache.clear()
636
+ self._logger.info("Prompt cache cleared")
637
+
638
+ @property
639
+ def config(self) -> LangfuseConfig:
640
+ """Get current configuration.
641
+
642
+ Returns:
643
+ LangfuseConfig instance
644
+ """
645
+ return self._config
646
+
647
+ @property
648
+ def langfuse_client(self) -> Langfuse:
649
+ """Get underlying Langfuse client for advanced usage.
650
+
651
+ Returns:
652
+ Langfuse client instance
653
+ """
654
+ return self._langfuse
655
+
656
+ @property
657
+ def openai_client(self) -> OpenAI:
658
+ """Get underlying OpenAI client for advanced usage.
659
+
660
+ Returns:
661
+ OpenAI client instance
662
+ """
663
+ return self._openai
@@ -0,0 +1,42 @@
1
+ """
2
+ Data models for Langfuse library responses.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Dict, Any, Optional
7
+
8
+
9
+ @dataclass
10
+ class LLMResponse:
11
+ """Normalized response from LLM providers.
12
+
13
+ Attributes:
14
+ content: The text content of the LLM response
15
+ model: The model name used for the generation
16
+ usage: Token usage information (input, output, total)
17
+ metadata: Additional metadata about the generation
18
+ raw_response: The raw response object from the provider (optional)
19
+ """
20
+
21
+ content: str
22
+ model: str
23
+ usage: Dict[str, int]
24
+ metadata: Optional[Dict[str, Any]] = None
25
+ raw_response: Optional[Any] = None
26
+
27
+ def __str__(self) -> str:
28
+ """String representation showing the content."""
29
+ return self.content
30
+
31
+ def to_dict(self) -> Dict[str, Any]:
32
+ """Convert response to dictionary.
33
+
34
+ Returns:
35
+ Dictionary representation of the response
36
+ """
37
+ return {
38
+ "content": self.content,
39
+ "model": self.model,
40
+ "usage": self.usage,
41
+ "metadata": self.metadata
42
+ }