sequa 0.0.1__py3-none-any.whl

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.
sequa/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from sequa!"
sequa/cassette.py ADDED
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import threading
5
+ from functools import wraps
6
+ from typing import Any, Callable, TypeVar, TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from sequa.recorder import RecorderEngine
10
+
11
+ F = TypeVar("F", bound=Callable[..., Any])
12
+
13
+ _local = threading.local()
14
+
15
+
16
+ def get_active_engine() -> Any | None:
17
+ """Retrieve the currently active RecorderEngine from the thread-local stack."""
18
+ if not hasattr(_local, "stack"):
19
+ _local.stack = []
20
+ if _local.stack:
21
+ return _local.stack[-1]
22
+ return None
23
+
24
+
25
+ class cassette:
26
+ def __init__(
27
+ self,
28
+ path: str = "cassettes",
29
+ mode: str = "auto",
30
+ ignore_fields: list[str] | None = None,
31
+ normalizer: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
32
+ adapter: Any | None = None,
33
+ ) -> None:
34
+ self.path = path
35
+ self.mode = mode
36
+ self.ignore_fields = ignore_fields or []
37
+ self.normalizer = normalizer
38
+ self.adapter = adapter
39
+
40
+ if self.adapter is None:
41
+ from sequa.llm.adapters.chat import LangChainGroqAdapter
42
+
43
+ self.adapter = LangChainGroqAdapter()
44
+
45
+ from sequa.recorder import RecorderEngine
46
+ self.engine = RecorderEngine(
47
+ path=self.path,
48
+ mode=self.mode,
49
+ ignore_fields=self.ignore_fields,
50
+ normalizer=self.normalizer,
51
+ )
52
+ self.original_methods: list[tuple[type, str, Any]] = []
53
+
54
+ def __enter__(self) -> cassette:
55
+ # 1. Push self.engine onto thread-local context stack
56
+ if not hasattr(_local, "stack"):
57
+ _local.stack = []
58
+ _local.stack.append(self.engine)
59
+
60
+ # 2. Patch langchain_groq ChatGroq if installed
61
+ try:
62
+ from langchain_groq import ChatGroq
63
+
64
+ # Sync invoke
65
+ if not getattr(ChatGroq.invoke, "__sequa_patched__", False):
66
+ original_invoke = ChatGroq.invoke
67
+ self.original_methods.append((ChatGroq, "invoke", original_invoke))
68
+
69
+ def wrapped_invoke(self_obj: ChatGroq, *args: Any, **kwargs: Any) -> Any:
70
+ active_engine = get_active_engine()
71
+ if active_engine is None:
72
+ return original_invoke(self_obj, *args, **kwargs)
73
+
74
+ def make_live_call(s: Any, *a: Any, **kw: Any) -> Any:
75
+ return original_invoke(s, *a, **kw)
76
+
77
+ return active_engine.handle_call(self.adapter, make_live_call, self_obj, *args, **kwargs)
78
+
79
+ wrapped_invoke.__sequa_patched__ = True
80
+ ChatGroq.invoke = wrapped_invoke
81
+
82
+ # Async ainvoke
83
+ if hasattr(ChatGroq, "ainvoke"):
84
+ if not getattr(ChatGroq.ainvoke, "__sequa_patched__", False):
85
+ original_ainvoke = ChatGroq.ainvoke
86
+ self.original_methods.append((ChatGroq, "ainvoke", original_ainvoke))
87
+
88
+ async def wrapped_ainvoke(self_obj: ChatGroq, *args: Any, **kwargs: Any) -> Any:
89
+ active_engine = get_active_engine()
90
+ if active_engine is None:
91
+ return await original_ainvoke(self_obj, *args, **kwargs)
92
+
93
+ async def make_live_call(s: Any, *a: Any, **kw: Any) -> Any:
94
+ return await original_ainvoke(s, *a, **kw)
95
+
96
+ return await active_engine.handle_call_async(
97
+ self.adapter, make_live_call, self_obj, *args, **kwargs
98
+ )
99
+
100
+ wrapped_ainvoke.__sequa_patched__ = True
101
+ ChatGroq.ainvoke = wrapped_ainvoke
102
+
103
+ except ImportError:
104
+ pass
105
+
106
+ return self
107
+
108
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
109
+ # 1. Pop from stack
110
+ if hasattr(_local, "stack") and _local.stack:
111
+ _local.stack.pop()
112
+
113
+ # 2. Restore original methods
114
+ for cls, attr, original in self.original_methods:
115
+ setattr(cls, attr, original)
116
+ self.original_methods.clear()
117
+
118
+ def intercept(
119
+ self, fn: Callable[..., Any], *args: Any, **kwargs: Any
120
+ ) -> dict[str, Any]:
121
+ """Provides direct interception utility for testing."""
122
+ canonical_request = self.adapter.to_canonical_request(
123
+ request=args[0] if args else None, **kwargs
124
+ )
125
+ result = fn(*args, **kwargs)
126
+ canonical_response = self.adapter.to_canonical_response(result, **kwargs)
127
+ return {
128
+ "request": canonical_request,
129
+ "response": canonical_response,
130
+ "raw": result,
131
+ }
132
+
133
+ def __call__(self, fn: F) -> F:
134
+ if inspect.iscoroutinefunction(fn):
135
+ @wraps(fn)
136
+ async def wrapped_async(*args: Any, **kwargs: Any) -> Any:
137
+ active_engine = get_active_engine()
138
+ if active_engine is not None:
139
+ async def make_live_call(*a: Any, **kw: Any) -> Any:
140
+ return await fn(*a, **kw)
141
+ return await active_engine.handle_call_async(
142
+ self.adapter, make_live_call, *args, **kwargs
143
+ )
144
+ else:
145
+ canonical_request = self.adapter.to_canonical_request(args, **kwargs)
146
+ result = await fn(*args, **kwargs)
147
+ canonical_response = self.adapter.to_canonical_response(result, **kwargs)
148
+ return {
149
+ "request": canonical_request,
150
+ "response": canonical_response,
151
+ "raw": result,
152
+ }
153
+ wrapped_async.__sequa_patched__ = True
154
+ return wrapped_async # type: ignore
155
+ else:
156
+ @wraps(fn)
157
+ def wrapped_sync(*args: Any, **kwargs: Any) -> Any:
158
+ active_engine = get_active_engine()
159
+ if active_engine is not None:
160
+ def make_live_call(*a: Any, **kw: Any) -> Any:
161
+ return fn(*a, **kw)
162
+ return active_engine.handle_call(
163
+ self.adapter, make_live_call, *args, **kwargs
164
+ )
165
+ else:
166
+ canonical_request = self.adapter.to_canonical_request(args, **kwargs)
167
+ result = fn(*args, **kwargs)
168
+ canonical_response = self.adapter.to_canonical_response(result, **kwargs)
169
+ return {
170
+ "request": canonical_request,
171
+ "response": canonical_response,
172
+ "raw": result,
173
+ }
174
+ wrapped_sync.__sequa_patched__ = True
175
+ return wrapped_sync # type: ignore
sequa/cli/main.py ADDED
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from typing import Any
8
+
9
+
10
+ def load_all_cassettes(path: str) -> list[tuple[str, dict[str, Any]]]:
11
+ """Finds and loads all JSON cassette files in the given directory or file path."""
12
+ cassettes = []
13
+ if os.path.isfile(path) and path.endswith(".json"):
14
+ try:
15
+ with open(path, "r", encoding="utf-8") as f:
16
+ cassettes.append((path, json.load(f)))
17
+ except Exception as e:
18
+ print(f"Error loading {path}: {e}", file=sys.stderr)
19
+ elif os.path.isdir(path):
20
+ for root, _, files in os.walk(path):
21
+ for file in files:
22
+ if file.endswith(".json"):
23
+ file_path = os.path.join(root, file)
24
+ try:
25
+ with open(file_path, "r", encoding="utf-8") as f:
26
+ data = json.load(f)
27
+ # Simple validation to verify it is a Sequa cassette file
28
+ if "request" in data and "response" in data:
29
+ cassettes.append((file_path, data))
30
+ except Exception as e:
31
+ # Skip files that are not valid JSON or not cassettes
32
+ pass
33
+ return cassettes
34
+
35
+
36
+ def cmd_stats(args: argparse.Namespace) -> int:
37
+ """Calculates and prints statistics for stored cassettes."""
38
+ cassettes = load_all_cassettes(args.path)
39
+ if not cassettes:
40
+ print(f"No cassettes found at path: {args.path}")
41
+ return 0
42
+
43
+ count = len(cassettes)
44
+ total_latency_ms = 0.0
45
+ total_size_bytes = 0
46
+
47
+ for path, data in cassettes:
48
+ # Size
49
+ try:
50
+ total_size_bytes += os.path.getsize(path)
51
+ except Exception:
52
+ pass
53
+
54
+ # Latency
55
+ latency = data.get("metadata", {}).get("latency_ms")
56
+ if latency is None:
57
+ latency = data.get("response", {}).get("latency")
58
+
59
+ if latency is not None:
60
+ try:
61
+ total_latency_ms += float(latency)
62
+ except ValueError:
63
+ pass
64
+
65
+ print("========================================")
66
+ print(" Sequa Statistics")
67
+ print("========================================")
68
+ print(f"Total Cassettes: {count}")
69
+ print(f"Total Size on Disk: {total_size_bytes / 1024:.2f} KB ({total_size_bytes} bytes)")
70
+ print(f"Total Latency Saved: {total_latency_ms / 1000:.2f} seconds ({total_latency_ms:.1f} ms)")
71
+ print("========================================")
72
+ return 0
73
+
74
+
75
+ def cmd_inspect(args: argparse.Namespace) -> int:
76
+ """Lists summary of all stored cassettes."""
77
+ cassettes = load_all_cassettes(args.path)
78
+ if not cassettes:
79
+ print(f"No cassettes found at path: {args.path}")
80
+ return 0
81
+
82
+ print(f"{'Filename / Hash':<40} | {'Provider':<15} | {'Model':<25} | {'Created At':<25}")
83
+ print("-" * 111)
84
+ for path, data in cassettes:
85
+ filename = os.path.basename(path)
86
+ provider = data.get("provider", "unknown")
87
+
88
+ # Get model
89
+ req = data.get("request", {})
90
+ model = req.get("model") or "unknown"
91
+
92
+ created_at = data.get("created_at", "unknown")
93
+
94
+ # Format filename to fit table neatly
95
+ if len(filename) > 38:
96
+ filename_display = filename[:35] + "..."
97
+ else:
98
+ filename_display = filename
99
+
100
+ print(f"{filename_display:<40} | {provider:<15} | {model:<25} | {created_at:<25}")
101
+ return 0
102
+
103
+
104
+ def cmd_clean(args: argparse.Namespace) -> int:
105
+ """Cleans dynamic/volatile fields from cassettes or formats them."""
106
+ cassettes = load_all_cassettes(args.path)
107
+ if not cassettes:
108
+ print(f"No cassettes found at path: {args.path}")
109
+ return 0
110
+
111
+ cleaned_count = 0
112
+ for path, data in cassettes:
113
+ modified = False
114
+
115
+ # 1. Remove latency metadata if requested
116
+ if args.remove_latency:
117
+ if "metadata" in data and "latency_ms" in data["metadata"]:
118
+ del data["metadata"]["latency_ms"]
119
+ modified = True
120
+ if "response" in data and "latency" in data["response"]:
121
+ del data["response"]["latency"]
122
+ modified = True
123
+
124
+ # 2. Remove timestamps if requested
125
+ if args.remove_timestamps:
126
+ if "created_at" in data:
127
+ data["created_at"] = ""
128
+ modified = True
129
+
130
+ # 3. Format/Sort keys cleanly (always done during clean)
131
+ from sequa.utils import sort_dict_keys
132
+ data = sort_dict_keys(data)
133
+ modified = True
134
+
135
+ if modified:
136
+ try:
137
+ with open(path, "w", encoding="utf-8") as f:
138
+ json.dump(data, f, indent=4, ensure_ascii=False)
139
+ cleaned_count += 1
140
+ except Exception as e:
141
+ print(f"Error writing to {path}: {e}", file=sys.stderr)
142
+
143
+ print(f"Successfully formatted/cleaned {cleaned_count} cassettes.")
144
+ return 0
145
+
146
+
147
+ def main() -> None:
148
+ parser = argparse.ArgumentParser(
149
+ description="Sequa CLI - Manage and inspect your LLM snapshot cassettes."
150
+ )
151
+ subparsers = parser.add_subparsers(dest="command", required=True)
152
+
153
+ # stats
154
+ parser_stats = subparsers.add_parser("stats", help="Show summary statistics of stored cassettes.")
155
+ parser_stats.add_argument(
156
+ "--path",
157
+ "-p",
158
+ default="cassettes",
159
+ help="Path to the cassettes directory or file (default: 'cassettes').",
160
+ )
161
+
162
+ # inspect
163
+ parser_inspect = subparsers.add_parser("inspect", help="List summary of all stored cassettes.")
164
+ parser_inspect.add_argument(
165
+ "--path",
166
+ "-p",
167
+ default="cassettes",
168
+ help="Path to the cassettes directory or file (default: 'cassettes').",
169
+ )
170
+
171
+ # clean
172
+ parser_clean = subparsers.add_parser("clean", help="Clean or format stored cassettes.")
173
+ parser_clean.add_argument(
174
+ "--path",
175
+ "-p",
176
+ default="cassettes",
177
+ help="Path to the cassettes directory or file (default: 'cassettes').",
178
+ )
179
+ parser_clean.add_argument(
180
+ "--remove-latency",
181
+ action="store_true",
182
+ help="Remove dynamic latency values from cassettes to avoid git diff noise.",
183
+ )
184
+ parser_clean.add_argument(
185
+ "--remove-timestamps",
186
+ action="store_true",
187
+ help="Redact/clear dynamic timestamps.",
188
+ )
189
+
190
+ args = parser.parse_args()
191
+
192
+ if args.command == "stats":
193
+ sys.exit(cmd_stats(args))
194
+ elif args.command == "inspect":
195
+ sys.exit(cmd_inspect(args))
196
+ elif args.command == "clean":
197
+ sys.exit(cmd_clean(args))
198
+
199
+
200
+ if __name__ == "__main__":
201
+ main()
@@ -0,0 +1,21 @@
1
+ from .base import AdapterRegistry, ProviderAdapter, CanonicalRequest, CanonicalResponse
2
+ from .chat import OpenAIAdapter, LangChainGroqAdapter
3
+ from .monkey_patch import LangChainGroqMonkeyPatch
4
+ from ...cassette import cassette
5
+
6
+
7
+ class RequestInterceptor(cassette):
8
+ pass
9
+
10
+
11
+ __all__ = [
12
+ "AdapterRegistry",
13
+ "ProviderAdapter",
14
+ "CanonicalRequest",
15
+ "CanonicalResponse",
16
+ "OpenAIAdapter",
17
+ "LangChainGroqAdapter",
18
+ "LangChainGroqMonkeyPatch",
19
+ "cassette",
20
+ "RequestInterceptor",
21
+ ]
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class CanonicalRequest:
10
+ provider: str
11
+ model: str | None = None
12
+ messages: list[dict[str, Any]] | None = None
13
+ params: dict[str, Any] = field(default_factory=dict)
14
+ temperature: float | None = None
15
+ raw: dict[str, Any] = field(default_factory=dict)
16
+ metadata: dict[str, Any] = field(default_factory=dict)
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class CanonicalResponse:
21
+ provider: str
22
+ output: Any = None
23
+ model: str | None = None
24
+ tool_calls: list[Any] = field(default_factory=list)
25
+ invalid_tool_calls: list[Any] = field(default_factory=list)
26
+ latency: float | None = None
27
+ reasoning: str | None = None
28
+ usage: dict[str, Any] | None = None
29
+ metadata: dict[str, Any] = field(default_factory=dict)
30
+
31
+
32
+ class ProviderAdapter(ABC):
33
+ provider_name: str = ""
34
+
35
+ @abstractmethod
36
+ def to_canonical_request(self, request: Any, **kwargs: Any) -> CanonicalRequest:
37
+ """Convert a provider-specific request into the canonical request shape."""
38
+
39
+ @abstractmethod
40
+ def to_canonical_response(self, response: Any, **kwargs: Any) -> CanonicalResponse:
41
+ """Convert a provider-specific response into the canonical response shape."""
42
+
43
+ @abstractmethod
44
+ def from_canonical_response(self, response: CanonicalResponse, request: Any) -> Any:
45
+ """Rebuild a provider-native response from a canonical response."""
46
+
47
+
48
+ class AdapterRegistry:
49
+ def __init__(self) -> None:
50
+ self._adapters: dict[str, ProviderAdapter] = {}
51
+
52
+ def register(self, adapter: ProviderAdapter) -> None:
53
+ self._adapters[adapter.provider_name] = adapter
54
+
55
+ def get(self, provider_name: str) -> ProviderAdapter:
56
+ try:
57
+ return self._adapters[provider_name]
58
+ except KeyError as exc:
59
+ raise KeyError(
60
+ f"No adapter registered for provider: {provider_name}"
61
+ ) from exc
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .base import CanonicalRequest, CanonicalResponse, ProviderAdapter
6
+
7
+
8
+ class LangChainGroqAdapter(ProviderAdapter):
9
+ """A generic chat-completions adapter that can be configured per provider."""
10
+
11
+ def __init__(
12
+ self, provider_name: str = "langchain_groq", response_object: str | None = None
13
+ ) -> None:
14
+ self.provider_name = provider_name
15
+ self._response_object = response_object
16
+
17
+ def to_canonical_request(self, request: Any, **kwargs: Any) -> CanonicalRequest:
18
+ if isinstance(request, (tuple, list)) and len(request) > 0:
19
+ first_arg = request[0]
20
+ if hasattr(first_arg, "model_name"):
21
+ payload = first_arg
22
+ messages = request[1] if len(request) > 1 else []
23
+ else:
24
+ payload = first_arg
25
+ messages = []
26
+ else:
27
+ payload = request or {}
28
+ messages = []
29
+
30
+ model = getattr(payload, "model_name", None) or kwargs.get("model")
31
+ temperature = getattr(payload, "temperature", None) or kwargs.get("temperature")
32
+
33
+ if isinstance(payload, dict):
34
+ raw_payload = payload
35
+ if not messages:
36
+ messages = payload.get("messages") or kwargs.get("messages") or []
37
+ else:
38
+ raw_payload = {"input": payload}
39
+ if not messages:
40
+ messages = kwargs.get("messages") or []
41
+
42
+ if isinstance(messages, (list, tuple)):
43
+ messages_list = list(messages)
44
+ elif messages:
45
+ messages_list = [messages]
46
+ else:
47
+ messages_list = []
48
+
49
+ return CanonicalRequest(
50
+ provider=self.provider_name,
51
+ model=model,
52
+ temperature=temperature,
53
+ messages=messages_list,
54
+ params=kwargs,
55
+ metadata={"raw_request": raw_payload},
56
+ )
57
+
58
+ def to_canonical_response(self, response: Any, **kwargs: Any) -> CanonicalResponse:
59
+ payload = response or {}
60
+ if hasattr(payload, "content") and not isinstance(payload, dict):
61
+ content = getattr(payload, "content", None)
62
+ return CanonicalResponse(
63
+ provider=self.provider_name,
64
+ output=content,
65
+ model=payload.response_metadata.get("model_name"),
66
+ tool_calls=payload.tool_calls,
67
+ invalid_tool_calls=payload.invalid_tool_calls,
68
+ latency=payload.response_metadata.get("total_time"),
69
+ reasoning=(
70
+ payload.response_metadata.get("token_usage", {}).get("reasoning_content")
71
+ if payload.response_metadata.get("token_usage")
72
+ else None
73
+ ),
74
+ usage=payload.usage_metadata,
75
+ metadata={
76
+ "raw_response": {
77
+ "id": getattr(payload, "id", None),
78
+ "response_metadata": getattr(payload, "response_metadata", {}),
79
+ }
80
+ },
81
+ )
82
+
83
+ choices = payload.get("choices", [])
84
+ first_choice = choices[0] if choices else {}
85
+ message = first_choice.get("message", {})
86
+
87
+ return CanonicalResponse(
88
+ provider=self.provider_name,
89
+ model=kwargs.get("model"),
90
+ output=message.get("content"),
91
+ usage=payload.get("usage"),
92
+ metadata={"raw_response": payload},
93
+ )
94
+
95
+ def from_canonical_response(self, response: CanonicalResponse, request: Any) -> Any:
96
+ raw_resp = response.metadata.get("raw_response")
97
+ resp_id = "replayed-response"
98
+ finish_reason = "stop"
99
+ if raw_resp:
100
+ if isinstance(raw_resp, dict):
101
+ resp_id = raw_resp.get("id", "replayed-response")
102
+ choices = raw_resp.get("choices", [])
103
+ if choices:
104
+ finish_reason = choices[0].get("finish_reason", "stop")
105
+ else:
106
+ resp_id = getattr(raw_resp, "id", "replayed-response")
107
+ response_metadata = getattr(raw_resp, "response_metadata", {})
108
+ finish_reason = response_metadata.get("finish_reason", "stop")
109
+
110
+ # Determine if we should return a LangChain AIMessage object
111
+ is_langchain = False
112
+ if request and isinstance(request, (tuple, list)) and len(request) > 0:
113
+ first_arg = request[0]
114
+ # If the class represents ChatGroq or a LangChain chat model
115
+ if hasattr(first_arg, "model_name") or hasattr(first_arg, "invoke"):
116
+ is_langchain = True
117
+
118
+ if raw_resp and hasattr(raw_resp, "content") and not isinstance(raw_resp, dict):
119
+ is_langchain = True
120
+
121
+ if is_langchain:
122
+ from langchain_core.messages import AIMessage
123
+
124
+ response_metadata = {}
125
+ if raw_resp:
126
+ if isinstance(raw_resp, dict):
127
+ response_metadata = dict(raw_resp.get("response_metadata", {}))
128
+ elif hasattr(raw_resp, "response_metadata"):
129
+ response_metadata = dict(getattr(raw_resp, "response_metadata", {}))
130
+
131
+ if not response_metadata:
132
+ response_metadata = {
133
+ "model_name": response.model,
134
+ "finish_reason": finish_reason,
135
+ }
136
+ if response.latency is not None:
137
+ response_metadata["total_time"] = response.latency
138
+
139
+ aimsg_kwargs: dict[str, Any] = {
140
+ "content": response.output or "",
141
+ "response_metadata": response_metadata,
142
+ "id": resp_id,
143
+ "tool_calls": response.tool_calls or [],
144
+ "invalid_tool_calls": response.invalid_tool_calls or [],
145
+ }
146
+
147
+ if response.usage and isinstance(response.usage, dict):
148
+ input_t = response.usage.get("input_tokens") or response.usage.get("prompt_tokens") or 0
149
+ output_t = response.usage.get("output_tokens") or response.usage.get("completion_tokens") or 0
150
+ total_t = response.usage.get("total_tokens") or (input_t + output_t)
151
+ aimsg_kwargs["usage_metadata"] = {
152
+ "input_tokens": input_t,
153
+ "output_tokens": output_t,
154
+ "total_tokens": total_t,
155
+ }
156
+
157
+ return AIMessage(**aimsg_kwargs)
158
+
159
+ payload: dict[str, Any] = {
160
+ "id": resp_id,
161
+ "choices": [
162
+ {
163
+ "message": {
164
+ "role": "assistant",
165
+ "content": response.output,
166
+ },
167
+ "finish_reason": finish_reason,
168
+ }
169
+ ],
170
+ "usage": response.usage or {},
171
+ "model": response.model or "replayed-model",
172
+ }
173
+
174
+ if self._response_object is not None:
175
+ payload["object"] = self._response_object
176
+
177
+ return payload
178
+
179
+
180
+ class OpenAIAdapter(ProviderAdapter):
181
+ """An OpenAI chat completions adapter."""
182
+
183
+ def __init__(self, response_object: str | None = "chat.completion") -> None:
184
+ self.provider_name = "openai"
185
+ self._response_object = response_object
186
+
187
+ def to_canonical_request(self, request: Any, **kwargs: Any) -> CanonicalRequest:
188
+ payload = request or {}
189
+ messages = payload.get("messages", [])
190
+ return CanonicalRequest(
191
+ provider=self.provider_name,
192
+ model=kwargs.get("model"),
193
+ messages=list(messages),
194
+ params=kwargs,
195
+ )
196
+
197
+ def to_canonical_response(self, response: Any, **kwargs: Any) -> CanonicalResponse:
198
+ payload = response or {}
199
+ choices = payload.get("choices", [])
200
+ first_choice = choices[0] if choices else {}
201
+ message = first_choice.get("message", {})
202
+ return CanonicalResponse(
203
+ provider=self.provider_name,
204
+ model=kwargs.get("model"),
205
+ output=message.get("content"),
206
+ usage=payload.get("usage"),
207
+ metadata={"raw_response": payload},
208
+ )
209
+
210
+ def from_canonical_response(self, response: CanonicalResponse, request: Any) -> Any:
211
+ raw_resp = response.metadata.get("raw_response")
212
+ resp_id = "replayed-response"
213
+ finish_reason = "stop"
214
+ if raw_resp and isinstance(raw_resp, dict):
215
+ resp_id = raw_resp.get("id", "replayed-response")
216
+ choices = raw_resp.get("choices", [])
217
+ if choices:
218
+ finish_reason = choices[0].get("finish_reason", "stop")
219
+
220
+ payload: dict[str, Any] = {
221
+ "id": resp_id,
222
+ "choices": [
223
+ {
224
+ "message": {
225
+ "role": "assistant",
226
+ "content": response.output,
227
+ },
228
+ "finish_reason": finish_reason,
229
+ }
230
+ ],
231
+ "usage": response.usage or {},
232
+ "model": response.model or "replayed-model",
233
+ }
234
+
235
+ if self._response_object is not None:
236
+ payload["object"] = self._response_object
237
+
238
+ return payload
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ class LangChainGroqMonkeyPatch:
6
+ def __init__(self, adapter: Any | None = None) -> None:
7
+ self.adapter = adapter
8
+ if self.adapter is None:
9
+ from sequa.llm.adapters.chat import LangChainGroqAdapter
10
+
11
+ self.adapter = LangChainGroqAdapter()
12
+ from sequa.cassette import cassette
13
+ self.decorator = cassette(adapter=self.adapter)
14
+
15
+ def patch(self, cls: Any) -> Any:
16
+ if getattr(cls.invoke, "__sequa_patched__", False):
17
+ return cls
18
+
19
+ original_invoke = cls.invoke
20
+
21
+ @self.decorator
22
+ def wrapped_invoke(self_obj: Any, *args: Any, **kwargs: Any) -> Any:
23
+ return original_invoke(self_obj, *args, **kwargs)
24
+
25
+ cls.invoke = wrapped_invoke
26
+ return cls
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from langchain_groq import ChatGroq
6
+
7
+ from sequa.cassette import cassette
8
+ from sequa.llm.adapters.chat import LangChainGroqAdapter
9
+
10
+
11
+ def patch_langchain(adapter: Any | None = None) -> type[ChatGroq]:
12
+ if getattr(ChatGroq.invoke, "__sequa_patched__", False):
13
+ return ChatGroq
14
+
15
+ adapter = adapter or LangChainGroqAdapter()
16
+ decorator = cassette(adapter=adapter)
17
+ original_invoke = ChatGroq.invoke
18
+
19
+ @decorator
20
+ def wrapped_invoke(self: ChatGroq, *args: Any, **kwargs: Any) -> Any:
21
+ return original_invoke(self, *args, **kwargs)
22
+
23
+ ChatGroq.invoke = wrapped_invoke
24
+ return ChatGroq
sequa/matcher.py ADDED
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+ from sequa.llm.adapters.base import CanonicalRequest
8
+ from sequa.utils import sort_dict_keys
9
+
10
+
11
+ def normalize(req: dict[str, Any] | CanonicalRequest) -> dict[str, Any]:
12
+ """Normalize request into a standard dictionary structure."""
13
+ if isinstance(req, CanonicalRequest):
14
+ req_dict = {
15
+ "provider": req.provider,
16
+ "model": req.model,
17
+ "messages": req.messages,
18
+ "temperature": req.temperature,
19
+ "params": req.params,
20
+ }
21
+ else:
22
+ req_dict = dict(req)
23
+
24
+ # Normalize messages list to standard role/content/type structure
25
+ messages = req_dict.get("messages")
26
+ if messages is not None:
27
+ normalized_msgs = []
28
+ for msg in messages:
29
+ if isinstance(msg, dict):
30
+ # Keep only core message fields
31
+ normalized_msgs.append(
32
+ {k: v for k, v in msg.items() if k in ("role", "content", "type", "name")}
33
+ )
34
+ elif hasattr(msg, "to_dict"):
35
+ try:
36
+ d = msg.to_dict()
37
+ normalized_msgs.append(
38
+ {k: v for k, v in d.items() if k in ("role", "content", "type", "name")}
39
+ )
40
+ except Exception:
41
+ normalized_msgs.append({"content": str(msg)})
42
+ elif hasattr(msg, "dict"):
43
+ try:
44
+ d = msg.dict()
45
+ normalized_msgs.append(
46
+ {k: v for k, v in d.items() if k in ("role", "content", "type", "name")}
47
+ )
48
+ except Exception:
49
+ normalized_msgs.append({"content": str(msg)})
50
+ else:
51
+ normalized_msgs.append({"content": str(msg)})
52
+ req_dict["messages"] = normalized_msgs
53
+
54
+ # Standardize types and remove None values to ensure consistent hashing
55
+ cleaned: dict[str, Any] = {}
56
+ for k, v in req_dict.items():
57
+ if v is not None:
58
+ if k == "params" and isinstance(v, dict):
59
+ # Deep clean params
60
+ cleaned_params = {pk: pv for pk, pv in v.items() if pv is not None}
61
+ cleaned[k] = cleaned_params
62
+ else:
63
+ cleaned[k] = v
64
+
65
+ return cleaned
66
+
67
+
68
+ def hash_request(
69
+ req: dict[str, Any] | CanonicalRequest, ignore_fields: list[str] | None = None
70
+ ) -> str:
71
+ """Normalize, strip ignored fields, recursively sort, serialize, and hash a request."""
72
+ # 1. Normalize
73
+ normalized = normalize(req)
74
+
75
+ # 2. Remove ignored fields
76
+ ignore = ignore_fields or []
77
+ for field_name in ignore:
78
+ if field_name in normalized:
79
+ del normalized[field_name]
80
+ if "params" in normalized and isinstance(normalized["params"], dict):
81
+ if field_name in normalized["params"]:
82
+ # Copy to avoid modifying the input request dictionary in-place
83
+ normalized["params"] = dict(normalized["params"])
84
+ del normalized["params"][field_name]
85
+
86
+ # 3. Recursive sort
87
+ sorted_norm = sort_dict_keys(normalized)
88
+
89
+ # 4. Serialize with compact JSON notation
90
+ serialized = json.dumps(
91
+ sorted_norm, sort_keys=True, ensure_ascii=True, separators=(",", ":")
92
+ )
93
+
94
+ # 5. SHA-256 Hash
95
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
96
+
97
+
98
+ def match(
99
+ req1: dict[str, Any] | CanonicalRequest,
100
+ req2: dict[str, Any] | CanonicalRequest,
101
+ ignore_fields: list[str] | None = None,
102
+ ) -> bool:
103
+ """Compare two requests to check if their hashes match after normalization and ignore filtering."""
104
+ return hash_request(req1, ignore_fields) == hash_request(req2, ignore_fields)
sequa/models.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import uuid
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class Cassette:
11
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
12
+ provider: str = ""
13
+ hash: str = ""
14
+ created_at: str = field(
15
+ default_factory=lambda: datetime.datetime.now(datetime.timezone.utc).isoformat()
16
+ )
17
+ request: dict[str, Any] = field(default_factory=dict)
18
+ response: dict[str, Any] = field(default_factory=dict)
19
+ metadata: dict[str, Any] = field(default_factory=dict)
20
+
21
+ def to_dict(self) -> dict[str, Any]:
22
+ return {
23
+ "id": self.id,
24
+ "provider": self.provider,
25
+ "hash": self.hash,
26
+ "created_at": self.created_at,
27
+ "request": self.request,
28
+ "response": self.response,
29
+ "metadata": self.metadata,
30
+ }
31
+
32
+ @classmethod
33
+ def from_dict(cls, data: dict[str, Any]) -> Cassette:
34
+ return cls(
35
+ id=data.get("id", str(uuid.uuid4())),
36
+ provider=data.get("provider", ""),
37
+ hash=data.get("hash", ""),
38
+ created_at=data.get("created_at", ""),
39
+ request=data.get("request", {}),
40
+ response=data.get("response", {}),
41
+ metadata=data.get("metadata", {}),
42
+ )
sequa/py.typed ADDED
File without changes
sequa/recorder.py ADDED
@@ -0,0 +1,241 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from typing import Any, Callable
6
+
7
+ from sequa.llm.adapters.base import CanonicalRequest, CanonicalResponse, ProviderAdapter
8
+ from sequa.matcher import hash_request
9
+ from sequa.models import Cassette
10
+ from sequa import storage
11
+
12
+
13
+ class SequaError(Exception):
14
+ """Base exception for Sequa."""
15
+
16
+
17
+ class CassetteNotFoundError(SequaError):
18
+ """Exception raised when a cassette is missing in replay mode."""
19
+
20
+
21
+ class RecorderEngine:
22
+ def __init__(
23
+ self,
24
+ path: str,
25
+ mode: str = "auto",
26
+ ignore_fields: list[str] | None = None,
27
+ normalizer: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
28
+ ) -> None:
29
+ self.path = path
30
+ self.mode = mode.lower()
31
+ self.ignore_fields = ignore_fields or []
32
+ self.normalizer = normalizer
33
+
34
+ if self.mode not in ("replay", "record", "auto", "live"):
35
+ raise ValueError(f"Invalid mode: {self.mode}. Must be replay, record, auto, or live.")
36
+
37
+ def get_cassette_path(self, req_hash: str) -> str:
38
+ """Resolve the path to the cassette file based on request hash and configured path."""
39
+ # If the path is a directory (does not end in .json), append the hash filename
40
+ if not self.path.lower().endswith(".json"):
41
+ return os.path.join(self.path, f"{req_hash}.json")
42
+ return self.path
43
+
44
+ def handle_call(
45
+ self,
46
+ adapter: ProviderAdapter,
47
+ make_live_call_fn: Callable[..., Any],
48
+ *args: Any,
49
+ **kwargs: Any,
50
+ ) -> Any:
51
+ """Intercepts the call, checking the cache according to the execution mode."""
52
+ # 1. Convert request args to CanonicalRequest
53
+ canonical_req = adapter.to_canonical_request(args, **kwargs)
54
+
55
+ # Apply custom normalizer if configured
56
+ if self.normalizer is not None:
57
+ normalized_dict = self.normalizer(
58
+ {
59
+ "provider": canonical_req.provider,
60
+ "model": canonical_req.model,
61
+ "messages": canonical_req.messages,
62
+ "temperature": canonical_req.temperature,
63
+ "params": canonical_req.params,
64
+ }
65
+ )
66
+ # Rebuild canonical request from normalized dict
67
+ canonical_req = CanonicalRequest(
68
+ provider=normalized_dict.get("provider", canonical_req.provider),
69
+ model=normalized_dict.get("model", canonical_req.model),
70
+ messages=normalized_dict.get("messages", canonical_req.messages),
71
+ temperature=normalized_dict.get("temperature", canonical_req.temperature),
72
+ params=normalized_dict.get("params", canonical_req.params),
73
+ raw=canonical_req.raw,
74
+ metadata=canonical_req.metadata,
75
+ )
76
+
77
+ # 2. Hash request
78
+ req_hash = hash_request(canonical_req, self.ignore_fields)
79
+ cassette_path = self.get_cassette_path(req_hash)
80
+
81
+ # Mode: live
82
+ if self.mode == "live":
83
+ return make_live_call_fn(*args, **kwargs)
84
+
85
+ # Mode: replay
86
+ if self.mode == "replay":
87
+ if not storage.exists(cassette_path):
88
+ raise CassetteNotFoundError(
89
+ f"Cassette not found in replay mode at path: {cassette_path}"
90
+ )
91
+ cassette_obj = storage.load(cassette_path)
92
+ canonical_resp = self._deserialize_canonical_response(cassette_obj.response)
93
+ return adapter.from_canonical_response(canonical_resp, args)
94
+
95
+ # Mode: auto
96
+ if self.mode == "auto":
97
+ if storage.exists(cassette_path):
98
+ cassette_obj = storage.load(cassette_path)
99
+ canonical_resp = self._deserialize_canonical_response(cassette_obj.response)
100
+ return adapter.from_canonical_response(canonical_resp, args)
101
+
102
+ # Mode: record, or auto on cache miss
103
+ start_time = time.perf_counter()
104
+ live_response = make_live_call_fn(*args, **kwargs)
105
+ latency = (time.perf_counter() - start_time) * 1000.0 # in ms
106
+
107
+ canonical_resp = adapter.to_canonical_response(live_response, **kwargs)
108
+ if canonical_resp.latency is None:
109
+ canonical_resp.latency = latency
110
+
111
+ # Serialize request/response and save to storage
112
+ serialized_req = self._serialize_canonical_request(canonical_req)
113
+ serialized_resp = self._serialize_canonical_response(canonical_resp)
114
+
115
+ cassette_obj = Cassette(
116
+ provider=canonical_req.provider,
117
+ hash=req_hash,
118
+ request=serialized_req,
119
+ response=serialized_resp,
120
+ metadata={"latency_ms": latency},
121
+ )
122
+ storage.save(cassette_obj, cassette_path)
123
+
124
+ return live_response
125
+
126
+ async def handle_call_async(
127
+ self,
128
+ adapter: ProviderAdapter,
129
+ make_live_call_fn: Callable[..., Any],
130
+ *args: Any,
131
+ **kwargs: Any,
132
+ ) -> Any:
133
+ """Intercepts the async call, checking the cache according to the execution mode."""
134
+ # 1. Convert request args to CanonicalRequest
135
+ canonical_req = adapter.to_canonical_request(args, **kwargs)
136
+
137
+ # Apply custom normalizer if configured
138
+ if self.normalizer is not None:
139
+ normalized_dict = self.normalizer(
140
+ {
141
+ "provider": canonical_req.provider,
142
+ "model": canonical_req.model,
143
+ "messages": canonical_req.messages,
144
+ "temperature": canonical_req.temperature,
145
+ "params": canonical_req.params,
146
+ }
147
+ )
148
+ # Rebuild canonical request from normalized dict
149
+ canonical_req = CanonicalRequest(
150
+ provider=normalized_dict.get("provider", canonical_req.provider),
151
+ model=normalized_dict.get("model", canonical_req.model),
152
+ messages=normalized_dict.get("messages", canonical_req.messages),
153
+ temperature=normalized_dict.get("temperature", canonical_req.temperature),
154
+ params=normalized_dict.get("params", canonical_req.params),
155
+ raw=canonical_req.raw,
156
+ metadata=canonical_req.metadata,
157
+ )
158
+
159
+ # 2. Hash request
160
+ req_hash = hash_request(canonical_req, self.ignore_fields)
161
+ cassette_path = self.get_cassette_path(req_hash)
162
+
163
+ # Mode: live
164
+ if self.mode == "live":
165
+ return await make_live_call_fn(*args, **kwargs)
166
+
167
+ # Mode: replay
168
+ if self.mode == "replay":
169
+ if not storage.exists(cassette_path):
170
+ raise CassetteNotFoundError(
171
+ f"Cassette not found in replay mode at path: {cassette_path}"
172
+ )
173
+ cassette_obj = storage.load(cassette_path)
174
+ canonical_resp = self._deserialize_canonical_response(cassette_obj.response)
175
+ return adapter.from_canonical_response(canonical_resp, args)
176
+
177
+ # Mode: auto
178
+ if self.mode == "auto":
179
+ if storage.exists(cassette_path):
180
+ cassette_obj = storage.load(cassette_path)
181
+ canonical_resp = self._deserialize_canonical_response(cassette_obj.response)
182
+ return adapter.from_canonical_response(canonical_resp, args)
183
+
184
+ # Mode: record, or auto on cache miss
185
+ start_time = time.perf_counter()
186
+ live_response = await make_live_call_fn(*args, **kwargs)
187
+ latency = (time.perf_counter() - start_time) * 1000.0 # in ms
188
+
189
+ canonical_resp = adapter.to_canonical_response(live_response, **kwargs)
190
+ if canonical_resp.latency is None:
191
+ canonical_resp.latency = latency
192
+
193
+ # Serialize request/response and save to storage
194
+ serialized_req = self._serialize_canonical_request(canonical_req)
195
+ serialized_resp = self._serialize_canonical_response(canonical_resp)
196
+
197
+ cassette_obj = Cassette(
198
+ provider=canonical_req.provider,
199
+ hash=req_hash,
200
+ request=serialized_req,
201
+ response=serialized_resp,
202
+ metadata={"latency_ms": latency},
203
+ )
204
+ storage.save(cassette_obj, cassette_path)
205
+
206
+ return live_response
207
+
208
+ def _serialize_canonical_request(self, req: CanonicalRequest) -> dict[str, Any]:
209
+ return {
210
+ "provider": req.provider,
211
+ "model": req.model,
212
+ "messages": req.messages,
213
+ "temperature": req.temperature,
214
+ "params": req.params,
215
+ }
216
+
217
+ def _serialize_canonical_response(self, resp: CanonicalResponse) -> dict[str, Any]:
218
+ return {
219
+ "provider": resp.provider,
220
+ "output": resp.output,
221
+ "model": resp.model,
222
+ "tool_calls": resp.tool_calls,
223
+ "invalid_tool_calls": resp.invalid_tool_calls,
224
+ "latency": resp.latency,
225
+ "reasoning": resp.reasoning,
226
+ "usage": resp.usage,
227
+ "metadata": resp.metadata,
228
+ }
229
+
230
+ def _deserialize_canonical_response(self, data: dict[str, Any]) -> CanonicalResponse:
231
+ return CanonicalResponse(
232
+ provider=data.get("provider", ""),
233
+ output=data.get("output"),
234
+ model=data.get("model"),
235
+ tool_calls=data.get("tool_calls") or [],
236
+ invalid_tool_calls=data.get("invalid_tool_calls") or [],
237
+ latency=data.get("latency"),
238
+ reasoning=data.get("reasoning"),
239
+ usage=data.get("usage"),
240
+ metadata=data.get("metadata") or {},
241
+ )
sequa/storage.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from typing import Any
6
+
7
+ from sequa.models import Cassette
8
+
9
+
10
+ def save(cassette: Cassette, path: str) -> None:
11
+ """Save a cassette as a JSON file at the specified path."""
12
+ # Ensure directory exists
13
+ dir_name = os.path.dirname(path)
14
+ if dir_name:
15
+ os.makedirs(dir_name, exist_ok=True)
16
+
17
+ with open(path, "w", encoding="utf-8") as f:
18
+ json.dump(cassette.to_dict(), f, indent=4, ensure_ascii=False)
19
+
20
+
21
+ def load(path: str) -> Cassette:
22
+ """Load a cassette from a JSON file at the specified path."""
23
+ with open(path, "r", encoding="utf-8") as f:
24
+ data = json.load(f)
25
+ return Cassette.from_dict(data)
26
+
27
+
28
+ def exists(path: str) -> bool:
29
+ """Check if a cassette file exists at the specified path."""
30
+ return os.path.isfile(path)
31
+
32
+
33
+ def delete(path: str) -> None:
34
+ """Delete the cassette file at the specified path if it exists."""
35
+ if os.path.exists(path):
36
+ os.remove(path)
sequa/utils.py ADDED
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def sort_dict_keys(data: Any) -> Any:
7
+ """Recursively sort dictionary keys for deterministic hashing."""
8
+ if isinstance(data, dict):
9
+ return {k: sort_dict_keys(v) for k, v in sorted(data.items())}
10
+ elif isinstance(data, list):
11
+ return [sort_dict_keys(item) for item in data]
12
+ return data
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.3
2
+ Name: sequa
3
+ Version: 0.0.1
4
+ Summary: Add your description here
5
+ Author: thetechnoadvisor
6
+ Author-email: thetechnoadvisor <thetechnoadvisor@gmail.com>
7
+ Requires-Dist: python-dotenv>=1.0.0
8
+ Requires-Dist: langchain-groq>=0.3.0
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Sequa 📼
13
+
14
+ Sequa is snapshot testing for LLM applications. Record once, replay forever.
15
+
16
+ ---
17
+
18
+ ## The Magic
19
+
20
+ ### Before
21
+ ```python
22
+ from langchain_groq import ChatGroq
23
+
24
+ model = ChatGroq(model_name="llama-3.1-8b-instant")
25
+ response = model.invoke("Write a 3-word slogan for gravity.")
26
+ # ⏱️ Time taken: 2.3 seconds
27
+ ```
28
+
29
+ ### After
30
+ ```python
31
+ from langchain_groq import ChatGroq
32
+ from sequa import cassette
33
+
34
+ model = ChatGroq(model_name="llama-3.1-8b-instant")
35
+
36
+ with cassette("tests/cassettes"):
37
+ response = model.invoke("Write a 3-word slogan for gravity.")
38
+ # ⏱️ First run: 2.3 seconds (recorded to tests/cassettes/)
39
+ # ⏱️ Second run: 12 ms (replayed locally!)
40
+ ```
41
+
42
+ ---
43
+
44
+ ## Features
45
+
46
+ - **Record once, replay forever**: Speed up integration test suites from minutes to milliseconds.
47
+ - **Multiple Execution Modes**: Support `replay`, `record`, `auto`, and `live` modes.
48
+ - **Robust Key Sorting & Hashing**: Recursively sorts request inputs to generate deterministic hashes.
49
+ - **Custom Ignored Fields**: Easily ignore dynamic/unstable fields (e.g. `temperature`, `max_tokens`).
50
+ - **Custom Normalizers**: Redact, replace, or clean requests prior to hashing.
51
+ - **CLI Utilities**: Inspect, format, and calculate statistics of stored cassettes.
52
+
53
+ ---
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ uv pip install -e .
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Configuration & Advanced API
64
+
65
+ ### 1. Execution Modes
66
+
67
+ Control Sequa behavior via the `mode` parameter:
68
+
69
+ ```python
70
+ with cassette("tests/cassettes", mode="replay"):
71
+ # Will raise CassetteNotFoundError if no matching cassette is found.
72
+ # Guaranteed to make zero external network requests.
73
+ ```
74
+
75
+ - `auto` (Default): Replays if a matching cassette exists, otherwise calls the live API and records it.
76
+ - `record`: Always calls the live API and records/overwrites the cassette.
77
+ - `replay`: Never calls the live API. Raises `CassetteNotFoundError` on cache misses.
78
+ - `live`: Direct pass-through to the live API, bypassing cassettes entirely.
79
+
80
+ ### 2. Ignore Fields
81
+
82
+ Strip request parameters before generating hashes:
83
+
84
+ ```python
85
+ with cassette("tests/cassettes", ignore_fields=["temperature", "max_tokens"]):
86
+ # These two calls generate the exact same hash and match the same cassette:
87
+ model.invoke("hello", temperature=0.2)
88
+ model.invoke("hello", temperature=0.9)
89
+ ```
90
+
91
+ ### 3. Custom Normalizers
92
+
93
+ For complex normalization or content redaction:
94
+
95
+ ```python
96
+ def redact_dates(request_dict):
97
+ # Redact dynamic inputs or strip timestamps
98
+ return request_dict
99
+
100
+ with cassette("tests/cassettes", normalizer=redact_dates):
101
+ model.invoke(...)
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Command Line Interface (CLI)
107
+
108
+ Sequa comes with a CLI tool to manage your cassettes.
109
+
110
+ ### Stats
111
+ Show the number of cassettes, total size on disk, and estimated API latency saved:
112
+ ```bash
113
+ sequa stats --path ./tests/cassettes
114
+ ```
115
+
116
+ ### Inspect
117
+ List all stored cassettes, their model, provider, and when they were recorded:
118
+ ```bash
119
+ sequa inspect --path ./tests/cassettes
120
+ ```
121
+
122
+ ### Clean
123
+ Clean dynamic fields (`latency`, `created_at`) from cassettes to prevent noisy git diffs:
124
+ ```bash
125
+ sequa clean --path ./tests/cassettes --remove-latency --remove-timestamps
126
+ ```
127
+
128
+ ---
129
+
130
+ ## License
131
+
132
+ MIT License.
@@ -0,0 +1,18 @@
1
+ sequa/__init__.py,sha256=radOQ-iUQ98KHoBPX-981_1dTxCFfNO3gk2YeUKKWsE,51
2
+ sequa/cassette.py,sha256=zNpejzSnHNwO2hOyh0IyvpUEFyxc9f0AMwMFBnyNPvg,6811
3
+ sequa/cli/main.py,sha256=6w9esnNDcmT8JMgWZs9iMIpdgtrgn915LGKfEGYOmGc,6757
4
+ sequa/llm/adapters/__init__.py,sha256=sbzm9VWXkG9zVLBE5DPjZ1b2y1x1cJkBv1ENhQVe0QY,507
5
+ sequa/llm/adapters/base.py,sha256=lq5EMVnQOrItLercPn8IYPtzltqntDhVcOAnVSt5h5U,2025
6
+ sequa/llm/adapters/chat.py,sha256=LNElOqCvckobKBUr131cLYuRLB1LDx39qoT8LIWVLJY,9247
7
+ sequa/llm/adapters/monkey_patch.py,sha256=eujopQF_wRChZXBqXF7AzNQtPoysWo8Ag8aSN8R8g8M,816
8
+ sequa/llm/adapters/patch_groq.py,sha256=MofDcr0M5U-yCn5CwYyv9FQqghyYUBNI-nzDRDVlwzI,675
9
+ sequa/matcher.py,sha256=30fgPZYZ2e3_wdQVi0inAtc1eq4YoDGhgCK6vX7cJ8k,3779
10
+ sequa/models.py,sha256=uvB6-910-BPYi-fdEOC3vFLTxoDChv7798qjf4jRQ5M,1326
11
+ sequa/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ sequa/recorder.py,sha256=Htja5CG99fyGE3pY-KTNPRjtPGYXzkRUp-y92OBQLtU,9606
13
+ sequa/storage.py,sha256=NlmFT-UmW3m59vpXVq2AiHmjm_JtBoQ6ZeKTdJL1qw4,986
14
+ sequa/utils.py,sha256=aYUGyfEqcQimGXLYYULIVgdh3_Ck2O8bh3R3ThKNtZ4,375
15
+ sequa-0.0.1.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
16
+ sequa-0.0.1.dist-info/entry_points.txt,sha256=xoYq4KZ0oJYxFU2PPH530SVsRNBilkhyF0ErZftrez4,47
17
+ sequa-0.0.1.dist-info/METADATA,sha256=kkbeWL0fYNHgKEU5fcD-l7aq381wQneW6oGE_DPMmoM,3471
18
+ sequa-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.28
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ sequa = sequa.cli.main:main
3
+