sequa 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sequa-0.0.1/PKG-INFO +132 -0
- sequa-0.0.1/README.md +121 -0
- sequa-0.0.1/pyproject.toml +20 -0
- sequa-0.0.1/src/sequa/__init__.py +2 -0
- sequa-0.0.1/src/sequa/cassette.py +175 -0
- sequa-0.0.1/src/sequa/cli/main.py +201 -0
- sequa-0.0.1/src/sequa/llm/adapters/__init__.py +21 -0
- sequa-0.0.1/src/sequa/llm/adapters/base.py +61 -0
- sequa-0.0.1/src/sequa/llm/adapters/chat.py +238 -0
- sequa-0.0.1/src/sequa/llm/adapters/monkey_patch.py +26 -0
- sequa-0.0.1/src/sequa/llm/adapters/patch_groq.py +24 -0
- sequa-0.0.1/src/sequa/matcher.py +104 -0
- sequa-0.0.1/src/sequa/models.py +42 -0
- sequa-0.0.1/src/sequa/py.typed +0 -0
- sequa-0.0.1/src/sequa/recorder.py +241 -0
- sequa-0.0.1/src/sequa/storage.py +36 -0
- sequa-0.0.1/src/sequa/utils.py +12 -0
sequa-0.0.1/PKG-INFO
ADDED
|
@@ -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.
|
sequa-0.0.1/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Sequa 📼
|
|
2
|
+
|
|
3
|
+
Sequa is snapshot testing for LLM applications. Record once, replay forever.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## The Magic
|
|
8
|
+
|
|
9
|
+
### Before
|
|
10
|
+
```python
|
|
11
|
+
from langchain_groq import ChatGroq
|
|
12
|
+
|
|
13
|
+
model = ChatGroq(model_name="llama-3.1-8b-instant")
|
|
14
|
+
response = model.invoke("Write a 3-word slogan for gravity.")
|
|
15
|
+
# ⏱️ Time taken: 2.3 seconds
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### After
|
|
19
|
+
```python
|
|
20
|
+
from langchain_groq import ChatGroq
|
|
21
|
+
from sequa import cassette
|
|
22
|
+
|
|
23
|
+
model = ChatGroq(model_name="llama-3.1-8b-instant")
|
|
24
|
+
|
|
25
|
+
with cassette("tests/cassettes"):
|
|
26
|
+
response = model.invoke("Write a 3-word slogan for gravity.")
|
|
27
|
+
# ⏱️ First run: 2.3 seconds (recorded to tests/cassettes/)
|
|
28
|
+
# ⏱️ Second run: 12 ms (replayed locally!)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- **Record once, replay forever**: Speed up integration test suites from minutes to milliseconds.
|
|
36
|
+
- **Multiple Execution Modes**: Support `replay`, `record`, `auto`, and `live` modes.
|
|
37
|
+
- **Robust Key Sorting & Hashing**: Recursively sorts request inputs to generate deterministic hashes.
|
|
38
|
+
- **Custom Ignored Fields**: Easily ignore dynamic/unstable fields (e.g. `temperature`, `max_tokens`).
|
|
39
|
+
- **Custom Normalizers**: Redact, replace, or clean requests prior to hashing.
|
|
40
|
+
- **CLI Utilities**: Inspect, format, and calculate statistics of stored cassettes.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv pip install -e .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Configuration & Advanced API
|
|
53
|
+
|
|
54
|
+
### 1. Execution Modes
|
|
55
|
+
|
|
56
|
+
Control Sequa behavior via the `mode` parameter:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
with cassette("tests/cassettes", mode="replay"):
|
|
60
|
+
# Will raise CassetteNotFoundError if no matching cassette is found.
|
|
61
|
+
# Guaranteed to make zero external network requests.
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- `auto` (Default): Replays if a matching cassette exists, otherwise calls the live API and records it.
|
|
65
|
+
- `record`: Always calls the live API and records/overwrites the cassette.
|
|
66
|
+
- `replay`: Never calls the live API. Raises `CassetteNotFoundError` on cache misses.
|
|
67
|
+
- `live`: Direct pass-through to the live API, bypassing cassettes entirely.
|
|
68
|
+
|
|
69
|
+
### 2. Ignore Fields
|
|
70
|
+
|
|
71
|
+
Strip request parameters before generating hashes:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
with cassette("tests/cassettes", ignore_fields=["temperature", "max_tokens"]):
|
|
75
|
+
# These two calls generate the exact same hash and match the same cassette:
|
|
76
|
+
model.invoke("hello", temperature=0.2)
|
|
77
|
+
model.invoke("hello", temperature=0.9)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 3. Custom Normalizers
|
|
81
|
+
|
|
82
|
+
For complex normalization or content redaction:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
def redact_dates(request_dict):
|
|
86
|
+
# Redact dynamic inputs or strip timestamps
|
|
87
|
+
return request_dict
|
|
88
|
+
|
|
89
|
+
with cassette("tests/cassettes", normalizer=redact_dates):
|
|
90
|
+
model.invoke(...)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Command Line Interface (CLI)
|
|
96
|
+
|
|
97
|
+
Sequa comes with a CLI tool to manage your cassettes.
|
|
98
|
+
|
|
99
|
+
### Stats
|
|
100
|
+
Show the number of cassettes, total size on disk, and estimated API latency saved:
|
|
101
|
+
```bash
|
|
102
|
+
sequa stats --path ./tests/cassettes
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Inspect
|
|
106
|
+
List all stored cassettes, their model, provider, and when they were recorded:
|
|
107
|
+
```bash
|
|
108
|
+
sequa inspect --path ./tests/cassettes
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Clean
|
|
112
|
+
Clean dynamic fields (`latency`, `created_at`) from cassettes to prevent noisy git diffs:
|
|
113
|
+
```bash
|
|
114
|
+
sequa clean --path ./tests/cassettes --remove-latency --remove-timestamps
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT License.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sequa"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "thetechnoadvisor", email = "thetechnoadvisor@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"python-dotenv>=1.0.0",
|
|
12
|
+
"langchain-groq>=0.3.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
sequa = "sequa.cli.main:main"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.9.28,<0.10.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
@@ -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
|
|
@@ -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
|
+
]
|