variably-sdk 2.9.0__tar.gz → 2.11.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 (25) hide show
  1. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/PKG-INFO +4 -5
  2. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/pyproject.toml +5 -1
  3. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/client.py +56 -0
  4. variably_sdk-2.11.0/src/variably/integrations/__init__.py +15 -0
  5. variably_sdk-2.11.0/src/variably/integrations/_common.py +88 -0
  6. variably_sdk-2.11.0/src/variably/integrations/langgraph.py +202 -0
  7. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/observe.py +27 -0
  8. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/version.py +1 -1
  9. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably_sdk.egg-info/PKG-INFO +4 -5
  10. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably_sdk.egg-info/SOURCES.txt +4 -1
  11. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably_sdk.egg-info/requires.txt +3 -0
  12. variably_sdk-2.11.0/tests/test_integrations_langgraph.py +131 -0
  13. variably_sdk-2.9.0/setup.py +0 -23
  14. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/README.md +0 -0
  15. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/setup.cfg +0 -0
  16. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/__init__.py +0 -0
  17. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/cache.py +0 -0
  18. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/config.py +0 -0
  19. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/errors.py +0 -0
  20. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/http_client.py +0 -0
  21. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/logger.py +0 -0
  22. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/types.py +0 -0
  23. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably_sdk.egg-info/dependency_links.txt +0 -0
  24. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably_sdk.egg-info/top_level.txt +0 -0
  25. {variably_sdk-2.9.0 → variably_sdk-2.11.0}/tests/test_streaming.py +0 -0
@@ -1,8 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: variably-sdk
3
- Version: 2.9.0
3
+ Version: 2.11.0
4
4
  Summary: Official Python SDK for Variably feature flags, LLM experimentation, and prompt optimization platform
5
- Author: Variably
6
5
  Author-email: Variably <support@variably.com>
7
6
  License: MIT
8
7
  Project-URL: Homepage, https://github.com/variably/variably-python-sdk
@@ -23,10 +22,12 @@ Classifier: Programming Language :: Python :: 3.11
23
22
  Classifier: Programming Language :: Python :: 3.12
24
23
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
24
  Classifier: Topic :: Internet :: WWW/HTTP
26
- Requires-Python: >=3.8
25
+ Requires-Python: >=3.7
27
26
  Description-Content-Type: text/markdown
28
27
  Requires-Dist: requests>=2.25.0
29
28
  Requires-Dist: typing-extensions>=3.7.4; python_version < "3.8"
29
+ Provides-Extra: langgraph
30
+ Requires-Dist: langgraph>=0.1; extra == "langgraph"
30
31
  Provides-Extra: dev
31
32
  Requires-Dist: pytest>=6.0; extra == "dev"
32
33
  Requires-Dist: pytest-asyncio>=0.18.0; extra == "dev"
@@ -40,8 +41,6 @@ Provides-Extra: test
40
41
  Requires-Dist: pytest>=6.0; extra == "test"
41
42
  Requires-Dist: pytest-cov>=2.10; extra == "test"
42
43
  Requires-Dist: responses>=0.18.0; extra == "test"
43
- Dynamic: author
44
- Dynamic: requires-python
45
44
 
46
45
  # Variably Python SDK
47
46
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "variably-sdk"
7
- version = "2.9.0"
7
+ version = "2.11.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"
@@ -35,6 +35,10 @@ dependencies = [
35
35
  ]
36
36
 
37
37
  [project.optional-dependencies]
