puvinoise-sdk 0.2.0__tar.gz
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.
- puvinoise_sdk-0.2.0/PKG-INFO +28 -0
- puvinoise_sdk-0.2.0/puvinoise/__init__.py +52 -0
- puvinoise_sdk-0.2.0/puvinoise/bootstrap.py +131 -0
- puvinoise_sdk-0.2.0/puvinoise/context.py +76 -0
- puvinoise_sdk-0.2.0/puvinoise/hooks.py +469 -0
- puvinoise_sdk-0.2.0/puvinoise/tracer.py +87 -0
- puvinoise_sdk-0.2.0/puvinoise_sdk.egg-info/PKG-INFO +28 -0
- puvinoise_sdk-0.2.0/puvinoise_sdk.egg-info/SOURCES.txt +11 -0
- puvinoise_sdk-0.2.0/puvinoise_sdk.egg-info/dependency_links.txt +1 -0
- puvinoise_sdk-0.2.0/puvinoise_sdk.egg-info/requires.txt +13 -0
- puvinoise_sdk-0.2.0/puvinoise_sdk.egg-info/top_level.txt +1 -0
- puvinoise_sdk-0.2.0/pyproject.toml +52 -0
- puvinoise_sdk-0.2.0/setup.cfg +4 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: puvinoise-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Independent OpenTelemetry SDK for AI agents (Anthropic, OpenAI, Ollama)
|
|
5
|
+
Author-email: Sarvesh Sahane <sarvesh.sahane@puvilabs.com>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://puvilabs.com
|
|
8
|
+
Keywords: puvinoise,opentelemetry,otel,tracing,llm,agents,observability
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Topic :: System :: Monitoring
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: opentelemetry-api>=1.20
|
|
20
|
+
Requires-Dist: opentelemetry-sdk>=1.20
|
|
21
|
+
Requires-Dist: opentelemetry-exporter-otlp>=1.20
|
|
22
|
+
Provides-Extra: anthropic
|
|
23
|
+
Requires-Dist: anthropic>=0.20; extra == "anthropic"
|
|
24
|
+
Provides-Extra: openai
|
|
25
|
+
Requires-Dist: openai>=1.0; extra == "openai"
|
|
26
|
+
Provides-Extra: all
|
|
27
|
+
Requires-Dist: anthropic>=0.20; extra == "all"
|
|
28
|
+
Requires-Dist: openai>=1.0; extra == "all"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Puvinoise - Independent OpenTelemetry SDK for AI agents
|
|
3
|
+
Supports Anthropic Claude, OpenAI, Ollama, and other LLM providers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from puvinoise.bootstrap import bootstrap
|
|
7
|
+
from puvinoise.tracer import run_with_trace
|
|
8
|
+
from puvinoise.hooks import (
|
|
9
|
+
# Anthropic
|
|
10
|
+
instrument_anthropic_call,
|
|
11
|
+
instrument_anthropic_stream,
|
|
12
|
+
# OpenAI
|
|
13
|
+
instrument_openai_call,
|
|
14
|
+
instrument_openai_stream,
|
|
15
|
+
# Generic / Ollama
|
|
16
|
+
instrument_llm_call,
|
|
17
|
+
# Agent internals
|
|
18
|
+
instrument_memory_operation,
|
|
19
|
+
instrument_tool_call,
|
|
20
|
+
# Span utilities
|
|
21
|
+
add_span_attribute,
|
|
22
|
+
add_span_event,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
# Bootstrap
|
|
29
|
+
"bootstrap",
|
|
30
|
+
|
|
31
|
+
# Core tracing
|
|
32
|
+
"run_with_trace",
|
|
33
|
+
|
|
34
|
+
# Anthropic hooks
|
|
35
|
+
"instrument_anthropic_call",
|
|
36
|
+
"instrument_anthropic_stream",
|
|
37
|
+
|
|
38
|
+
# OpenAI hooks
|
|
39
|
+
"instrument_openai_call",
|
|
40
|
+
"instrument_openai_stream",
|
|
41
|
+
|
|
42
|
+
# Generic / Ollama hooks
|
|
43
|
+
"instrument_llm_call",
|
|
44
|
+
|
|
45
|
+
# Agent internals
|
|
46
|
+
"instrument_memory_operation",
|
|
47
|
+
"instrument_tool_call",
|
|
48
|
+
|
|
49
|
+
# Span utilities
|
|
50
|
+
"add_span_attribute",
|
|
51
|
+
"add_span_event",
|
|
52
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from opentelemetry import trace
|
|
2
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
3
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
|
|
4
|
+
from opentelemetry.sdk.resources import Resource
|
|
5
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
_initialized = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def bootstrap(
|
|
15
|
+
service_name: str,
|
|
16
|
+
otlp_endpoint: str = None,
|
|
17
|
+
console_export: bool = False,
|
|
18
|
+
batch_config: dict = None
|
|
19
|
+
):
|
|
20
|
+
"""
|
|
21
|
+
Initialize OpenTelemetry tracing for the agent.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
service_name: Name of the service (e.g., "blog-agent")
|
|
25
|
+
otlp_endpoint: OTLP collector endpoint (defaults to env var or hardcoded)
|
|
26
|
+
console_export: If True, also export spans to console for debugging
|
|
27
|
+
batch_config: Custom batch processor configuration
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Tracer instance
|
|
31
|
+
"""
|
|
32
|
+
global _initialized
|
|
33
|
+
|
|
34
|
+
# Prevent double initialization
|
|
35
|
+
if _initialized:
|
|
36
|
+
logger.info(f"Tracer already initialized for service: {service_name}")
|
|
37
|
+
return trace.get_tracer(service_name)
|
|
38
|
+
|
|
39
|
+
# Get endpoint from parameter, env var, or default
|
|
40
|
+
endpoint = (
|
|
41
|
+
otlp_endpoint
|
|
42
|
+
or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
|
43
|
+
# or "http://13.235.48.96:4318/v1/traces"
|
|
44
|
+
)
|
|
45
|
+
if not endpoint.endswith("/v1/traces"):
|
|
46
|
+
endpoint = endpoint.rstrip("/") + "/v1/traces"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
tenant_id = os.getenv("TENANT_ID")
|
|
50
|
+
|
|
51
|
+
if not tenant_id:
|
|
52
|
+
raise ValueError("TENANT_ID environment variable must be set")
|
|
53
|
+
|
|
54
|
+
resource = Resource.create({
|
|
55
|
+
"service.name": service_name,
|
|
56
|
+
"service.version": os.getenv("SERVICE_VERSION", "0.1.0"),
|
|
57
|
+
"deployment.environment": os.getenv("ENVIRONMENT", "development"),
|
|
58
|
+
"tenant.id": tenant_id,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Create tracer provider
|
|
64
|
+
provider = TracerProvider(resource=resource)
|
|
65
|
+
trace.set_tracer_provider(provider)
|
|
66
|
+
|
|
67
|
+
# Default batch configuration
|
|
68
|
+
default_batch_config = {
|
|
69
|
+
"schedule_delay_millis": 1000, # Export every 1 second
|
|
70
|
+
"max_export_batch_size": 64, # Max 64 spans per batch
|
|
71
|
+
"export_timeout_millis": 30000, # 30 second timeout
|
|
72
|
+
"max_queue_size": 2048, # Queue up to 2048 spans
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Merge with custom config if provided
|
|
76
|
+
if batch_config:
|
|
77
|
+
default_batch_config.update(batch_config)
|
|
78
|
+
|
|
79
|
+
# Configure OTLP exporter
|
|
80
|
+
try:
|
|
81
|
+
otlp_exporter = OTLPSpanExporter(endpoint=endpoint)
|
|
82
|
+
provider.add_span_processor(
|
|
83
|
+
BatchSpanProcessor(otlp_exporter, **default_batch_config)
|
|
84
|
+
)
|
|
85
|
+
logger.info(f"✅ OTLP exporter configured: {endpoint}")
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.error(f"❌ Failed to configure OTLP exporter: {e}")
|
|
88
|
+
# Continue with console exporter as fallback
|
|
89
|
+
console_export = True
|
|
90
|
+
|
|
91
|
+
# Optional console exporter for debugging
|
|
92
|
+
if console_export:
|
|
93
|
+
console_exporter = ConsoleSpanExporter()
|
|
94
|
+
provider.add_span_processor(
|
|
95
|
+
BatchSpanProcessor(console_exporter, **default_batch_config)
|
|
96
|
+
)
|
|
97
|
+
logger.info("✅ Console exporter configured for debugging")
|
|
98
|
+
|
|
99
|
+
_initialized = True
|
|
100
|
+
logger.info(f"🚀 OpenTelemetry initialized for service: {service_name}")
|
|
101
|
+
|
|
102
|
+
return trace.get_tracer(service_name)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def shutdown():
|
|
106
|
+
"""
|
|
107
|
+
Gracefully shutdown the tracer provider.
|
|
108
|
+
Call this when your application is terminating to ensure all spans are exported.
|
|
109
|
+
"""
|
|
110
|
+
provider = trace.get_tracer_provider()
|
|
111
|
+
if isinstance(provider, TracerProvider):
|
|
112
|
+
provider.shutdown()
|
|
113
|
+
logger.info("OpenTelemetry tracer provider shut down successfully")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def force_flush(timeout_millis: int = 30000):
|
|
117
|
+
"""
|
|
118
|
+
Force flush all pending spans.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
timeout_millis: Maximum time to wait for flush in milliseconds
|
|
122
|
+
"""
|
|
123
|
+
provider = trace.get_tracer_provider()
|
|
124
|
+
if isinstance(provider, TracerProvider):
|
|
125
|
+
success = provider.force_flush(timeout_millis)
|
|
126
|
+
if success:
|
|
127
|
+
logger.info("Successfully flushed all pending spans")
|
|
128
|
+
else:
|
|
129
|
+
logger.warning("Flush timed out or failed")
|
|
130
|
+
return success
|
|
131
|
+
return False
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
import contextvars
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
# Context variables automatically propagate across async calls
|
|
6
|
+
_run_id = contextvars.ContextVar("run_id", default=None)
|
|
7
|
+
_agent_name = contextvars.ContextVar("agent_name", default=None)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AgentContext:
|
|
11
|
+
"""
|
|
12
|
+
Holds metadata for a single agent execution.
|
|
13
|
+
Uses contextvars for thread-safe and async-safe context propagation.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, agent_name: str, run_id: Optional[str] = None):
|
|
17
|
+
"""
|
|
18
|
+
Initialize agent context.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
agent_name: Name of the agent
|
|
22
|
+
run_id: Optional custom run ID (generates UUID if not provided)
|
|
23
|
+
"""
|
|
24
|
+
self.run_id = run_id or str(uuid.uuid4())
|
|
25
|
+
self.agent_name = agent_name
|
|
26
|
+
self._tokens = []
|
|
27
|
+
|
|
28
|
+
def activate(self):
|
|
29
|
+
"""Activate this context for the current execution."""
|
|
30
|
+
self._tokens = [
|
|
31
|
+
_run_id.set(self.run_id),
|
|
32
|
+
_agent_name.set(self.agent_name)
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def deactivate(self):
|
|
36
|
+
"""Deactivate this context and restore previous values."""
|
|
37
|
+
for token in reversed(self._tokens):
|
|
38
|
+
try:
|
|
39
|
+
if token:
|
|
40
|
+
token.var.reset(token)
|
|
41
|
+
except Exception:
|
|
42
|
+
pass # Token may have already been reset
|
|
43
|
+
self._tokens.clear()
|
|
44
|
+
|
|
45
|
+
def __enter__(self):
|
|
46
|
+
"""Context manager support."""
|
|
47
|
+
self.activate()
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
51
|
+
"""Context manager cleanup."""
|
|
52
|
+
self.deactivate()
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def get_run_id() -> Optional[str]:
|
|
57
|
+
"""Get the current run ID from context."""
|
|
58
|
+
return _run_id.get()
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def get_agent_name() -> Optional[str]:
|
|
62
|
+
"""Get the current agent name from context."""
|
|
63
|
+
return _agent_name.get()
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def get_context() -> dict:
|
|
67
|
+
"""Get all current context values as a dictionary."""
|
|
68
|
+
return {
|
|
69
|
+
"run_id": _run_id.get(),
|
|
70
|
+
"agent_name": _agent_name.get()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def is_active() -> bool:
|
|
75
|
+
"""Check if there's an active agent context."""
|
|
76
|
+
return _run_id.get() is not None
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multi-provider instrumentation hooks for puvinoise SDK.
|
|
3
|
+
Supports Anthropic, OpenAI, Ollama, and generic LLM providers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from opentelemetry import trace
|
|
7
|
+
from opentelemetry.trace import Status, StatusCode
|
|
8
|
+
from puvinoise.context import AgentContext
|
|
9
|
+
import functools
|
|
10
|
+
|
|
11
|
+
_tracer = trace.get_tracer("agent.hooks")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Internal helpers
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
def _attach_agent_context(span):
|
|
19
|
+
"""Attach run_id and agent_name from AgentContext to span (if active)."""
|
|
20
|
+
run_id = AgentContext.get_run_id()
|
|
21
|
+
agent_name = AgentContext.get_agent_name()
|
|
22
|
+
if run_id:
|
|
23
|
+
span.set_attribute("agent.run_id", run_id)
|
|
24
|
+
if agent_name:
|
|
25
|
+
span.set_attribute("agent.name", agent_name)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _extract_openai_response(result, span):
|
|
29
|
+
"""
|
|
30
|
+
Parse an OpenAI ChatCompletion response object and record span attributes.
|
|
31
|
+
Works for openai>=1.0 (the openai.types.chat.ChatCompletion object).
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
# Model actually used (may differ from requested)
|
|
35
|
+
if hasattr(result, "model"):
|
|
36
|
+
span.set_attribute("llm.response_model", result.model)
|
|
37
|
+
|
|
38
|
+
choices = getattr(result, "choices", None)
|
|
39
|
+
if choices:
|
|
40
|
+
first = choices[0]
|
|
41
|
+
# finish_reason
|
|
42
|
+
finish_reason = getattr(first, "finish_reason", None)
|
|
43
|
+
if finish_reason:
|
|
44
|
+
span.set_attribute("llm.stop_reason", finish_reason)
|
|
45
|
+
# message content
|
|
46
|
+
message = getattr(first, "message", None)
|
|
47
|
+
if message:
|
|
48
|
+
content = getattr(message, "content", None) or ""
|
|
49
|
+
span.set_attribute("llm.response", content[:500])
|
|
50
|
+
span.set_attribute("llm.response_length", len(content))
|
|
51
|
+
|
|
52
|
+
# Token usage
|
|
53
|
+
usage = getattr(result, "usage", None)
|
|
54
|
+
if usage:
|
|
55
|
+
if hasattr(usage, "prompt_tokens"):
|
|
56
|
+
span.set_attribute("llm.input_tokens", usage.prompt_tokens)
|
|
57
|
+
if hasattr(usage, "completion_tokens"):
|
|
58
|
+
span.set_attribute("llm.output_tokens", usage.completion_tokens)
|
|
59
|
+
except Exception:
|
|
60
|
+
pass # Never let telemetry crash the caller
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _extract_anthropic_response(result, span):
|
|
64
|
+
"""Parse an Anthropic MessageResponse and record span attributes."""
|
|
65
|
+
try:
|
|
66
|
+
if hasattr(result, "model"):
|
|
67
|
+
span.set_attribute("llm.response_model", result.model)
|
|
68
|
+
|
|
69
|
+
if hasattr(result, "content") and result.content:
|
|
70
|
+
if isinstance(result.content, list):
|
|
71
|
+
text_blocks = [b.text for b in result.content if hasattr(b, "text")]
|
|
72
|
+
response_text = " ".join(text_blocks)
|
|
73
|
+
else:
|
|
74
|
+
response_text = str(result.content)
|
|
75
|
+
span.set_attribute("llm.response", response_text[:500])
|
|
76
|
+
span.set_attribute("llm.response_length", len(response_text))
|
|
77
|
+
|
|
78
|
+
if hasattr(result, "stop_reason"):
|
|
79
|
+
span.set_attribute("llm.stop_reason", result.stop_reason)
|
|
80
|
+
|
|
81
|
+
usage = getattr(result, "usage", None)
|
|
82
|
+
if usage:
|
|
83
|
+
if hasattr(usage, "input_tokens"):
|
|
84
|
+
span.set_attribute("llm.input_tokens", usage.input_tokens)
|
|
85
|
+
if hasattr(usage, "output_tokens"):
|
|
86
|
+
span.set_attribute("llm.output_tokens", usage.output_tokens)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _extract_openai_messages(kwargs, span):
|
|
92
|
+
"""Extract prompt info from OpenAI-style messages kwarg."""
|
|
93
|
+
messages = kwargs.get("messages")
|
|
94
|
+
if not messages:
|
|
95
|
+
return
|
|
96
|
+
span.set_attribute("llm.message_count", len(messages))
|
|
97
|
+
user_msgs = [m for m in messages if m.get("role") == "user"]
|
|
98
|
+
if user_msgs:
|
|
99
|
+
content = user_msgs[0].get("content", "")
|
|
100
|
+
if isinstance(content, str):
|
|
101
|
+
span.set_attribute("llm.prompt", content[:500])
|
|
102
|
+
elif isinstance(content, list):
|
|
103
|
+
texts = [b.get("text", "") for b in content if b.get("type") == "text"]
|
|
104
|
+
if texts:
|
|
105
|
+
span.set_attribute("llm.prompt", texts[0][:500])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Anthropic hooks
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def instrument_anthropic_call(func):
|
|
113
|
+
"""
|
|
114
|
+
Decorator to automatically trace synchronous Anthropic API calls.
|
|
115
|
+
|
|
116
|
+
Usage::
|
|
117
|
+
|
|
118
|
+
@instrument_anthropic_call
|
|
119
|
+
def call_claude(prompt, model="claude-sonnet-4-5-20250929"):
|
|
120
|
+
response = client.messages.create(...)
|
|
121
|
+
return response
|
|
122
|
+
"""
|
|
123
|
+
@functools.wraps(func)
|
|
124
|
+
def wrapper(*args, **kwargs):
|
|
125
|
+
with _tracer.start_as_current_span("llm.anthropic") as span:
|
|
126
|
+
_attach_agent_context(span)
|
|
127
|
+
span.set_attribute("llm.provider", "anthropic")
|
|
128
|
+
|
|
129
|
+
model = kwargs.get("model")
|
|
130
|
+
if model:
|
|
131
|
+
span.set_attribute("llm.model", model)
|
|
132
|
+
|
|
133
|
+
_extract_openai_messages(kwargs, span) # same shape for Anthropic
|
|
134
|
+
|
|
135
|
+
if "temperature" in kwargs:
|
|
136
|
+
span.set_attribute("llm.temperature", float(kwargs["temperature"]))
|
|
137
|
+
if "max_tokens" in kwargs:
|
|
138
|
+
span.set_attribute("llm.max_tokens", int(kwargs["max_tokens"]))
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
result = func(*args, **kwargs)
|
|
142
|
+
_extract_anthropic_response(result, span)
|
|
143
|
+
span.set_status(Status(StatusCode.OK))
|
|
144
|
+
return result
|
|
145
|
+
except Exception as e:
|
|
146
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
147
|
+
span.record_exception(e)
|
|
148
|
+
span.set_attribute("error.type", type(e).__name__)
|
|
149
|
+
raise
|
|
150
|
+
|
|
151
|
+
return wrapper
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def instrument_anthropic_stream(func):
|
|
155
|
+
"""
|
|
156
|
+
Decorator for *generator* streaming Anthropic API calls.
|
|
157
|
+
|
|
158
|
+
The span stays open for the entire stream duration (bug fix: previously
|
|
159
|
+
the span closed before iteration completed).
|
|
160
|
+
|
|
161
|
+
Usage::
|
|
162
|
+
|
|
163
|
+
@instrument_anthropic_stream
|
|
164
|
+
def stream_claude(prompt):
|
|
165
|
+
with client.messages.stream(...) as stream:
|
|
166
|
+
for text in stream.text_stream:
|
|
167
|
+
yield text
|
|
168
|
+
"""
|
|
169
|
+
@functools.wraps(func)
|
|
170
|
+
def wrapper(*args, **kwargs):
|
|
171
|
+
span = _tracer.start_span("llm.anthropic.stream")
|
|
172
|
+
ctx = trace.use_span(span, end_on_exit=False)
|
|
173
|
+
ctx.__enter__()
|
|
174
|
+
|
|
175
|
+
_attach_agent_context(span)
|
|
176
|
+
span.set_attribute("llm.provider", "anthropic")
|
|
177
|
+
span.set_attribute("llm.streaming", True)
|
|
178
|
+
|
|
179
|
+
model = kwargs.get("model")
|
|
180
|
+
if model:
|
|
181
|
+
span.set_attribute("llm.model", model)
|
|
182
|
+
|
|
183
|
+
chunk_count = 0
|
|
184
|
+
total_length = 0
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
for chunk in func(*args, **kwargs):
|
|
188
|
+
chunk_count += 1
|
|
189
|
+
if isinstance(chunk, str):
|
|
190
|
+
total_length += len(chunk)
|
|
191
|
+
yield chunk
|
|
192
|
+
|
|
193
|
+
span.set_attribute("llm.chunks_received", chunk_count)
|
|
194
|
+
span.set_attribute("llm.total_length", total_length)
|
|
195
|
+
span.set_status(Status(StatusCode.OK))
|
|
196
|
+
except Exception as e:
|
|
197
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
198
|
+
span.record_exception(e)
|
|
199
|
+
raise
|
|
200
|
+
finally:
|
|
201
|
+
span.end()
|
|
202
|
+
|
|
203
|
+
return wrapper
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# OpenAI hook
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def instrument_openai_call(func):
|
|
211
|
+
"""
|
|
212
|
+
Decorator to automatically trace OpenAI API calls (openai>=1.0).
|
|
213
|
+
|
|
214
|
+
API keys are read by the openai library itself (``OPENAI_API_KEY`` env var
|
|
215
|
+
or ``openai.api_key``). The SDK never touches credentials.
|
|
216
|
+
|
|
217
|
+
Usage::
|
|
218
|
+
|
|
219
|
+
@instrument_openai_call
|
|
220
|
+
def call_gpt(prompt, model="gpt-4o"):
|
|
221
|
+
response = client.chat.completions.create(
|
|
222
|
+
model=model,
|
|
223
|
+
messages=[{"role": "user", "content": prompt}],
|
|
224
|
+
)
|
|
225
|
+
return response
|
|
226
|
+
"""
|
|
227
|
+
@functools.wraps(func)
|
|
228
|
+
def wrapper(*args, **kwargs):
|
|
229
|
+
with _tracer.start_as_current_span("llm.openai") as span:
|
|
230
|
+
_attach_agent_context(span)
|
|
231
|
+
span.set_attribute("llm.provider", "openai")
|
|
232
|
+
|
|
233
|
+
model = kwargs.get("model")
|
|
234
|
+
if model:
|
|
235
|
+
span.set_attribute("llm.model", model)
|
|
236
|
+
|
|
237
|
+
_extract_openai_messages(kwargs, span)
|
|
238
|
+
|
|
239
|
+
if "temperature" in kwargs:
|
|
240
|
+
span.set_attribute("llm.temperature", float(kwargs["temperature"]))
|
|
241
|
+
if "max_tokens" in kwargs:
|
|
242
|
+
span.set_attribute("llm.max_tokens", int(kwargs["max_tokens"]))
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
result = func(*args, **kwargs)
|
|
246
|
+
_extract_openai_response(result, span)
|
|
247
|
+
span.set_status(Status(StatusCode.OK))
|
|
248
|
+
return result
|
|
249
|
+
except Exception as e:
|
|
250
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
251
|
+
span.record_exception(e)
|
|
252
|
+
span.set_attribute("error.type", type(e).__name__)
|
|
253
|
+
raise
|
|
254
|
+
|
|
255
|
+
return wrapper
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def instrument_openai_stream(func):
|
|
259
|
+
"""
|
|
260
|
+
Decorator for streaming OpenAI calls using ``stream=True``.
|
|
261
|
+
|
|
262
|
+
Usage::
|
|
263
|
+
|
|
264
|
+
@instrument_openai_stream
|
|
265
|
+
def stream_gpt(prompt, model="gpt-4o"):
|
|
266
|
+
for chunk in client.chat.completions.create(
|
|
267
|
+
model=model,
|
|
268
|
+
messages=[{"role": "user", "content": prompt}],
|
|
269
|
+
stream=True,
|
|
270
|
+
):
|
|
271
|
+
delta = chunk.choices[0].delta.content or ""
|
|
272
|
+
yield delta
|
|
273
|
+
"""
|
|
274
|
+
@functools.wraps(func)
|
|
275
|
+
def wrapper(*args, **kwargs):
|
|
276
|
+
span = _tracer.start_span("llm.openai.stream")
|
|
277
|
+
ctx = trace.use_span(span, end_on_exit=False)
|
|
278
|
+
ctx.__enter__()
|
|
279
|
+
|
|
280
|
+
_attach_agent_context(span)
|
|
281
|
+
span.set_attribute("llm.provider", "openai")
|
|
282
|
+
span.set_attribute("llm.streaming", True)
|
|
283
|
+
|
|
284
|
+
model = kwargs.get("model")
|
|
285
|
+
if model:
|
|
286
|
+
span.set_attribute("llm.model", model)
|
|
287
|
+
|
|
288
|
+
chunk_count = 0
|
|
289
|
+
total_length = 0
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
for chunk in func(*args, **kwargs):
|
|
293
|
+
chunk_count += 1
|
|
294
|
+
if isinstance(chunk, str):
|
|
295
|
+
total_length += len(chunk)
|
|
296
|
+
yield chunk
|
|
297
|
+
|
|
298
|
+
span.set_attribute("llm.chunks_received", chunk_count)
|
|
299
|
+
span.set_attribute("llm.total_length", total_length)
|
|
300
|
+
span.set_status(Status(StatusCode.OK))
|
|
301
|
+
except Exception as e:
|
|
302
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
303
|
+
span.record_exception(e)
|
|
304
|
+
raise
|
|
305
|
+
finally:
|
|
306
|
+
span.end()
|
|
307
|
+
|
|
308
|
+
return wrapper
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
# Generic / Ollama hook
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
def instrument_llm_call(provider="unknown"):
|
|
316
|
+
"""
|
|
317
|
+
Generic LLM call instrumentation (works with any provider: Ollama, Cohere, etc.).
|
|
318
|
+
|
|
319
|
+
Unlike the old version this is now a *parameterised* decorator so you can
|
|
320
|
+
tag the provider name cleanly::
|
|
321
|
+
|
|
322
|
+
@instrument_llm_call(provider="ollama")
|
|
323
|
+
def call_ollama(prompt, model="llama3"):
|
|
324
|
+
...
|
|
325
|
+
|
|
326
|
+
For backward-compat, calling without parentheses still works (provider="unknown").
|
|
327
|
+
"""
|
|
328
|
+
def decorator(func):
|
|
329
|
+
@functools.wraps(func)
|
|
330
|
+
def wrapper(*args, **kwargs):
|
|
331
|
+
with _tracer.start_as_current_span("llm.call") as span:
|
|
332
|
+
_attach_agent_context(span)
|
|
333
|
+
span.set_attribute("llm.provider", provider)
|
|
334
|
+
|
|
335
|
+
model = kwargs.get("model", "unknown")
|
|
336
|
+
span.set_attribute("llm.model", model)
|
|
337
|
+
|
|
338
|
+
# Accept a plain prompt string as first positional arg or kwarg
|
|
339
|
+
prompt = kwargs.get("prompt") or (
|
|
340
|
+
args[0] if args and isinstance(args[0], str) else None
|
|
341
|
+
)
|
|
342
|
+
if prompt:
|
|
343
|
+
span.set_attribute("llm.prompt", prompt[:500])
|
|
344
|
+
|
|
345
|
+
if "temperature" in kwargs:
|
|
346
|
+
span.set_attribute("llm.temperature", float(kwargs["temperature"]))
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
result = func(*args, **kwargs)
|
|
350
|
+
|
|
351
|
+
if isinstance(result, str):
|
|
352
|
+
span.set_attribute("llm.response", result[:500])
|
|
353
|
+
span.set_attribute("llm.response_length", len(result))
|
|
354
|
+
elif isinstance(result, dict):
|
|
355
|
+
# Ollama /api/chat returns a dict
|
|
356
|
+
msg = (
|
|
357
|
+
result.get("message", {}).get("content")
|
|
358
|
+
or result.get("response", "")
|
|
359
|
+
)
|
|
360
|
+
if msg:
|
|
361
|
+
span.set_attribute("llm.response", msg[:500])
|
|
362
|
+
span.set_attribute("llm.response_length", len(msg))
|
|
363
|
+
# Ollama token counts
|
|
364
|
+
if "prompt_eval_count" in result:
|
|
365
|
+
span.set_attribute("llm.input_tokens", result["prompt_eval_count"])
|
|
366
|
+
if "eval_count" in result:
|
|
367
|
+
span.set_attribute("llm.output_tokens", result["eval_count"])
|
|
368
|
+
|
|
369
|
+
span.set_status(Status(StatusCode.OK))
|
|
370
|
+
return result
|
|
371
|
+
except Exception as e:
|
|
372
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
373
|
+
span.record_exception(e)
|
|
374
|
+
span.set_attribute("error.type", type(e).__name__)
|
|
375
|
+
raise
|
|
376
|
+
|
|
377
|
+
return wrapper
|
|
378
|
+
|
|
379
|
+
# Allow @instrument_llm_call (no parens) for backward compat
|
|
380
|
+
if callable(provider):
|
|
381
|
+
func, provider = provider, "unknown"
|
|
382
|
+
return decorator(func)
|
|
383
|
+
|
|
384
|
+
return decorator
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
# Memory & tool hooks (unchanged API, small robustness improvements)
|
|
389
|
+
# ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
def instrument_memory_operation(operation_type="query"):
|
|
392
|
+
"""
|
|
393
|
+
Decorator to trace memory operations (retrieval, storage, delete, etc.).
|
|
394
|
+
|
|
395
|
+
Usage::
|
|
396
|
+
|
|
397
|
+
@instrument_memory_operation("retrieve")
|
|
398
|
+
def retrieve_memory(query):
|
|
399
|
+
...
|
|
400
|
+
"""
|
|
401
|
+
def decorator(func):
|
|
402
|
+
@functools.wraps(func)
|
|
403
|
+
def wrapper(*args, **kwargs):
|
|
404
|
+
with _tracer.start_as_current_span(f"memory.{operation_type}") as span:
|
|
405
|
+
_attach_agent_context(span)
|
|
406
|
+
span.set_attribute("memory.operation", operation_type)
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
result = func(*args, **kwargs)
|
|
410
|
+
if isinstance(result, (list, tuple)):
|
|
411
|
+
span.set_attribute("memory.result_count", len(result))
|
|
412
|
+
span.set_status(Status(StatusCode.OK))
|
|
413
|
+
return result
|
|
414
|
+
except Exception as e:
|
|
415
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
416
|
+
span.record_exception(e)
|
|
417
|
+
raise
|
|
418
|
+
|
|
419
|
+
return wrapper
|
|
420
|
+
return decorator
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def instrument_tool_call(tool_name=None):
|
|
424
|
+
"""
|
|
425
|
+
Decorator to trace agent tool usage.
|
|
426
|
+
|
|
427
|
+
Usage::
|
|
428
|
+
|
|
429
|
+
@instrument_tool_call("web_search")
|
|
430
|
+
def search_web(query):
|
|
431
|
+
...
|
|
432
|
+
"""
|
|
433
|
+
def decorator(func):
|
|
434
|
+
@functools.wraps(func)
|
|
435
|
+
def wrapper(*args, **kwargs):
|
|
436
|
+
name = tool_name or func.__name__
|
|
437
|
+
with _tracer.start_as_current_span(f"tool.{name}") as span:
|
|
438
|
+
_attach_agent_context(span)
|
|
439
|
+
span.set_attribute("tool.name", name)
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
result = func(*args, **kwargs)
|
|
443
|
+
span.set_status(Status(StatusCode.OK))
|
|
444
|
+
return result
|
|
445
|
+
except Exception as e:
|
|
446
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
447
|
+
span.record_exception(e)
|
|
448
|
+
raise
|
|
449
|
+
|
|
450
|
+
return wrapper
|
|
451
|
+
return decorator
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
# Span utilities
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
def add_span_attribute(key: str, value):
|
|
459
|
+
"""Add a custom attribute to the current active span."""
|
|
460
|
+
span = trace.get_current_span()
|
|
461
|
+
if span and span.is_recording():
|
|
462
|
+
span.set_attribute(key, value)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def add_span_event(name: str, attributes: dict = None):
|
|
466
|
+
"""Add an event to the current active span."""
|
|
467
|
+
span = trace.get_current_span()
|
|
468
|
+
if span and span.is_recording():
|
|
469
|
+
span.add_event(name, attributes or {})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from opentelemetry import trace
|
|
2
|
+
from opentelemetry.trace import Status, StatusCode
|
|
3
|
+
from puvinoise.context import AgentContext
|
|
4
|
+
import functools
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
_tracer = trace.get_tracer("agent.runtime")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_with_trace(fn, agent_name="default-agent", *args, **kwargs):
|
|
11
|
+
"""
|
|
12
|
+
Wraps a function execution with OpenTelemetry tracing and agent context.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
fn: The function to execute
|
|
16
|
+
agent_name: Name of the agent for this execution
|
|
17
|
+
*args, **kwargs: Arguments to pass to the function
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The result of the function execution
|
|
21
|
+
"""
|
|
22
|
+
ctx = AgentContext(agent_name)
|
|
23
|
+
ctx.activate()
|
|
24
|
+
|
|
25
|
+
with _tracer.start_as_current_span("agent.run") as span:
|
|
26
|
+
span.set_attribute("agent.run_id", ctx.run_id)
|
|
27
|
+
span.set_attribute("agent.name", ctx.agent_name)
|
|
28
|
+
|
|
29
|
+
tenant_id = os.getenv("TENANT_ID")
|
|
30
|
+
if tenant_id:
|
|
31
|
+
span.set_attribute("tenant.id", tenant_id)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
# Execute the function
|
|
35
|
+
result = fn(*args, **kwargs)
|
|
36
|
+
|
|
37
|
+
# Mark span as successful
|
|
38
|
+
span.set_status(Status(StatusCode.OK))
|
|
39
|
+
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
except Exception as e:
|
|
43
|
+
# Record the error
|
|
44
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
45
|
+
span.record_exception(e)
|
|
46
|
+
raise
|
|
47
|
+
|
|
48
|
+
finally:
|
|
49
|
+
# Cleanup happens automatically when span exits
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def trace_function(operation_name=None):
|
|
54
|
+
"""
|
|
55
|
+
Decorator to trace individual functions within an agent run.
|
|
56
|
+
|
|
57
|
+
Usage:
|
|
58
|
+
@trace_function("search_memory")
|
|
59
|
+
def search_memory(query):
|
|
60
|
+
...
|
|
61
|
+
"""
|
|
62
|
+
def decorator(fn):
|
|
63
|
+
@functools.wraps(fn)
|
|
64
|
+
def wrapper(*args, **kwargs):
|
|
65
|
+
span_name = operation_name or f"{fn.__module__}.{fn.__name__}"
|
|
66
|
+
|
|
67
|
+
with _tracer.start_as_current_span(span_name) as span:
|
|
68
|
+
# Add context from AgentContext if available
|
|
69
|
+
run_id = AgentContext.get_run_id()
|
|
70
|
+
agent_name = AgentContext.get_agent_name()
|
|
71
|
+
|
|
72
|
+
if run_id:
|
|
73
|
+
span.set_attribute("agent.run_id", run_id)
|
|
74
|
+
if agent_name:
|
|
75
|
+
span.set_attribute("agent.name", agent_name)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
result = fn(*args, **kwargs)
|
|
79
|
+
span.set_status(Status(StatusCode.OK))
|
|
80
|
+
return result
|
|
81
|
+
except Exception as e:
|
|
82
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
83
|
+
span.record_exception(e)
|
|
84
|
+
raise
|
|
85
|
+
|
|
86
|
+
return wrapper
|
|
87
|
+
return decorator
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: puvinoise-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Independent OpenTelemetry SDK for AI agents (Anthropic, OpenAI, Ollama)
|
|
5
|
+
Author-email: Sarvesh Sahane <sarvesh.sahane@puvilabs.com>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://puvilabs.com
|
|
8
|
+
Keywords: puvinoise,opentelemetry,otel,tracing,llm,agents,observability
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Topic :: System :: Monitoring
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: opentelemetry-api>=1.20
|
|
20
|
+
Requires-Dist: opentelemetry-sdk>=1.20
|
|
21
|
+
Requires-Dist: opentelemetry-exporter-otlp>=1.20
|
|
22
|
+
Provides-Extra: anthropic
|
|
23
|
+
Requires-Dist: anthropic>=0.20; extra == "anthropic"
|
|
24
|
+
Provides-Extra: openai
|
|
25
|
+
Requires-Dist: openai>=1.0; extra == "openai"
|
|
26
|
+
Provides-Extra: all
|
|
27
|
+
Requires-Dist: anthropic>=0.20; extra == "all"
|
|
28
|
+
Requires-Dist: openai>=1.0; extra == "all"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
puvinoise/__init__.py
|
|
3
|
+
puvinoise/bootstrap.py
|
|
4
|
+
puvinoise/context.py
|
|
5
|
+
puvinoise/hooks.py
|
|
6
|
+
puvinoise/tracer.py
|
|
7
|
+
puvinoise_sdk.egg-info/PKG-INFO
|
|
8
|
+
puvinoise_sdk.egg-info/SOURCES.txt
|
|
9
|
+
puvinoise_sdk.egg-info/dependency_links.txt
|
|
10
|
+
puvinoise_sdk.egg-info/requires.txt
|
|
11
|
+
puvinoise_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
puvinoise
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "puvinoise-sdk"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Independent OpenTelemetry SDK for AI agents (Anthropic, OpenAI, Ollama)"
|
|
9
|
+
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Sarvesh Sahane", email = "sarvesh.sahane@puvilabs.com" }
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
license = { text = "Proprietary" }
|
|
17
|
+
|
|
18
|
+
keywords = ["puvinoise", "opentelemetry", "otel", "tracing", "llm", "agents", "observability"]
|
|
19
|
+
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"Topic :: Software Development :: Libraries",
|
|
28
|
+
"Topic :: System :: Monitoring",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Core deps — always required
|
|
32
|
+
dependencies = [
|
|
33
|
+
"opentelemetry-api>=1.20",
|
|
34
|
+
"opentelemetry-sdk>=1.20",
|
|
35
|
+
"opentelemetry-exporter-otlp>=1.20",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://puvilabs.com"
|
|
40
|
+
|
|
41
|
+
# Optional per-provider deps — install only what you need:
|
|
42
|
+
# pip install puvinoise[anthropic]
|
|
43
|
+
# pip install puvinoise[openai]
|
|
44
|
+
# pip install puvinoise[all]
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
anthropic = ["anthropic>=0.20"]
|
|
47
|
+
openai = ["openai>=1.0"]
|
|
48
|
+
all = ["anthropic>=0.20", "openai>=1.0"]
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.packages.find]
|
|
51
|
+
where = ["."]
|
|
52
|
+
include = ["puvinoise*"]
|