Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions python/sigil_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
from .client import Client
from .config import ApiConfig, AuthConfig, ClientConfig, EmbeddingCaptureConfig, GenerationExportConfig, default_config
from .context import (
content_capture_mode_from_context,
conversation_title_from_context,
conversation_id_from_context,
agent_name_from_context,
agent_version_from_context,
user_id_from_context,
with_agent_name,
with_agent_version,
with_content_capture_mode,
with_conversation_id,
with_conversation_title,
with_user_id,
Expand All @@ -27,6 +29,7 @@
from .models import (
Artifact,
ArtifactKind,
ContentCaptureMode,
ConversationRating,
ConversationRatingInput,
ConversationRatingSummary,
Expand Down Expand Up @@ -67,6 +70,7 @@
"Client",
"ClientConfig",
"ClientShutdownError",
"ContentCaptureMode",
"ConversationRating",
"ConversationRatingInput",
"ConversationRatingSummary",
Expand Down Expand Up @@ -101,6 +105,7 @@
"agent_name_from_context",
"agent_version_from_context",
"assistant_text_message",
"content_capture_mode_from_context",
"conversation_id_from_context",
"conversation_title_from_context",
"text_part",
Expand All @@ -112,6 +117,7 @@
"user_text_message",
"with_agent_name",
"with_agent_version",
"with_content_capture_mode",
"with_conversation_id",
"with_conversation_title",
"with_user_id",
Expand Down
144 changes: 139 additions & 5 deletions python/sigil_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import re
import secrets
import threading
from typing import Any, Optional
from typing import Any, Callable, Optional
from urllib import error as urllib_error
from urllib import parse as urllib_parse
from urllib import request as urllib_request
Expand All @@ -22,9 +22,11 @@
from .context import (
agent_name_from_context,
agent_version_from_context,
content_capture_mode_from_context,
conversation_id_from_context,
conversation_title_from_context,
user_id_from_context,
with_content_capture_mode,
)
from .errors import (
ClientShutdownError,
Expand All @@ -36,6 +38,7 @@
)
from .exporters import GRPCGenerationExporter, HTTPGenerationExporter, NoopGenerationExporter
from .models import (
ContentCaptureMode,
ConversationRating,
ConversationRatingInput,
ConversationRatingSummary,
Expand Down Expand Up @@ -121,6 +124,8 @@
_metric_token_type_cache_creation = "cache_creation"
_metric_token_type_reasoning = "reasoning"

_metadata_key_content_capture_mode = "sigil.sdk.content_capture_mode"

_status_code_pattern = re.compile(r"\b([1-5][0-9][0-9])\b")
_instrumentation_name = "github.com/grafana/sigil/sdks/python"
_sdk_name = "sdk-python"
Expand All @@ -129,6 +134,89 @@
_metadata_legacy_user_id_key = "user.id"


def _resolve_content_capture_mode(override: ContentCaptureMode, fallback: ContentCaptureMode) -> ContentCaptureMode:
"""Returns the effective mode from an override and a fallback. DEFAULT falls through."""
if override != ContentCaptureMode.DEFAULT:
return override
return fallback


def _resolve_client_content_capture_mode(mode: ContentCaptureMode) -> ContentCaptureMode:
"""Resolves client-level mode. DEFAULT → NO_TOOL_CONTENT for backward compat."""
if mode == ContentCaptureMode.DEFAULT:
return ContentCaptureMode.NO_TOOL_CONTENT
return mode


def _call_content_capture_resolver(
resolver: Callable | None,
metadata: dict[str, Any] | None,
) -> ContentCaptureMode:
"""Invokes resolver callback safely. Returns DEFAULT when nil. Exceptions → METADATA_ONLY."""
if resolver is None:
return ContentCaptureMode.DEFAULT
try:
return resolver(metadata)
except Exception: # noqa: BLE001
return ContentCaptureMode.METADATA_ONLY


def _should_include_tool_content(
tool_mode: ContentCaptureMode,
ctx_mode: ContentCaptureMode | None,
ctx_set: bool,
client_default: ContentCaptureMode,
legacy_include: bool,
) -> bool:
"""Determines whether tool content should be included in span attributes."""
resolved = _resolve_client_content_capture_mode(client_default)
if ctx_set:
resolved = ctx_mode
if tool_mode != ContentCaptureMode.DEFAULT:
resolved = tool_mode
if resolved == ContentCaptureMode.METADATA_ONLY:
return False
if resolved == ContentCaptureMode.FULL:
return True
# NO_TOOL_CONTENT / DEFAULT: honor legacy include_content opt-in.
return legacy_include


def _stamp_content_capture_metadata(generation: Generation, mode: ContentCaptureMode) -> None:
"""Sets the content capture mode marker on the generation."""
generation.metadata[_metadata_key_content_capture_mode] = mode.value


def _strip_content(generation: Generation, error_category: str) -> None:
"""Strips sensitive content from a generation while preserving structure."""
generation.system_prompt = ""
generation.artifacts = []

if generation.call_error != "":
generation.call_error = error_category if error_category else "sdk_error"
generation.metadata.pop("call_error", None)

for message in generation.input:
_strip_message_content(message)
for message in generation.output:
_strip_message_content(message)
for tool in generation.tools:
tool.description = ""
tool.input_schema_json = b""


def _strip_message_content(message: Message) -> None:
"""Strips text content from all parts of a message."""
for part in message.parts:
part.text = ""
part.thinking = ""
if part.tool_call is not None:
part.tool_call.input_json = b""
if part.tool_result is not None:
part.tool_result.content = ""
part.tool_result.content_json = b""


class Client:
"""Sigil client that records generations, tool spans, and exports in background."""

Expand Down Expand Up @@ -260,12 +348,20 @@ def start_tool_execution(self, start: ToolExecutionStart) -> "ToolExecutionRecor
)
_set_tool_span_attributes(span, seed)

# Resolve content capture: per-tool > context (parent generation) > resolver > client default.
resolver_mode = _call_content_capture_resolver(self._config.content_capture_resolver, {})
effective_client_default = _resolve_content_capture_mode(resolver_mode, self._config.content_capture)
ctx_mode, ctx_set = content_capture_mode_from_context()
include_content = _should_include_tool_content(
seed.content_capture, ctx_mode, ctx_set, effective_client_default, seed.include_content
)

return ToolExecutionRecorder(
client=self,
seed=seed,
span=span,
started_at=started_at,
include_content=seed.include_content,
include_content=include_content,
)

def submit_conversation_rating(
Expand All @@ -283,6 +379,22 @@ def submit_conversation_rating(
if len(normalized_conversation_id) > _max_rating_conversation_id_len:
raise ValidationError("sigil conversation rating validation failed: conversation_id is too long")

# Strip comment when MetadataOnly is in effect.
resolver_mode = _call_content_capture_resolver(self._config.content_capture_resolver, rating.metadata)
effective_mode = _resolve_content_capture_mode(
resolver_mode, _resolve_client_content_capture_mode(self._config.content_capture)
)
if effective_mode == ContentCaptureMode.METADATA_ONLY:
rating = ConversationRatingInput(
rating_id=rating.rating_id,
rating=rating.rating,
comment="",
metadata=dict(rating.metadata),
generation_id=rating.generation_id,
rater_id=rating.rater_id,
source=rating.source,
)

normalized_rating = _normalize_conversation_rating_input(rating)
endpoint = _conversation_rating_endpoint(
self._config.api.endpoint,
Expand Down Expand Up @@ -434,11 +546,19 @@ def _start_generation(self, start: GenerationStart, default_mode: GenerationMode
),
)

# Resolve content capture mode: per-recording > resolver > client default.
resolver_mode = _call_content_capture_resolver(self._config.content_capture_resolver, seed.metadata)
client_mode = _resolve_client_content_capture_mode(
_resolve_content_capture_mode(resolver_mode, self._config.content_capture)
)
cc_mode = _resolve_content_capture_mode(seed.content_capture, client_mode)

return GenerationRecorder(
client=self,
seed=seed,
span=span,
started_at=started_at,
_content_capture_mode=cc_mode,
)

def _enqueue_generation(self, generation: Generation) -> None:
Expand Down Expand Up @@ -672,6 +792,7 @@ class GenerationRecorder:
span: Span
started_at: datetime

_content_capture_mode: ContentCaptureMode = ContentCaptureMode.NO_TOOL_CONTENT
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
_ended: bool = False
_call_error: Exception | None = None
Expand All @@ -680,14 +801,22 @@ class GenerationRecorder:
_last_generation: Generation | None = None
_final_error: Exception | None = None
_first_token_at: datetime | None = None
_ctx_manager: Any = field(default=None, init=False, repr=False)

def __enter__(self) -> "GenerationRecorder":
self._ctx_manager = with_content_capture_mode(self._content_capture_mode)
self._ctx_manager.__enter__()
return self

def __exit__(self, exc_type, exc, _tb) -> bool:
if exc is not None and self._call_error is None:
self.set_call_error(exc)
self.end()
try:
if exc is not None and self._call_error is None:
self.set_call_error(exc)
self.end()
finally:
if self._ctx_manager is not None:
self._ctx_manager.__exit__(None, None, None)
self._ctx_manager = None
return False

def set_call_error(self, error: Exception) -> None:
Expand Down Expand Up @@ -735,6 +864,11 @@ def end(self) -> None:
generation = self._normalize_generation(result, completed_at, call_error)
_apply_trace_context_from_span(self.span, generation)

_stamp_content_capture_metadata(generation, self._content_capture_mode)
if self._content_capture_mode == ContentCaptureMode.METADATA_ONLY:
error_cat = _error_category_from_exception(call_error, fallback_sdk=True) if call_error else ""
_strip_content(generation, error_cat)

self.span.update_name(_generation_span_name(generation.operation_name, generation.model.name))
_set_generation_span_attributes(self.span, generation)

Expand Down
4 changes: 3 additions & 1 deletion python/sigil_sdk/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from opentelemetry.trace import Tracer

from .exporters.base import GenerationExporter
from .models import utc_now
from .models import ContentCaptureMode, utc_now

TENANT_HEADER = "X-Scope-OrgID"
AUTHORIZATION_HEADER = "Authorization"
Expand Down Expand Up @@ -71,6 +71,8 @@ class ClientConfig:
generation_export: GenerationExportConfig = field(default_factory=GenerationExportConfig)
api: ApiConfig = field(default_factory=ApiConfig)
embedding_capture: EmbeddingCaptureConfig = field(default_factory=EmbeddingCaptureConfig)
content_capture: ContentCaptureMode = ContentCaptureMode.DEFAULT
content_capture_resolver: Optional[Callable[[dict], ContentCaptureMode]] = None
tracer: Optional[Tracer] = None
meter: Optional[Meter] = None
logger: Optional[logging.Logger] = None
Expand Down
28 changes: 27 additions & 1 deletion python/sigil_sdk/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@

from contextlib import contextmanager
import contextvars
from typing import Iterator, Optional
from typing import TYPE_CHECKING, Iterator, Optional

if TYPE_CHECKING:
from .models import ContentCaptureMode


_conversation_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("sigil_conversation_id", default=None)
_conversation_title: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("sigil_conversation_title", default=None)
_user_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("sigil_user_id", default=None)
_agent_name: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("sigil_agent_name", default=None)
_agent_version: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("sigil_agent_version", default=None)
_content_capture_mode: contextvars.ContextVar[Optional["ContentCaptureMode"]] = contextvars.ContextVar(
"sigil_content_capture_mode", default=None
)


@contextmanager
Expand Down Expand Up @@ -97,3 +103,23 @@ def user_id_from_context() -> Optional[str]:
"""Returns the current user id from context variables."""

return _user_id.get()


@contextmanager
def with_content_capture_mode(mode: "ContentCaptureMode") -> Iterator[None]:
"""Sets the content capture mode within a context block."""

token = _content_capture_mode.set(mode)
try:
yield
finally:
_content_capture_mode.reset(token)


def content_capture_mode_from_context() -> tuple[Optional["ContentCaptureMode"], bool]:
"""Returns (mode, is_set) from context. Returns (None, False) if not set."""

mode = _content_capture_mode.get()
if mode is None:
return None, False
return mode, True
11 changes: 11 additions & 0 deletions python/sigil_sdk/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ class PartKind(str, Enum):
TOOL_RESULT = "tool_result"


class ContentCaptureMode(str, Enum):
"""Controls what content is included in exported generation payloads and OTel span attributes."""

DEFAULT = "default"
FULL = "full"
NO_TOOL_CONTENT = "no_tool_content"
METADATA_ONLY = "metadata_only"


class ArtifactKind(str, Enum):
"""Allowed raw artifact kinds."""

Expand Down Expand Up @@ -175,6 +184,7 @@ class GenerationStart:
tool_choice: Optional[str] = None
thinking_enabled: Optional[bool] = None
tools: list[ToolDefinition] = field(default_factory=list)
content_capture: ContentCaptureMode = ContentCaptureMode.DEFAULT
tags: dict[str, str] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
started_at: Optional[datetime] = None
Expand Down Expand Up @@ -256,6 +266,7 @@ class ToolExecutionStart:
request_model: str = ""
request_provider: str = ""
include_content: bool = False
content_capture: ContentCaptureMode = ContentCaptureMode.DEFAULT
started_at: Optional[datetime] = None


Expand Down
Loading
Loading