variably-sdk 2.4.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.
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/PKG-INFO +1 -1
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/pyproject.toml +1 -1
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/src/variably/__init__.py +2 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/src/variably/client.py +4 -0
- variably_sdk-2.6.0/src/variably/observe.py +257 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/src/variably/version.py +1 -1
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/src/variably_sdk.egg-info/PKG-INFO +1 -1
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/src/variably_sdk.egg-info/SOURCES.txt +1 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/README.md +0 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/setup.cfg +0 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/setup.py +0 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/src/variably/cache.py +0 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/src/variably/config.py +0 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/src/variably/errors.py +0 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/src/variably/http_client.py +0 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/src/variably/logger.py +0 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/src/variably/types.py +0 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/src/variably_sdk.egg-info/dependency_links.txt +0 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/src/variably_sdk.egg-info/requires.txt +0 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/src/variably_sdk.egg-info/top_level.txt +0 -0
- {variably_sdk-2.4.0 → variably_sdk-2.6.0}/tests/test_streaming.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "variably-sdk"
|
|
7
|
-
version = "2.
|
|
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
|
]
|
|
@@ -612,6 +612,7 @@ class VariablyClient:
|
|
|
612
612
|
latency_ms: Optional[int] = None,
|
|
613
613
|
prompt_tokens: Optional[int] = None,
|
|
614
614
|
completion_tokens: Optional[int] = None,
|
|
615
|
+
cost: Optional[float] = None,
|
|
615
616
|
metadata: Optional[Dict[str, Any]] = None,
|
|
616
617
|
) -> LogResult:
|
|
617
618
|
"""Log a prompt/response pair for 43-dimension evaluation (Observe Mode).
|
|
@@ -633,6 +634,7 @@ class VariablyClient:
|
|
|
633
634
|
latency_ms: LLM call latency in milliseconds.
|
|
634
635
|
prompt_tokens: Number of input tokens.
|
|
635
636
|
completion_tokens: Number of output tokens.
|
|
637
|
+
cost: Cost in USD for this LLM call.
|
|
636
638
|
metadata: Arbitrary key-value metadata.
|
|
637
639
|
|
|
638
640
|
Returns:
|
|
@@ -673,6 +675,8 @@ class VariablyClient:
|
|
|
673
675
|
data["prompt_tokens"] = prompt_tokens
|
|
674
676
|
if completion_tokens is not None:
|
|
675
677
|
data["completion_tokens"] = completion_tokens
|
|
678
|
+
if cost is not None:
|
|
679
|
+
data["cost"] = cost
|
|
676
680
|
if metadata:
|
|
677
681
|
data["metadata"] = metadata
|
|
678
682
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|