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 +2 -0
- sequa/cassette.py +175 -0
- sequa/cli/main.py +201 -0
- sequa/llm/adapters/__init__.py +21 -0
- sequa/llm/adapters/base.py +61 -0
- sequa/llm/adapters/chat.py +238 -0
- sequa/llm/adapters/monkey_patch.py +26 -0
- sequa/llm/adapters/patch_groq.py +24 -0
- sequa/matcher.py +104 -0
- sequa/models.py +42 -0
- sequa/py.typed +0 -0
- sequa/recorder.py +241 -0
- sequa/storage.py +36 -0
- sequa/utils.py +12 -0
- sequa-0.0.1.dist-info/METADATA +132 -0
- sequa-0.0.1.dist-info/RECORD +18 -0
- sequa-0.0.1.dist-info/WHEEL +4 -0
- sequa-0.0.1.dist-info/entry_points.txt +3 -0
sequa/__init__.py
ADDED
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,,
|