38
+ # Framework integrations (Task 42.4). Install e.g. `pip install variably-sdk[langgraph]`.
39
+ langgraph = [
40
+ "langgraph>=0.1",
41
+ ]
38
42
  dev = [
39
43
  "pytest>=6.0",
40
44
  "pytest-asyncio>=0.18.0",
@@ -678,6 +678,11 @@ class VariablyClient:
678
678
  # Multi-turn / Session context
679
679
  session_id: Optional[str] = None, # Groups conversation turns into sessions
680
680
  conversation_history: Optional[List[Dict[str, Any]]] = None, # Prior turns for coherence scoring
681
+ # Multi-agent context — see docs/improvements/42.1-implementation-plan.md
682
+ agent_name: Optional[str] = None, # This step's agent, within a session_id trace
683
+ is_aggregator: bool = False, # True on the final step that aggregates the trace
684
+ tools_called: Optional[List[Dict[str, Any]]] = None, # [{"name","arguments","output"}] actually called
685
+ expected_tools: Optional[List[Dict[str, Any]]] = None, # golden tools for deterministic trajectory scoring
681
686
  # User & organization context
682
687
  user_id: Optional[str] = None, # End-user ID for per-user analytics
683
688
  tags: Optional[List[str]] = None, # Grouping labels, e.g. ["production", "rag"]
@@ -689,6 +694,8 @@ class VariablyClient:
689
694
  prompt_tokens: Optional[int] = None, # Input token count
690
695
  completion_tokens: Optional[int] = None, # Output token count
691
696
  cost: Optional[float] = None, # Cost in USD
697
+ # Turn-type marker — see _SUBMIT_RESPONSE_KINDS for allowed values.
698
+ kind: Optional[str] = None,
692
699
  ) -> LogResult:
