variably-sdk 2.5.0__tar.gz → 2.6.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.
Files changed (21) hide show
  1. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/PKG-INFO +1 -1
  2. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/pyproject.toml +1 -1
  3. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/src/variably/__init__.py +2 -0
  4. variably_sdk-2.6.0/src/variably/observe.py +257 -0
  5. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/src/variably/version.py +1 -1
  6. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/src/variably_sdk.egg-info/PKG-INFO +1 -1
  7. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/src/variably_sdk.egg-info/SOURCES.txt +1 -0
  8. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/README.md +0 -0
  9. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/setup.cfg +0 -0
  10. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/setup.py +0 -0
  11. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/src/variably/cache.py +0 -0
  12. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/src/variably/client.py +0 -0
  13. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/src/variably/config.py +0 -0
  14. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/src/variably/errors.py +0 -0
  15. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/src/variably/http_client.py +0 -0
  16. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/src/variably/logger.py +0 -0
  17. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/src/variably/types.py +0 -0
  18. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/src/variably_sdk.egg-info/dependency_links.txt +0 -0
  19. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/src/variably_sdk.egg-info/requires.txt +0 -0
  20. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/src/variably_sdk.egg-info/top_level.txt +0 -0
  21. {variably_sdk-2.5.0 → variably_sdk-2.6.0}/tests/test_streaming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: variably-sdk
3
- Version: 2.5.0
3
+ Version: 2.6.0
4
4
  Summary: Official Python SDK for Variably feature flags, LLM experimentation, and prompt optimization platform
5
5
  Author: Variably
6
6
  Author-email: Variably <support@variably.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "variably-sdk"
7
- version = "2.5.0"
7
+ version = "2.6.0"
8
8
  description = "Official Python SDK for Variably feature flags, LLM experimentation, and prompt optimization platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
@@ -16,6 +16,7 @@ from .errors import (
16
16
  TimeoutError,
17
17
  ConfigurationError,
18
18
  )
19
+ from .observe import observe
19
20
  from .version import __version__
20
21
 
21
22
  __all__ = [
@@ -35,5 +36,6 @@ __all__ = [
35
36
  "RateLimitError",
36
37
  "TimeoutError",
37
38
  "ConfigurationError",
39
+ "observe",
38
40
  "__version__",
39
41
  ]
@@ -0,0 +1,257 @@
1
+ """
2
+ Variably Observe Mode — drop-in integration for multi-dimension LLM evaluation.
3
+
4
+ Usage:
5
+ from variably import observe
6
+
7
+ observe(prompt=user_query, response=llm_response, provider_response=completion)
8
+
9
+ Never raises. Returns {"observation_id": "...", "status": "queued"} or None.
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import threading
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from .client import VariablyClient
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Lazy singleton client
21
+ # ---------------------------------------------------------------------------
22
+
23
+ _client: Optional[VariablyClient] = None
24
+ _client_lock = threading.Lock()
25
+
26
+
27
+ def _get_client() -> Optional[VariablyClient]:
28
+ """Return a shared VariablyClient, creating it on first call. Thread-safe."""
29
+ global _client
30
+ if _client is not None:
31
+ return _client
32
+
33
+ with _client_lock:
34
+ # Double-check after acquiring lock
35
+ if _client is not None:
36
+ return _client
37
+
38
+ # Try loading .env if python-dotenv is available
39
+ try:
40
+ from dotenv import load_dotenv # type: ignore[import-untyped]
41
+
42
+ load_dotenv()
43
+ except ImportError:
44
+ pass
45
+
46
+ api_key = os.environ.get("VARIABLY_API_KEY")
47
+ if not api_key:
48
+ return None
49
+
50
+ base_url = os.environ.get(
51
+ "VARIABLY_BASE_URL", "https://api.variably.tech"
52
+ )
53
+
54
+ try:
55
+ _client = VariablyClient(
56
+ {
57
+ "api_key": api_key,
58
+ "base_url": base_url,
59
+ "log_level": os.environ.get("VARIABLY_LOG_LEVEL", "WARNING"),
60
+ }
61
+ )
62
+ return _client
63
+ except Exception as exc:
64
+ print(
65
+ f"[variably.observe] Failed to initialize client: {exc}",
66
+ file=sys.stderr,
67
+ )
68
+ return None
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Provider response auto-extraction (duck-typed, no SDK imports needed)
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def _extract_from_provider(provider_response: Any) -> Dict[str, Any]:
77
+ """Extract model, tokens, and provider from a raw LLM response object.
78
+
79
+ Supports OpenAI, Anthropic, and Google/Gemini response shapes via duck typing.
80
+ No provider SDK imports required.
81
+ """
82
+ extracted: Dict[str, Any] = {}
83
+
84
+ if provider_response is None:
85
+ return extracted
86
+
87
+ # --- Model name ---
88
+ model = getattr(provider_response, "model", None)
89
+ if model:
90
+ extracted["model"] = str(model)
91
+
92
+ # --- OpenAI-style: resp.usage.prompt_tokens / completion_tokens ---
93
+ usage = getattr(provider_response, "usage", None)
94
+ if usage is not None:
95
+ pt = getattr(usage, "prompt_tokens", None)
96
+ ct = getattr(usage, "completion_tokens", None)
97
+ if pt is not None:
98
+ extracted["prompt_tokens"] = int(pt)
99
+ if ct is not None:
100
+ extracted["completion_tokens"] = int(ct)
101
+
102
+ # Anthropic-style: resp.usage.input_tokens / output_tokens
103
+ if pt is None:
104
+ it = getattr(usage, "input_tokens", None)
105
+ if it is not None:
106
+ extracted["prompt_tokens"] = int(it)
107
+ if ct is None:
108
+ ot = getattr(usage, "output_tokens", None)
109
+ if ot is not None:
110
+ extracted["completion_tokens"] = int(ot)
111
+
112
+ # --- Google/Gemini-style: resp.usage_metadata ---
113
+ usage_meta = getattr(provider_response, "usage_metadata", None)
114
+ if usage_meta is not None and usage is None:
115
+ ptc = getattr(usage_meta, "prompt_token_count", None)
116
+ ctc = getattr(usage_meta, "candidates_token_count", None)
117
+ if ptc is not None:
118
+ extracted["prompt_tokens"] = int(ptc)
119
+ if ctc is not None:
120
+ extracted["completion_tokens"] = int(ctc)
121
+
122
+ # --- Auto-detect provider from model name ---
123
+ m = extracted.get("model", "")
124
+ if m:
125
+ m_lower = m.lower()
126
+ if any(k in m_lower for k in ("gpt-", "o1-", "o3-", "chatgpt")):
127
+ extracted["provider"] = "openai"
128
+ elif "claude" in m_lower:
129
+ extracted["provider"] = "anthropic"
130
+ elif any(k in m_lower for k in ("gemini", "palm")):
131
+ extracted["provider"] = "google"
132
+
133
+ return extracted
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Public API
138
+ # ---------------------------------------------------------------------------
139
+
140
+
141
+ def observe(
142
+ prompt: str,
143
+ response: str,
144
+ *,
145
+ # Auto-extraction from provider response object
146
+ provider_response: Any = None,
147
+ # Manual overrides (take precedence over auto-extracted)
148
+ model: Optional[str] = None,
149
+ provider: Optional[str] = None,
150
+ latency_ms: Optional[int] = None,
151
+ prompt_tokens: Optional[int] = None,
152
+ completion_tokens: Optional[int] = None,
153
+ cost: Optional[float] = None,
154
+ # RAG context
155
+ reference_materials: Optional[List[Dict[str, Any]]] = None,
156
+ retrieval_query: Optional[str] = None,
157
+ # Multi-turn context
158
+ conversation_history: Optional[List[Dict[str, str]]] = None,
159
+ # Metadata
160
+ tags: Optional[List[str]] = None,
161
+ user_id: Optional[str] = None,
162
+ session_id: Optional[str] = None,
163
+ metadata: Optional[Dict[str, Any]] = None,
164
+ ) -> Optional[Dict[str, Any]]:
165
+ """Log a prompt/response pair to Variably for multi-dimension evaluation.
166
+
167
+ Never raises. Returns ``{"observation_id": "...", "status": "queued"}``
168
+ on success, or ``None`` on any error (missing API key, network failure, etc.).
169
+
170
+ Args:
171
+ prompt: The user's input / question sent to the LLM.
172
+ response: The LLM's generated response text.
173
+ provider_response: Raw OpenAI / Anthropic / Google response object.
174
+ Model name, token counts, and provider are auto-extracted via
175
+ duck typing — no provider SDK imports needed.
176
+ model: Model name override (e.g. ``"gpt-4o"``).
177
+ Takes precedence over auto-extracted value.
178
+ provider: Provider override (e.g. ``"openai"``).
179
+ Takes precedence over auto-detected value.
180
+ latency_ms: Response generation time in milliseconds.
181
+ prompt_tokens: Input token count override.
182
+ Takes precedence over auto-extracted value.
183
+ completion_tokens: Output token count override.
184
+ Takes precedence over auto-extracted value.
185
+ cost: Cost in USD for this LLM call.
186
+ reference_materials: RAG chunks for grounding / hallucination scoring.
187
+ Each dict should have ``"id"``, ``"content"``, and optionally
188
+ ``"source"``.
189
+ retrieval_query: The query sent to your retriever (enables retrieval
190
+ quality scoring).
191
+ conversation_history: Prior turns for coherence scoring.
192
+ Each dict should have ``"role"`` and ``"content"``.
193
+ tags: Grouping labels, e.g. ``["production", "rag"]``.
194
+ user_id: Your end-user's ID.
195
+ session_id: Conversation session ID (groups multi-turn observations).
196
+ metadata: Arbitrary key-value data.
197
+
198
+ Returns:
199
+ Dict with ``observation_id`` and ``status`` on success, or ``None``.
200
+ """
201
+ try:
202
+ client = _get_client()
203
+ if client is None:
204
+ return None
205
+
206
+ # Auto-extract from provider response; manual params override
207
+ auto = _extract_from_provider(provider_response)
208
+
209
+ resolved_model = model or auto.get("model")
210
+ resolved_provider = provider or auto.get("provider")
211
+ resolved_prompt_tokens = (
212
+ prompt_tokens if prompt_tokens is not None else auto.get("prompt_tokens")
213
+ )
214
+ resolved_completion_tokens = (
215
+ completion_tokens
216
+ if completion_tokens is not None
217
+ else auto.get("completion_tokens")
218
+ )
219
+
220
+ result = client.log(
221
+ prompt=prompt,
222
+ response=response,
223
+ model=resolved_model,
224
+ provider=resolved_provider,
225
+ latency_ms=latency_ms,
226
+ prompt_tokens=resolved_prompt_tokens,
227
+ completion_tokens=resolved_completion_tokens,
228
+ cost=cost,
229
+ reference_materials=reference_materials,
230
+ retrieval_query=retrieval_query,
231
+ conversation_history=conversation_history,
232
+ tags=tags,
233
+ user_id=user_id,
234
+ session_id=session_id,
235
+ metadata=metadata,
236
+ )
237
+
238
+ return {
239
+ "observation_id": result.observation_id,
240
+ "status": result.status,
241
+ }
242
+
243
+ except Exception as exc:
244
+ print(f"[variably.observe] {exc}", file=sys.stderr)
245
+ return None
246
+
247
+
248
+ def reset() -> None:
249
+ """Reset the singleton client. Useful for testing or reconfiguration."""
250
+ global _client
251
+ with _client_lock:
252
+ if _client is not None:
253
+ try:
254
+ _client.close()
255
+ except Exception:
256
+ pass
257
+ _client = None
@@ -1,3 +1,3 @@
1
1
  """Version information for Variably SDK."""
2
2
 
3
- __version__ = "2.3.0"
3
+ __version__ = "2.6.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: variably-sdk
3
- Version: 2.5.0
3
+ Version: 2.6.0
4
4
  Summary: Official Python SDK for Variably feature flags, LLM experimentation, and prompt optimization platform
5
5
  Author: Variably
6
6
  Author-email: Variably <support@variably.com>
@@ -8,6 +8,7 @@ src/variably/config.py
8
8
  src/variably/errors.py
9
9
  src/variably/http_client.py
10
10
  src/variably/logger.py
11
+ src/variably/observe.py
11
12
  src/variably/types.py
12
13
  src/variably/version.py
13
14
  src/variably_sdk.egg-info/PKG-INFO
File without changes
File without changes
File without changes