693
700
  """Log a prompt/response pair for multi-dimensional evaluation (Observe Mode).
694
701
 
@@ -722,6 +729,45 @@ class VariablyClient:
722
729
  # Normalize prompt to string
723
730
  prompt_str = prompt if isinstance(prompt, str) else str(prompt)
724
731
 
732
+ # Validate kind and inject the marker into tags + metadata so the
733
+ # dashboard can suppress grounding/hallucination/attribution
734
+ # scores on non-generative turns. See submit_response for the
735
+ # full rationale; the same logic applies in Observe Mode.
736
+ if kind is not None and kind not in self._SUBMIT_RESPONSE_KINDS:
737
+ raise ConfigurationError(
738
+ f"kind must be one of {self._SUBMIT_RESPONSE_KINDS}, got {kind!r}",
739
+ "kind",
740
+ )
741
+ if kind and kind != "generative":
742
+ kind_tag = f"kind:{kind}"
743
+ tags = list(tags) if tags else []
744
+ if kind_tag not in tags:
745
+ tags.append(kind_tag)
746
+ metadata = dict(metadata) if metadata else {}
747
+ metadata.setdefault("kind", kind)
748
+
749
+ # Validate multi-agent inputs up front (mirrors server-side checks) so
750
+ # a malformed trace fails fast at the call site rather than silently
751
+ # producing an unscoreable session.
752
+ if is_aggregator and not agent_name:
753
+ raise ConfigurationError(
754
+ "is_aggregator=True requires a non-empty agent_name",
755
+ "agent_name",
756
+ )
757
+ if is_aggregator and not session_id:
758
+ raise ConfigurationError(
759
+ "is_aggregator=True requires a session_id to assemble the multi-agent trace",
760
+ "session_id",
761
+ )
762
+ for _label, _tools in (("tools_called", tools_called), ("expected_tools", expected_tools)):
763
+ if _tools:
764
+ for _t in _tools:
765
+ if not isinstance(_t, dict) or not _t.get("name"):
766
+ raise ConfigurationError(
767
+ f"{_label} entries must be dicts with a non-empty 'name'",
768
+ _label,
769
+ )
770
+
725
771
  data: Dict[str, Any] = {
726
772
  "prompt": prompt_str,
727
773
  "response": response,
@@ -744,6 +790,14 @@ class VariablyClient:
744
790
  data["user_id"] = user_id
745
791
  if session_id:
746
792
  data["session_id"] = session_id
793
+ if agent_name:
794
+ data["agent_name"] = agent_name
795
+ if is_aggregator:
796
+ data["is_aggregator"] = True
797
+ if tools_called:
798
+ data["tools_called"] = tools_called
799
+ if expected_tools:
800
+ data["expected_tools"] = expected_tools
747
801
  if provider:
748
802
  data["provider"] = provider
749
803
  if model:
@@ -758,6 +812,8 @@ class VariablyClient:
758
812
  data["cost"] = cost
759
813
  if metadata:
760
814
  data["metadata"] = metadata
815
+ if kind:
816
+ data["kind"] = kind
761
817
 
762
818
  try:
763
819
  result = self.http_client.log_observation(data)
@@ -0,0 +1,15 @@
1
+ """Framework auto-instrumentation for variА/Bly (improvement #42, Task 42.4).
2
+
3
+ Per-framework integrations that wire multi-agent workflows into variА/Bly's
4
+ deterministic cross-step evaluation with little or no per-node code:
5
+
6
+ from variably.integrations.langgraph import instrument
7
+ instrument(graph, session_id_key="session_id")
8
+
9
+ Each integration maps the framework's node/agent identity to ``agent_name``,
10
+ detects the terminal node(s) as ``is_aggregator``, pulls session id + context
11
+ from the framework's state, and logs via ``variably.observe`` — always
12
+ best-effort, never raising into the customer's workflow.
13
+
14
+ Install extras per framework, e.g. ``pip install variably-sdk[langgraph]``.
15
+ """
@@ -0,0 +1,88 @@
1
+ """Shared helpers for variА/Bly framework integrations (Task 42.4).
2
+
3
+ Best-effort extraction of the fields the evaluator needs (response text, an
4
+ optional query/prompt, and the session id) from a framework's state / node
5
+ return value. Everything here is defensive: extraction never raises, so a
6
+ framework whose state shape we don't recognise degrades to an empty string
7
+ rather than breaking the customer's workflow.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from typing import Any, Optional
14
+
15
+ # Keys we look for when extracting a human-readable response from a node's
16
+ # return value, in priority order.
17
+ _RESPONSE_KEYS = ("response", "output", "answer", "result", "text", "content")
18
+ # Keys we look for when extracting the user query/prompt from graph state.
19
+ _QUERY_KEYS = ("query", "question", "input", "prompt", "user_input")
20
+
21
+
22
+ def warn(msg: str) -> None:
23
+ print(f"[variably.integrations] {msg}", file=sys.stderr)
24
+
25
+
26
+ def _message_text(msgs: Any) -> str:
27
+ """Extract text from a LangChain-style messages list (or single message)."""
28
+ try:
29
+ last = msgs[-1] if isinstance(msgs, (list, tuple)) and msgs else msgs
30
+ content = getattr(last, "content", None)
31
+ if isinstance(content, str):
32
+ return content
33
+ if isinstance(last, dict) and isinstance(last.get("content"), str):
34
+ return last["content"]
35
+ except Exception:
36
+ pass
37
+ return ""
38
+
39
+
40
+ def extract_response_text(update: Any) -> str:
41
+ """Best-effort extraction of response text from a node's return value."""
42
+ try:
43
+ if update is None:
44
+ return ""
45
+ if isinstance(update, str):
46
+ return update
47
+ if isinstance(update, dict):
48
+ for k in _RESPONSE_KEYS:
49
+ v = update.get(k)
50
+ if isinstance(v, str) and v.strip():
51
+ return v
52
+ if "messages" in update:
53
+ text = _message_text(update["messages"])
54
+ if text:
55
+ return text
56
+ for v in update.values(): # fallback: first non-empty string
57
+ if isinstance(v, str) and v.strip():
58
+ return v
59
+ return ""
60
+ # objects: try common attributes
61
+ for k in _RESPONSE_KEYS:
62
+ v = getattr(update, k, None)
63
+ if isinstance(v, str) and v.strip():
64
+ return v
65
+ return str(update)
66
+ except Exception:
67
+ return ""
68
+
69
+
70
+ def extract_query(state: Any) -> str:
71
+ """Best-effort extraction of the user query/prompt from graph state."""
72
+ try:
73
+ get = state.get if isinstance(state, dict) else (lambda k, d=None: getattr(state, k, d))
74
+ for k in _QUERY_KEYS:
75
+ v = get(k)
76
+ if isinstance(v, str) and v.strip():
77
+ return v
78
+ except Exception:
79
+ pass
80
+ return ""
81
+
82
+
83
+ def extract_session_id(state: Any, key: str) -> Optional[str]:
84
+ try:
85
+ v = state.get(key) if isinstance(state, dict) else getattr(state, key, None)
86
+ return str(v) if v else None
87
+ except Exception:
88
+ return None
@@ -0,0 +1,202 @@
1
+ """LangGraph auto-instrumentation for variА/Bly (Task 42.4).
2
+
3
+ Two entry points:
4
+
5
+ 1. ``instrument(graph, session_id_key=...)`` — zero-touch. Call it once after
6
+ you've added nodes/edges (before ``.compile()``). Wraps every node so each
7
+ execution logs to variА/Bly: ``agent_name`` = node name, ``is_aggregator``
8
+ on the graph's terminal node(s), ``session_id`` pulled from graph state.
9
+
10
+ 2. ``@observe_node(...)`` — an explicit per-node decorator. Version-independent
11
+ and the most robust option if ``instrument`` can't wrap your graph's node
12
+ representation.
13
+
14
+ Both are best-effort: instrumentation failures degrade to a stderr warning and
15
+ NEVER raise into your graph. Targets langgraph >= 0.1; call ``instrument`` after
16
+ ``add_node`` / ``add_edge`` and before ``compile()``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import functools
22
+ from typing import Any, Callable, Iterable, Optional
23
+
24
+ from variably import observe as _observe
25
+ from variably.integrations._common import (
26
+ extract_query,
27
+ extract_response_text,
28
+ extract_session_id,
29
+ warn,
30
+ )
31
+
32
+
33
+ def _log_node(
34
+ name: str,
35
+ state: Any,
36
+ update: Any,
37
+ *,
38
+ is_aggregator: bool,
39
+ session_id_key: str,
40
+ response_extractor: Optional[Callable[[Any], str]],
41
+ ) -> None:
42
+ """Log one node execution to variА/Bly. Never raises."""
43
+ try:
44
+ resp = (response_extractor or extract_response_text)(update)
45
+ sid = extract_session_id(state, session_id_key)
46
+ # is_aggregator requires a session_id (SDK write-time rule); only claim
47
+ # it when we actually have one, else it degrades to a normal step.
48
+ agg = bool(is_aggregator and sid)
49
+ _observe(
50
+ prompt=extract_query(state) or f"[node:{name}]",
51
+ response=resp or "",
52
+ session_id=sid,
53
+ agent_name=name,
54
+ is_aggregator=agg,
55
+ )
56
+ except Exception as e: # pragma: no cover - defensive
57
+ warn(f"node '{name}' instrumentation failed: {e}")
58
+
59
+
60
+ def observe_node(
61
+ *,
62
+ agent_name: Optional[str] = None,
63
+ is_aggregator: bool = False,
64
+ session_id_key: str = "session_id",
65
+ response_extractor: Optional[Callable[[Any], str]] = None,
66
+ ) -> Callable[[Callable], Callable]:
67
+ """Decorator for a LangGraph node function. Logs the node's output to
68
+ variА/Bly after it runs. ``agent_name`` defaults to the function name."""
69
+
70
+ def decorate(fn: Callable) -> Callable:
71
+ name = agent_name or getattr(fn, "__name__", "node")
72
+
73
+ @functools.wraps(fn)
74
+ def wrapper(state, *args, **kwargs):
75
+ update = fn(state, *args, **kwargs)
76
+ _log_node(
77
+ name, state, update,
78
+ is_aggregator=is_aggregator,
79
+ session_id_key=session_id_key,
80
+ response_extractor=response_extractor,
81
+ )
82
+ return update
83
+
84
+ return wrapper
85
+
86
+ return decorate
87
+
88
+
89
+ def _detect_terminal_nodes(graph) -> set:
90
+ """Terminal nodes = those with an edge to END, plus any with no outgoing
91
+ edge. Best-effort across langgraph versions."""
92
+ terminals: set = set()
93
+ try:
94
+ try:
95
+ from langgraph.graph import END
96
+ except Exception:
97
+ END = "__end__"
98
+
99
+ node_names = set(getattr(graph, "nodes", {}) or {})
100
+ edges = getattr(graph, "edges", None) or set()
101
+ has_outgoing: set = set()
102
+ for edge in edges:
103
+ try:
104
+ u, v = edge
105
+ except Exception:
106
+ continue
107
+ has_outgoing.add(u)
108
+ if v == END or v == "__end__":
109
+ terminals.add(u)
110
+ # nodes with no outgoing plain edge (may route via conditional branches,
111
+ # but this is a reasonable default when no END edge is declared)
112
+ terminals |= (node_names - has_outgoing)
113
+ except Exception as e: # pragma: no cover - defensive
114
+ warn(f"terminal-node detection failed: {e}")
115
+ return terminals
116
+
117
+
118
+ def _wrap_callable(name, fn, is_agg, session_id_key, response_extractor):
119
+ @functools.wraps(fn)
120
+ def wrapped(state, *args, **kwargs):
121
+ update = fn(state, *args, **kwargs)
122
+ _log_node(
123
+ name, state, update,
124
+ is_aggregator=is_agg,
125
+ session_id_key=session_id_key,
126
+ response_extractor=response_extractor,
127
+ )
128
+ return update
129
+ return wrapped
130
+
131
+
132
+ def instrument(
133
+ graph,
134
+ *,
135
+ session_id_key: str = "session_id",
136
+ aggregator_nodes: Optional[Iterable[str]] = None,
137
+ response_extractor: Optional[Callable[[Any], str]] = None,
138
+ ):
139
+ """Instrument every node in a LangGraph ``StateGraph`` in place.
140
+
141
+ Args:
142
+ graph: an uncompiled ``StateGraph``.
143
+ session_id_key: state key holding the session/thread id.
144
+ aggregator_nodes: override terminal-node auto-detection with an explicit set.
145
+ response_extractor: custom fn to pull response text from a node's return.
146
+
147
+ Returns the same graph (for chaining). Best-effort; never raises.
148
+ """
149
+ nodes = getattr(graph, "nodes", None)
150
+ if not nodes:
151
+ warn("graph has no .nodes — nothing to instrument (pass an uncompiled StateGraph)")
152
+ return graph
153
+
154
+ terminals = set(aggregator_nodes) if aggregator_nodes is not None else _detect_terminal_nodes(graph)
155
+
156
+ for name in list(nodes.keys()):
157
+ is_agg = name in terminals
158
+ try:
159
+ spec = nodes[name]
160
+ # langgraph StateNodeSpec exposes the node fn on .runnable; older /
161
+ # simpler representations store the callable directly.
162
+ runnable = getattr(spec, "runnable", None)
163
+ if runnable is not None:
164
+ inner = getattr(runnable, "func", None) or runnable
165
+ if not callable(inner):
166
+ warn(f"node '{name}': runnable is not callable; skipped")
167
+ continue
168
+ wrapped = _wrap_callable(name, inner, is_agg, session_id_key, response_extractor)
169
+ if hasattr(runnable, "func"):
170
+ try:
171
+ runnable.func = wrapped # RunnableCallable/Lambda
172
+ continue
173
+ except Exception:
174
+ pass
175
+ if _replace_spec_runnable(nodes, name, spec, wrapped):
176
+ continue
177
+ warn(f"node '{name}': could not rewrap runnable (unsupported spec)")
178
+ elif callable(spec):
179
+ nodes[name] = _wrap_callable(name, spec, is_agg, session_id_key, response_extractor)
180
+ else:
181
+ warn(f"node '{name}': unrecognised spec type {type(spec).__name__}; skipped")
182
+ except Exception as e: # pragma: no cover - defensive
183
+ warn(f"failed to instrument node '{name}': {e}")
184
+
185
+ return graph
186
+
187
+
188
+ def _replace_spec_runnable(nodes, name, spec, wrapped) -> bool:
189
+ """Try to put `wrapped` back onto the node spec across spec representations."""
190
+ # settable attribute
191
+ try:
192
+ spec.runnable = wrapped # type: ignore[attr-defined]
193
+ return True
194
+ except Exception:
195
+ pass
196
+ # namedtuple-style: rebuild via _replace
197
+ try:
198
+ nodes[name] = spec._replace(runnable=wrapped) # type: ignore[attr-defined]
199
+ return True
200
+ except Exception:
201
+ pass
202
+ return False
@@ -172,11 +172,33 @@ def observe(
172
172
  # Advanced: not needed if you pass session_id (variA/Bly auto-builds history from previous turns).
173
173
  prior_turns: Optional[List[Dict[str, str]]] = None,
174
174
 
175
+ # --- Multi-agent context ---
176
+ # Turn the session_id grouping into an evaluatable multi-agent trace.
177
+ # agent_name labels which agent produced this step; set is_aggregator=True
178
+ # on the final step that produces the aggregated answer to trigger
179
+ # deterministic cross-step scoring (coherence + trajectory).
180
+ agent_name: Optional[str] = None,
181
+ is_aggregator: bool = False,
182
+ # Tools this step actually called / was expected to call. Each dict:
183
+ # {"name": "...", "arguments": {...}, "output": "..."}. Enables the
184
+ # deterministic trajectory precision/recall + tool-correctness metrics.
185
+ tools_called: Optional[List[Dict[str, Any]]] = None,
186
+ expected_tools: Optional[List[Dict[str, Any]]] = None,
187
+
175
188
  # --- User & organization context ---
176
189
  user_id: Optional[str] = None, # Your end-user's ID — enables per-user analytics
177
190
  tags: Optional[List[str]] = None, # Grouping labels, e.g. ["production", "rag", "v2"]
178
191
  metadata: Optional[Dict[str, Any]] = None, # Any arbitrary key-value data for filtering
179
192
 
193
+ # --- Turn type ---
194
+ # Marks what kind of LLM turn this is. Use "classifier" / "router" /
195
+ # "tool_use" / "transformer" for non-generative turns so the dashboard
196
+ # suppresses grounding/hallucination/attribution scores (which don't
197
+ # apply to JSON control structures or function calls). Default
198
+ # "generative" preserves existing behavior.
199
+ # Allowed: "generative" (default), "classifier", "router", "tool_use", "transformer".
200
+ kind: Optional[str] = None,
201
+
180
202
  # --- Backward compatibility ---
181
203
  conversation_history: Optional[List[Dict[str, str]]] = None, # Deprecated: use prior_turns instead
182
204
  ) -> Optional[Dict[str, Any]]:
@@ -262,7 +284,12 @@ def observe(
262
284
  tags=tags,
263
285
  user_id=user_id,
264
286
  session_id=session_id,
287
+ agent_name=agent_name,
288
+ is_aggregator=is_aggregator,
289
+ tools_called=tools_called,
290
+ expected_tools=expected_tools,
265
291
  metadata=metadata,
292
+ kind=kind,
266
293
  )
267
294
 
268
295
  return {
@@ -1,3 +1,3 @@
1
1
  """Version information for Variably SDK."""
2
2
 
3
- __version__ = "2.9.0"
3
+ __version__ = "2.11.0"
@@ -1,8 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: variably-sdk
3
- Version: 2.9.0
3
+ Version: 2.11.0
4
4
  Summary: Official Python SDK for Variably feature flags, LLM experimentation, and prompt optimization platform
5
- Author: Variably
6
5
  Author-email: Variably <support@variably.com>
7
6
  License: MIT
8
7
  Project-URL: Homepage, https://github.com/variably/variably-python-sdk
@@ -23,10 +22,12 @@ Classifier: Programming Language :: Python :: 3.11
23
22
  Classifier: Programming Language :: Python :: 3.12
24
23
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
24
  Classifier: Topic :: Internet :: WWW/HTTP
26
- Requires-Python: >=3.8
25
+ Requires-Python: >=3.7
27
26
  Description-Content-Type: text/markdown
28
27
  Requires-Dist: requests>=2.25.0
29
28
  Requires-Dist: typing-extensions>=3.7.4; python_version < "3.8"
29
+ Provides-Extra: langgraph
30
+ Requires-Dist: langgraph>=0.1; extra == "langgraph"
30
31
  Provides-Extra: dev
31
32
  Requires-Dist: pytest>=6.0; extra == "dev"
32
33
  Requires-Dist: pytest-asyncio>=0.18.0; extra == "dev"
@@ -40,8 +41,6 @@ Provides-Extra: test
40
41
  Requires-Dist: pytest>=6.0; extra == "test"
41
42
  Requires-Dist: pytest-cov>=2.10; extra == "test"
42
43
  Requires-Dist: responses>=0.18.0; extra == "test"
43
- Dynamic: author
44
- Dynamic: requires-python
45
44
 
46
45
  # Variably Python SDK
47
46
 
@@ -1,6 +1,5 @@
1
1
  README.md
2
2
  pyproject.toml
3
- setup.py
4
3
  src/variably/__init__.py
5
4
  src/variably/cache.py
6
5
  src/variably/client.py
@@ -11,9 +10,13 @@ src/variably/logger.py
11
10
  src/variably/observe.py
12
11
  src/variably/types.py
13
12
  src/variably/version.py
13
+ src/variably/integrations/__init__.py
14
+ src/variably/integrations/_common.py
15
+ src/variably/integrations/langgraph.py
14
16
  src/variably_sdk.egg-info/PKG-INFO
15
17
  src/variably_sdk.egg-info/SOURCES.txt
16
18
  src/variably_sdk.egg-info/dependency_links.txt
17
19
  src/variably_sdk.egg-info/requires.txt
18
20
  src/variably_sdk.egg-info/top_level.txt
21
+ tests/test_integrations_langgraph.py
19
22
  tests/test_streaming.py
@@ -13,6 +13,9 @@ mypy>=0.800
13
13
  isort>=5.0
14
14
  responses>=0.18.0
15
15
 
16
+ [langgraph]
17
+ langgraph>=0.1
18
+
16
19
  [test]
17
20
  pytest>=6.0
18
21
  pytest-cov>=2.10
@@ -0,0 +1,131 @@
1
+ """Tests for the LangGraph integration (Task 42.4).
2
+
3
+ Uses a fake StateGraph and a captured `observe` so no real langgraph is needed.
4
+ """
5
+
6
+ import sys
7
+ import os
8
+
9
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
10
+
11
+ import variably.integrations.langgraph as lg
12
+ from variably.integrations.langgraph import instrument, observe_node
13
+
14
+
15
+ class FakeGraph:
16
+ """Minimal StateGraph stand-in: nodes as name->callable, edges as a set."""
17
+ def __init__(self):
18
+ self.nodes = {}
19
+ self.edges = set()
20
+
21
+ def add_node(self, name, fn):
22
+ self.nodes[name] = fn
23
+
24
+ def add_edge(self, u, v):
25
+ self.edges.add((u, v))
26
+
27
+
28
+ def _capture(monkeypatch):
29
+ calls = []
30
+ monkeypatch.setattr(lg, "_observe", lambda **kw: calls.append(kw))
31
+ return calls
32
+
33
+
34
+ def test_observe_node_decorator(monkeypatch):
35
+ calls = _capture(monkeypatch)
36
+
37
+ @observe_node(agent_name="reco", is_aggregator=True)
38
+ def node(state):
39
+ return {"response": "final recommendation"}
40
+
41
+ out = node({"session_id": "s1", "query": "what to buy?"})
42
+ assert out == {"response": "final recommendation"}
43
+ assert len(calls) == 1
44
+ c = calls[0]
45
+ assert c["agent_name"] == "reco"
46
+ assert c["is_aggregator"] is True
47
+ assert c["session_id"] == "s1"
48
+ assert c["response"] == "final recommendation"
49
+ assert c["prompt"] == "what to buy?"
50
+
51
+
52
+ def test_is_aggregator_requires_session_id(monkeypatch):
53
+ calls = _capture(monkeypatch)
54
+
55
+ @observe_node(agent_name="reco", is_aggregator=True)
56
+ def node(state):
57
+ return {"response": "x"}
58
+
59
+ node({}) # no session_id
60
+ assert calls[0]["is_aggregator"] is False
61
+ assert calls[0]["session_id"] is None
62
+
63
+
64
+ def test_instrument_wraps_all_nodes_and_marks_terminal(monkeypatch):
65
+ calls = _capture(monkeypatch)
66
+ g = FakeGraph()
67
+ g.add_node("profiler", lambda s: {"profile": "eco-friendly"})
68
+ g.add_node("recommender", lambda s: {"response": "buy X"})
69
+ g.add_edge("profiler", "recommender")
70
+ g.add_edge("recommender", "__end__") # terminal
71
+
72
+ instrument(g, session_id_key="session_id")
73
+
74
+ state = {"session_id": "sess", "query": "q"}
75
+ g.nodes["profiler"](state)
76
+ g.nodes["recommender"](state)
77
+
78
+ by_agent = {c["agent_name"]: c for c in calls}
79
+ assert set(by_agent) == {"profiler", "recommender"}
80
+ assert by_agent["profiler"]["is_aggregator"] is False
81
+ assert by_agent["recommender"]["is_aggregator"] is True
82
+ assert by_agent["profiler"]["response"] == "eco-friendly"
83
+ assert by_agent["recommender"]["response"] == "buy X"
84
+
85
+
86
+ def test_instrument_never_breaks_node_when_logging_fails(monkeypatch):
87
+ def boom(**kw):
88
+ raise RuntimeError("logging down")
89
+ monkeypatch.setattr(lg, "_observe", boom)
90
+
91
+ g = FakeGraph()
92
+ g.add_node("n", lambda s: {"response": "still works"})
93
+ instrument(g)
94
+ # node must still return its update even though logging raises
95
+ assert g.nodes["n"]({"session_id": "s"}) == {"response": "still works"}
96
+
97
+
98
+ def test_instrument_rewraps_runnable_spec(monkeypatch):
99
+ calls = _capture(monkeypatch)
100
+
101
+ class FakeRunnable:
102
+ def __init__(self, func):
103
+ self.func = func
104
+
105
+ class Spec:
106
+ def __init__(self, runnable):
107
+ self.runnable = runnable
108
+
109
+ g = FakeGraph()
110
+ g.nodes["x"] = Spec(FakeRunnable(lambda s: {"response": "r"}))
111
+ g.add_edge("x", "__end__")
112
+
113
+ instrument(g)
114
+ # the wrapped fn now lives on .runnable.func
115
+ g.nodes["x"].runnable.func({"session_id": "s"})
116
+ assert len(calls) == 1
117
+ assert calls[0]["agent_name"] == "x"
118
+ assert calls[0]["is_aggregator"] is True
119
+
120
+
121
+ def test_explicit_aggregator_override(monkeypatch):
122
+ calls = _capture(monkeypatch)
123
+ g = FakeGraph()
124
+ g.add_node("a", lambda s: {"response": "1"})
125
+ g.add_node("b", lambda s: {"response": "2"})
126
+ instrument(g, aggregator_nodes={"a"})
127
+ g.nodes["a"]({"session_id": "s"})
128
+ g.nodes["b"]({"session_id": "s"})
129
+ by_agent = {c["agent_name"]: c for c in calls}
130
+ assert by_agent["a"]["is_aggregator"] is True
131
+ assert by_agent["b"]["is_aggregator"] is False
@@ -1,23 +0,0 @@
1
- from setuptools import setup, find_packages
2
-
3
- setup(
4
- name="variably",
5
- version="2.4.0",
6
- description="Variably Python SDK for LLM experimentation platform",
7
- author="Variably",
8
- packages=find_packages(),
9
- install_requires=[
10
- "requests>=2.25.0",
11
- ],
12
- python_requires=">=3.8",
13
- classifiers=[
14
- "Development Status :: 4 - Beta",
15
- "Intended Audience :: Developers",
16
- "License :: OSI Approved :: MIT License",
17
- "Programming Language :: Python :: 3",
18
- "Programming Language :: Python :: 3.8",
19
- "Programming Language :: Python :: 3.9",
20
- "Programming Language :: Python :: 3.10",
21
- "Programming Language :: Python :: 3.11",
22
- ],
23
- )
File without changes
File without changes