counterfix 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.
- counterfix/__init__.py +41 -0
- counterfix/adapters/__init__.py +6 -0
- counterfix/adapters/_attach.py +40 -0
- counterfix/adapters/openai_compat.py +206 -0
- counterfix/core/__init__.py +23 -0
- counterfix/core/config.py +41 -0
- counterfix/core/detector.py +61 -0
- counterfix/core/diagnosis.py +74 -0
- counterfix/core/record.py +119 -0
- counterfix/core/types.py +66 -0
- counterfix/detectors/__init__.py +17 -0
- counterfix/py.typed +0 -0
- counterfix-0.0.1.dist-info/METADATA +143 -0
- counterfix-0.0.1.dist-info/RECORD +16 -0
- counterfix-0.0.1.dist-info/WHEEL +4 -0
- counterfix-0.0.1.dist-info/licenses/LICENSE +21 -0
counterfix/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""counterfix: find why an LLM call failed and get a fix that was tested to work."""
|
|
2
|
+
|
|
3
|
+
from counterfix.adapters import GuardedClient, get_diagnosis, guard, record_from_openai
|
|
4
|
+
from counterfix.core import (
|
|
5
|
+
CallRecord,
|
|
6
|
+
Config,
|
|
7
|
+
Detector,
|
|
8
|
+
Diagnosis,
|
|
9
|
+
Evidence,
|
|
10
|
+
FailureType,
|
|
11
|
+
Finding,
|
|
12
|
+
Message,
|
|
13
|
+
Prescription,
|
|
14
|
+
PrescriptionKind,
|
|
15
|
+
SkipCheck,
|
|
16
|
+
Usage,
|
|
17
|
+
run_detectors,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__version__ = "0.0.1"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"CallRecord",
|
|
24
|
+
"Config",
|
|
25
|
+
"Detector",
|
|
26
|
+
"Diagnosis",
|
|
27
|
+
"Evidence",
|
|
28
|
+
"FailureType",
|
|
29
|
+
"Finding",
|
|
30
|
+
"GuardedClient",
|
|
31
|
+
"Message",
|
|
32
|
+
"Prescription",
|
|
33
|
+
"PrescriptionKind",
|
|
34
|
+
"SkipCheck",
|
|
35
|
+
"Usage",
|
|
36
|
+
"__version__",
|
|
37
|
+
"get_diagnosis",
|
|
38
|
+
"guard",
|
|
39
|
+
"record_from_openai",
|
|
40
|
+
"run_detectors",
|
|
41
|
+
]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Adapters that plug counterfix into LLM clients and frameworks."""
|
|
2
|
+
|
|
3
|
+
from counterfix.adapters._attach import get_diagnosis
|
|
4
|
+
from counterfix.adapters.openai_compat import GuardedClient, guard, record_from_openai
|
|
5
|
+
|
|
6
|
+
__all__ = ["GuardedClient", "get_diagnosis", "guard", "record_from_openai"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Attaching a Diagnosis to a provider's response object without changing its type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from counterfix.core.diagnosis import Diagnosis
|
|
9
|
+
|
|
10
|
+
ATTR = "diagnosis"
|
|
11
|
+
_FALLBACK_LIMIT = 2048
|
|
12
|
+
# Objects that refuse new attributes (slots, frozen models) are tracked here by id,
|
|
13
|
+
# together with the object itself so the id cannot be reused while it is stored.
|
|
14
|
+
_fallback: OrderedDict[int, tuple[Any, Diagnosis]] = OrderedDict()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def attach(response: Any, diagnosis: Diagnosis) -> None:
|
|
18
|
+
"""Attach ``diagnosis`` as ``response.diagnosis`` when possible, else remember it."""
|
|
19
|
+
for setter in (setattr, object.__setattr__):
|
|
20
|
+
try:
|
|
21
|
+
setter(response, ATTR, diagnosis)
|
|
22
|
+
if getattr(response, ATTR, None) is diagnosis:
|
|
23
|
+
return
|
|
24
|
+
except Exception:
|
|
25
|
+
continue
|
|
26
|
+
_fallback[id(response)] = (response, diagnosis)
|
|
27
|
+
_fallback.move_to_end(id(response))
|
|
28
|
+
while len(_fallback) > _FALLBACK_LIMIT:
|
|
29
|
+
_fallback.popitem(last=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_diagnosis(response: Any) -> Diagnosis | None:
|
|
33
|
+
"""Return the Diagnosis counterfix attached to ``response``, or None."""
|
|
34
|
+
value = getattr(response, ATTR, None)
|
|
35
|
+
if isinstance(value, Diagnosis):
|
|
36
|
+
return value
|
|
37
|
+
stored = _fallback.get(id(response))
|
|
38
|
+
if stored is not None and stored[0] is response:
|
|
39
|
+
return stored[1]
|
|
40
|
+
return None
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""``guard()`` for OpenAI-compatible clients.
|
|
2
|
+
|
|
3
|
+
Works with the official ``openai`` SDK and with any provider that exposes the same
|
|
4
|
+
``client.chat.completions.create(...)`` shape: OpenAI, Groq, Ollama, vLLM, Together,
|
|
5
|
+
OpenRouter and others. The wrapped client keeps the same API; each response gains a
|
|
6
|
+
``.diagnosis`` attribute (or use :func:`counterfix.get_diagnosis`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable, Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from counterfix.adapters._attach import attach
|
|
18
|
+
from counterfix.core.config import Config
|
|
19
|
+
from counterfix.core.detector import Detector, run_detectors
|
|
20
|
+
from counterfix.core.diagnosis import Diagnosis
|
|
21
|
+
from counterfix.core.record import CallRecord, Message, Usage
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("counterfix")
|
|
24
|
+
|
|
25
|
+
_CREATE_PATH = ("chat", "completions", "create")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get(obj: Any, key: str, default: Any = None) -> Any:
|
|
29
|
+
"""Read ``key`` from a dict or an object attribute."""
|
|
30
|
+
if obj is None:
|
|
31
|
+
return default
|
|
32
|
+
if isinstance(obj, dict):
|
|
33
|
+
return obj.get(key, default)
|
|
34
|
+
return getattr(obj, key, default)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _to_dict(obj: Any) -> dict[str, Any]:
|
|
38
|
+
"""Best-effort conversion of SDK objects to plain dicts."""
|
|
39
|
+
if isinstance(obj, dict):
|
|
40
|
+
return dict(obj)
|
|
41
|
+
dump = getattr(obj, "model_dump", None)
|
|
42
|
+
if callable(dump):
|
|
43
|
+
try:
|
|
44
|
+
result = dump()
|
|
45
|
+
if isinstance(result, dict):
|
|
46
|
+
return result
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
try:
|
|
50
|
+
return dict(vars(obj))
|
|
51
|
+
except TypeError:
|
|
52
|
+
return {"value": repr(obj)}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _usage(raw: Any) -> Usage:
|
|
56
|
+
"""Token usage, including reasoning tokens when the provider reports them."""
|
|
57
|
+
usage = Usage.model_validate(_to_dict(raw))
|
|
58
|
+
details = _get(raw, "completion_tokens_details")
|
|
59
|
+
reasoning_tokens = _get(details, "reasoning_tokens")
|
|
60
|
+
if isinstance(reasoning_tokens, int):
|
|
61
|
+
usage.reasoning_tokens = reasoning_tokens
|
|
62
|
+
return usage
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def record_from_openai(
|
|
66
|
+
request: dict[str, Any],
|
|
67
|
+
response: Any,
|
|
68
|
+
*,
|
|
69
|
+
latency_ms: float | None = None,
|
|
70
|
+
provider: str | None = None,
|
|
71
|
+
) -> CallRecord:
|
|
72
|
+
"""Build a :class:`CallRecord` from chat-completions kwargs and the response."""
|
|
73
|
+
messages = [Message.model_validate(_to_dict(m)) for m in request.get("messages") or []]
|
|
74
|
+
params = {k: v for k, v in request.items() if k not in ("messages", "model")}
|
|
75
|
+
|
|
76
|
+
choices = _get(response, "choices") or []
|
|
77
|
+
first = choices[0] if len(choices) > 0 else None
|
|
78
|
+
message = _get(first, "message")
|
|
79
|
+
raw_tool_calls = _get(message, "tool_calls")
|
|
80
|
+
usage_raw = _get(response, "usage")
|
|
81
|
+
usage = _usage(usage_raw) if usage_raw is not None else None
|
|
82
|
+
# Reasoning models: Groq/OpenAI-style "reasoning", vLLM/DeepSeek-style "reasoning_content".
|
|
83
|
+
reasoning = _get(message, "reasoning") or _get(message, "reasoning_content")
|
|
84
|
+
|
|
85
|
+
return CallRecord(
|
|
86
|
+
provider=provider,
|
|
87
|
+
model=_get(response, "model") or request.get("model"),
|
|
88
|
+
messages=messages,
|
|
89
|
+
params=params,
|
|
90
|
+
response_text=_get(message, "content"),
|
|
91
|
+
reasoning_text=reasoning if isinstance(reasoning, str) else None,
|
|
92
|
+
finish_reason=_get(first, "finish_reason"),
|
|
93
|
+
tool_calls=[_to_dict(t) for t in raw_tool_calls] if raw_tool_calls else None,
|
|
94
|
+
usage=usage,
|
|
95
|
+
latency_ms=latency_ms,
|
|
96
|
+
metadata={"n_choices": len(choices)},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _Guard:
|
|
101
|
+
"""Shared state for one guarded client."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, config: Config, detectors: Sequence[Detector], provider: str | None):
|
|
104
|
+
self.config = config
|
|
105
|
+
self.detectors = list(detectors)
|
|
106
|
+
self.provider = provider
|
|
107
|
+
|
|
108
|
+
def diagnose(self, request: dict[str, Any], response: Any, latency_ms: float) -> None:
|
|
109
|
+
"""Build the record, run detectors, attach the result. Never raises if fail_open."""
|
|
110
|
+
try:
|
|
111
|
+
record = record_from_openai(
|
|
112
|
+
request, response, latency_ms=latency_ms, provider=self.provider
|
|
113
|
+
)
|
|
114
|
+
diagnosis: Diagnosis = run_detectors(record, self.detectors, self.config)
|
|
115
|
+
attach(response, diagnosis)
|
|
116
|
+
if self.config.on_diagnosis is not None:
|
|
117
|
+
self.config.on_diagnosis(record, diagnosis)
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
if not self.config.fail_open:
|
|
120
|
+
raise
|
|
121
|
+
logger.warning("counterfix could not diagnose this call: %s", exc)
|
|
122
|
+
|
|
123
|
+
def wrap_create(self, create: Callable[..., Any]) -> Callable[..., Any]:
|
|
124
|
+
guard = self
|
|
125
|
+
|
|
126
|
+
def guarded_create(*args: Any, **kwargs: Any) -> Any:
|
|
127
|
+
if not guard.config.enabled:
|
|
128
|
+
return create(*args, **kwargs)
|
|
129
|
+
if kwargs.get("stream"):
|
|
130
|
+
# Streams are passed through untouched for now; buffering comes later.
|
|
131
|
+
return create(*args, **kwargs)
|
|
132
|
+
start = time.perf_counter()
|
|
133
|
+
result = create(*args, **kwargs) # the user's own errors propagate unchanged
|
|
134
|
+
if inspect.isawaitable(result):
|
|
135
|
+
return guard._finish_async(result, kwargs, start)
|
|
136
|
+
guard.diagnose(kwargs, result, (time.perf_counter() - start) * 1000)
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
guarded_create.__doc__ = create.__doc__
|
|
140
|
+
guarded_create.__wrapped__ = create # type: ignore[attr-defined]
|
|
141
|
+
return guarded_create
|
|
142
|
+
|
|
143
|
+
async def _finish_async(self, pending: Any, kwargs: dict[str, Any], start: float) -> Any:
|
|
144
|
+
result = await pending
|
|
145
|
+
self.diagnose(kwargs, result, (time.perf_counter() - start) * 1000)
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class GuardedClient:
|
|
150
|
+
"""A transparent proxy: everything behaves like the wrapped client, except that
|
|
151
|
+
``chat.completions.create`` also produces a diagnosis."""
|
|
152
|
+
|
|
153
|
+
__slots__ = ("_cf_guard", "_cf_path", "_cf_target")
|
|
154
|
+
|
|
155
|
+
def __init__(self, target: Any, guard: _Guard, path: tuple[str, ...] = ()):
|
|
156
|
+
object.__setattr__(self, "_cf_target", target)
|
|
157
|
+
object.__setattr__(self, "_cf_guard", guard)
|
|
158
|
+
object.__setattr__(self, "_cf_path", path)
|
|
159
|
+
|
|
160
|
+
def __getattr__(self, name: str) -> Any:
|
|
161
|
+
target = object.__getattribute__(self, "_cf_target")
|
|
162
|
+
guard = object.__getattribute__(self, "_cf_guard")
|
|
163
|
+
path = (*object.__getattribute__(self, "_cf_path"), name)
|
|
164
|
+
value = getattr(target, name)
|
|
165
|
+
if path == _CREATE_PATH and callable(value):
|
|
166
|
+
return guard.wrap_create(value)
|
|
167
|
+
if path == _CREATE_PATH[: len(path)]:
|
|
168
|
+
return GuardedClient(value, guard, path)
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
172
|
+
setattr(object.__getattribute__(self, "_cf_target"), name, value)
|
|
173
|
+
|
|
174
|
+
def __repr__(self) -> str:
|
|
175
|
+
return f"counterfix.guard({object.__getattribute__(self, '_cf_target')!r})"
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def unwrapped(self) -> Any:
|
|
179
|
+
"""The original client."""
|
|
180
|
+
return object.__getattribute__(self, "_cf_target")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def guard(
|
|
184
|
+
client: Any,
|
|
185
|
+
*,
|
|
186
|
+
config: Config | None = None,
|
|
187
|
+
detectors: Sequence[Detector] | None = None,
|
|
188
|
+
provider: str | None = None,
|
|
189
|
+
) -> Any:
|
|
190
|
+
"""Wrap an OpenAI-compatible client so every chat completion is checked.
|
|
191
|
+
|
|
192
|
+
>>> from openai import OpenAI # doctest: +SKIP
|
|
193
|
+
>>> client = guard(OpenAI()) # doctest: +SKIP
|
|
194
|
+
>>> r = client.chat.completions.create(model="gpt-4o-mini", messages=[...]) # doctest: +SKIP
|
|
195
|
+
>>> r.diagnosis.summary() # doctest: +SKIP
|
|
196
|
+
|
|
197
|
+
``detectors`` defaults to the built-in live detectors. ``provider`` is an optional
|
|
198
|
+
label stored on each record (e.g. "groq", "ollama").
|
|
199
|
+
"""
|
|
200
|
+
if isinstance(client, GuardedClient):
|
|
201
|
+
return client
|
|
202
|
+
if detectors is None:
|
|
203
|
+
from counterfix.detectors import default_detectors
|
|
204
|
+
|
|
205
|
+
detectors = default_detectors()
|
|
206
|
+
return GuardedClient(client, _Guard(config or Config(), detectors, provider))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Core, provider-free building blocks."""
|
|
2
|
+
|
|
3
|
+
from counterfix.core.config import Config
|
|
4
|
+
from counterfix.core.detector import Detector, SkipCheck, run_detectors
|
|
5
|
+
from counterfix.core.diagnosis import Diagnosis, Evidence, Finding, Prescription
|
|
6
|
+
from counterfix.core.record import CallRecord, Message, Usage
|
|
7
|
+
from counterfix.core.types import FailureType, PrescriptionKind
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"CallRecord",
|
|
11
|
+
"Config",
|
|
12
|
+
"Detector",
|
|
13
|
+
"Diagnosis",
|
|
14
|
+
"Evidence",
|
|
15
|
+
"FailureType",
|
|
16
|
+
"Finding",
|
|
17
|
+
"Message",
|
|
18
|
+
"Prescription",
|
|
19
|
+
"PrescriptionKind",
|
|
20
|
+
"SkipCheck",
|
|
21
|
+
"Usage",
|
|
22
|
+
"run_detectors",
|
|
23
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Runtime settings for counterfix."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
from counterfix.core.diagnosis import Diagnosis
|
|
11
|
+
from counterfix.core.record import CallRecord
|
|
12
|
+
|
|
13
|
+
DiagnosisHook = Callable[[CallRecord, Diagnosis], None]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Config(BaseModel):
|
|
17
|
+
"""Settings shared by the live guard and the diagnosis engine.
|
|
18
|
+
|
|
19
|
+
The defaults are deliberately safe: live mode only flags failures, never retries,
|
|
20
|
+
makes no extra LLM calls, and never lets an internal error reach the host app.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
24
|
+
|
|
25
|
+
mode: Literal["flag", "retry"] = "flag"
|
|
26
|
+
"""'flag' (default) attaches a diagnosis only; 'retry' may re-run with a known fix."""
|
|
27
|
+
|
|
28
|
+
enabled: bool = True
|
|
29
|
+
"""Set False to turn counterfix off without removing the wrapper."""
|
|
30
|
+
|
|
31
|
+
fail_open: bool = True
|
|
32
|
+
"""If True, internal errors are logged and recorded, never raised."""
|
|
33
|
+
|
|
34
|
+
max_extra_calls: int = Field(default=0, ge=0)
|
|
35
|
+
"""Extra LLM calls allowed per live call. 0 means live mode never calls a model."""
|
|
36
|
+
|
|
37
|
+
judge_model: str | None = None
|
|
38
|
+
"""Model used by LLM-judge detectors (diagnose mode). None until configured."""
|
|
39
|
+
|
|
40
|
+
on_diagnosis: DiagnosisHook | None = None
|
|
41
|
+
"""Optional callback run after every diagnosis, e.g. to log or store it."""
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The detector interface and the runner that applies detectors to a call."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Iterable, Sequence
|
|
7
|
+
from typing import Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from counterfix.core.config import Config
|
|
10
|
+
from counterfix.core.diagnosis import Diagnosis, Finding
|
|
11
|
+
from counterfix.core.record import CallRecord
|
|
12
|
+
from counterfix.core.types import FailureType
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("counterfix")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SkipCheck(Exception):
|
|
18
|
+
"""Raised by a detector that cannot run on this call (e.g. missing capability)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class Detector(Protocol):
|
|
23
|
+
"""Anything with a name, the failure types it covers, and a ``check`` method.
|
|
24
|
+
|
|
25
|
+
``check`` returns the findings for one call (an empty list when the call looks
|
|
26
|
+
fine) or raises :class:`SkipCheck` when it cannot judge this call.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
covers: tuple[FailureType, ...]
|
|
31
|
+
|
|
32
|
+
def check(self, record: CallRecord, config: Config) -> list[Finding]: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_detectors(
|
|
36
|
+
record: CallRecord,
|
|
37
|
+
detectors: Iterable[Detector],
|
|
38
|
+
config: Config,
|
|
39
|
+
) -> Diagnosis:
|
|
40
|
+
"""Run each detector on the record and collect a :class:`Diagnosis`.
|
|
41
|
+
|
|
42
|
+
A detector that raises is recorded under ``errors`` and the others still run,
|
|
43
|
+
so one broken check never hides the rest or breaks the host application.
|
|
44
|
+
"""
|
|
45
|
+
diagnosis = Diagnosis(record_id=record.id)
|
|
46
|
+
for detector in detectors:
|
|
47
|
+
name = getattr(detector, "name", type(detector).__name__)
|
|
48
|
+
try:
|
|
49
|
+
findings: Sequence[Finding] = detector.check(record, config)
|
|
50
|
+
except SkipCheck as skip:
|
|
51
|
+
diagnosis.skipped[name] = str(skip) or "not applicable"
|
|
52
|
+
continue
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
if not config.fail_open:
|
|
55
|
+
raise
|
|
56
|
+
logger.warning("counterfix detector %s failed: %s", name, exc)
|
|
57
|
+
diagnosis.errors[name] = f"{type(exc).__name__}: {exc}"
|
|
58
|
+
continue
|
|
59
|
+
diagnosis.checked.append(name)
|
|
60
|
+
diagnosis.findings.extend(findings)
|
|
61
|
+
return diagnosis
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""What counterfix returns: findings, their evidence, and prescriptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from counterfix.core.types import FailureType, PrescriptionKind
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Evidence(BaseModel):
|
|
13
|
+
"""Why a detector believes a failure happened."""
|
|
14
|
+
|
|
15
|
+
detector: str
|
|
16
|
+
detail: str
|
|
17
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Prescription(BaseModel):
|
|
21
|
+
"""A suggested change. ``verified`` is True only after a counterfactual re-run passed."""
|
|
22
|
+
|
|
23
|
+
kind: PrescriptionKind
|
|
24
|
+
description: str
|
|
25
|
+
patch: dict[str, Any] = Field(default_factory=dict)
|
|
26
|
+
verified: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Finding(BaseModel):
|
|
30
|
+
"""One suspected failure in a call."""
|
|
31
|
+
|
|
32
|
+
failure_type: FailureType
|
|
33
|
+
confidence: float = Field(ge=0.0, le=1.0)
|
|
34
|
+
evidence: list[Evidence] = Field(default_factory=list)
|
|
35
|
+
prescription: Prescription | None = None
|
|
36
|
+
inferred: bool = False # True when the signal was indirect (e.g. no stop reason)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Diagnosis(BaseModel):
|
|
40
|
+
"""The result of checking one call.
|
|
41
|
+
|
|
42
|
+
``checked`` lists the detectors that ran, ``skipped`` those that could not run
|
|
43
|
+
(with the reason), and ``errors`` any internal errors. Internal errors never
|
|
44
|
+
reach the user's application; they are recorded here instead.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
record_id: str
|
|
48
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
49
|
+
checked: list[str] = Field(default_factory=list)
|
|
50
|
+
skipped: dict[str, str] = Field(default_factory=dict)
|
|
51
|
+
errors: dict[str, str] = Field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def ok(self) -> bool:
|
|
55
|
+
"""True when no failure was found."""
|
|
56
|
+
return not self.findings
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def top(self) -> Finding | None:
|
|
60
|
+
"""The most confident finding, or None."""
|
|
61
|
+
if not self.findings:
|
|
62
|
+
return None
|
|
63
|
+
return max(self.findings, key=lambda f: f.confidence)
|
|
64
|
+
|
|
65
|
+
def summary(self) -> str:
|
|
66
|
+
"""A one-line human summary."""
|
|
67
|
+
if self.ok:
|
|
68
|
+
note = f" ({len(self.errors)} internal errors)" if self.errors else ""
|
|
69
|
+
return f"no failure found by {len(self.checked)} checks{note}"
|
|
70
|
+
parts = [
|
|
71
|
+
f"{f.failure_type.value} {f.failure_type.label} ({f.confidence:.0%})"
|
|
72
|
+
for f in sorted(self.findings, key=lambda f: -f.confidence)
|
|
73
|
+
]
|
|
74
|
+
return "; ".join(parts)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The provider-neutral record of one LLM call.
|
|
2
|
+
|
|
3
|
+
Everything inside counterfix works on a :class:`CallRecord`. Adapters convert each
|
|
4
|
+
provider's request and response into one, so the core never imports a provider SDK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Message(BaseModel):
|
|
17
|
+
"""One chat message. ``content`` may be text, a list of parts, or None."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(extra="allow")
|
|
20
|
+
|
|
21
|
+
role: str
|
|
22
|
+
content: Any = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def text(self) -> str:
|
|
26
|
+
"""The message's text, joining text parts when content is a list."""
|
|
27
|
+
if self.content is None:
|
|
28
|
+
return ""
|
|
29
|
+
if isinstance(self.content, str):
|
|
30
|
+
return self.content
|
|
31
|
+
if isinstance(self.content, list):
|
|
32
|
+
parts: list[str] = []
|
|
33
|
+
for part in self.content:
|
|
34
|
+
if isinstance(part, str):
|
|
35
|
+
parts.append(part)
|
|
36
|
+
elif isinstance(part, dict) and isinstance(part.get("text"), str):
|
|
37
|
+
parts.append(part["text"])
|
|
38
|
+
return "\n".join(parts)
|
|
39
|
+
return str(self.content)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Usage(BaseModel):
|
|
43
|
+
"""Token counts, when the provider returns them."""
|
|
44
|
+
|
|
45
|
+
model_config = ConfigDict(extra="allow")
|
|
46
|
+
|
|
47
|
+
prompt_tokens: int | None = None
|
|
48
|
+
completion_tokens: int | None = None
|
|
49
|
+
total_tokens: int | None = None
|
|
50
|
+
reasoning_tokens: int | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CallRecord(BaseModel):
|
|
54
|
+
"""A single LLM call: what was asked, with which settings, and what came back."""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(extra="allow")
|
|
57
|
+
|
|
58
|
+
id: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
|
59
|
+
provider: str | None = None
|
|
60
|
+
model: str | None = None
|
|
61
|
+
messages: list[Message] = Field(default_factory=list)
|
|
62
|
+
params: dict[str, Any] = Field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
response_text: str | None = None
|
|
65
|
+
reasoning_text: str | None = None
|
|
66
|
+
"""Hidden "thinking" returned by reasoning models, when the provider exposes it."""
|
|
67
|
+
finish_reason: str | None = None
|
|
68
|
+
tool_calls: list[dict[str, Any]] | None = None
|
|
69
|
+
usage: Usage | None = None
|
|
70
|
+
latency_ms: float | None = None
|
|
71
|
+
|
|
72
|
+
error: str | None = None
|
|
73
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def system_text(self) -> str:
|
|
77
|
+
"""All system messages joined, or an empty string."""
|
|
78
|
+
return "\n".join(m.text for m in self.messages if m.role in ("system", "developer"))
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def prompt_text(self) -> str:
|
|
82
|
+
"""The text of the last user message, or an empty string."""
|
|
83
|
+
for message in reversed(self.messages):
|
|
84
|
+
if message.role == "user":
|
|
85
|
+
return message.text
|
|
86
|
+
return ""
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def answer_text(self) -> str:
|
|
90
|
+
"""The visible answer, with inline reasoning removed.
|
|
91
|
+
|
|
92
|
+
Handles the tag styles models use for inline thinking: <think> (Qwen, DeepSeek),
|
|
93
|
+
<thought> (Gemma) and <thinking>. An unclosed tag (cut off mid-thought) removes
|
|
94
|
+
everything after it.
|
|
95
|
+
"""
|
|
96
|
+
return _THINK.sub("", self.response_text or "").strip()
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def has_reasoning(self) -> bool:
|
|
100
|
+
"""True when the model spent tokens on reasoning (separate field or inline tags)."""
|
|
101
|
+
return bool(
|
|
102
|
+
self.reasoning_text
|
|
103
|
+
or (self.usage is not None and (self.usage.reasoning_tokens or 0) > 0)
|
|
104
|
+
or _THINK_OPEN.search(self.response_text or "")
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def max_tokens(self) -> int | None:
|
|
109
|
+
"""The output-token limit the call used, if one was set."""
|
|
110
|
+
for key in ("max_tokens", "max_completion_tokens", "max_output_tokens"):
|
|
111
|
+
value = self.params.get(key)
|
|
112
|
+
if isinstance(value, int):
|
|
113
|
+
return value
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_TAGS = r"(think|thought|thinking)"
|
|
118
|
+
_THINK = re.compile(rf"<{_TAGS}>.*?(</\1>|$)", re.DOTALL | re.IGNORECASE)
|
|
119
|
+
_THINK_OPEN = re.compile(rf"<{_TAGS}>", re.IGNORECASE)
|
counterfix/core/types.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Failure types and small shared enums."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FailureType(str, Enum):
|
|
9
|
+
"""The failure taxonomy (v1: F1 to F9) plus non-model categories.
|
|
10
|
+
|
|
11
|
+
``INFRASTRUCTURE``, ``PROVIDER_FILTER`` and ``EMPTY_OUTPUT`` are kept apart from
|
|
12
|
+
the model failure types so that API errors or provider filtering are never
|
|
13
|
+
counted as the model's fault.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
TRUNCATION = "F1"
|
|
17
|
+
FORMAT_BREAK = "F2"
|
|
18
|
+
OVER_REFUSAL = "F3"
|
|
19
|
+
HALLUCINATION = "F4"
|
|
20
|
+
FALSE_PREMISE = "F5"
|
|
21
|
+
DROPPED_INSTRUCTION = "F6"
|
|
22
|
+
PROMPT_FRAGILITY = "F7"
|
|
23
|
+
COMPUTATION = "F8"
|
|
24
|
+
AMBIGUITY = "F9"
|
|
25
|
+
|
|
26
|
+
INFRASTRUCTURE = "INFRA"
|
|
27
|
+
PROVIDER_FILTER = "FILTER"
|
|
28
|
+
EMPTY_OUTPUT = "EMPTY"
|
|
29
|
+
UNCLASSIFIED = "UNCLASSIFIED"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_model_failure(self) -> bool:
|
|
33
|
+
"""True for F1 to F9, the failures that belong to the model's behaviour."""
|
|
34
|
+
return self.value.startswith("F") and self.value[1:].isdigit()
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def label(self) -> str:
|
|
38
|
+
return _LABELS[self]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_LABELS: dict[FailureType, str] = {
|
|
42
|
+
FailureType.TRUNCATION: "Truncation",
|
|
43
|
+
FailureType.FORMAT_BREAK: "Format break (JSON, schema)",
|
|
44
|
+
FailureType.OVER_REFUSAL: "Over-refusal",
|
|
45
|
+
FailureType.HALLUCINATION: "Hallucination from missing knowledge",
|
|
46
|
+
FailureType.FALSE_PREMISE: "False-premise acceptance",
|
|
47
|
+
FailureType.DROPPED_INSTRUCTION: "Dropped instruction",
|
|
48
|
+
FailureType.PROMPT_FRAGILITY: "Prompt fragility",
|
|
49
|
+
FailureType.COMPUTATION: "Computation or token-level error",
|
|
50
|
+
FailureType.AMBIGUITY: "Ambiguous request",
|
|
51
|
+
FailureType.INFRASTRUCTURE: "Infrastructure error (not a model failure)",
|
|
52
|
+
FailureType.PROVIDER_FILTER: "Provider content filter (not a model failure)",
|
|
53
|
+
FailureType.EMPTY_OUTPUT: "Empty output",
|
|
54
|
+
FailureType.UNCLASSIFIED: "Unclassified failure",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PrescriptionKind(str, Enum):
|
|
59
|
+
"""What kind of change a prescription asks the user to make."""
|
|
60
|
+
|
|
61
|
+
PARAM_CHANGE = "param_change" # e.g. raise max_tokens, enable JSON mode
|
|
62
|
+
PROMPT_CHANGE = "prompt_change" # e.g. restructure, add "check premises"
|
|
63
|
+
ADD_CONTEXT = "add_context" # e.g. add retrieval
|
|
64
|
+
USE_TOOL = "use_tool" # e.g. route arithmetic to a code tool
|
|
65
|
+
ASK_USER = "ask_user" # e.g. ask a clarifying question (F9)
|
|
66
|
+
ESCALATE = "escalate" # e.g. larger model, abstain; no prompt-level fix
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Built-in detectors.
|
|
2
|
+
|
|
3
|
+
Phase 1 ships the detector interface only. The cheap live detectors (F1 truncation,
|
|
4
|
+
F2 format, F3 refusal, plus empty output and provider filtering) arrive in Phase 2.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from counterfix.core.detector import Detector
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_detectors() -> list[Detector]:
|
|
13
|
+
"""The detectors ``guard()`` uses when none are passed. Empty until Phase 2."""
|
|
14
|
+
return []
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["default_detectors"]
|
counterfix/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: counterfix
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Find why an LLM call failed and get a fix that was tested to work.
|
|
5
|
+
Project-URL: Homepage, https://github.com/KANSADWALA/CounterFix
|
|
6
|
+
Project-URL: Issues, https://github.com/KANSADWALA/CounterFix/issues
|
|
7
|
+
Author: Shubham Kansadwala
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: counterfactual,debugging,evaluation,hallucination,llm,reliability
|
|
11
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: json-repair>=0.30
|
|
19
|
+
Requires-Dist: jsonschema>=4.20
|
|
20
|
+
Requires-Dist: pydantic>=2.7
|
|
21
|
+
Requires-Dist: tenacity>=8.2
|
|
22
|
+
Provides-Extra: anthropic
|
|
23
|
+
Requires-Dist: anthropic>=0.40; extra == 'anthropic'
|
|
24
|
+
Provides-Extra: cache
|
|
25
|
+
Requires-Dist: diskcache>=5.6; extra == 'cache'
|
|
26
|
+
Provides-Extra: cli
|
|
27
|
+
Requires-Dist: rich>=13.7; extra == 'cli'
|
|
28
|
+
Requires-Dist: typer>=0.12; extra == 'cli'
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-recording>=0.13; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
34
|
+
Provides-Extra: jev
|
|
35
|
+
Requires-Dist: typesafe-sdk>=0.7; extra == 'jev'
|
|
36
|
+
Provides-Extra: langchain
|
|
37
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
38
|
+
Provides-Extra: litellm
|
|
39
|
+
Requires-Dist: litellm>=1.50; extra == 'litellm'
|
|
40
|
+
Provides-Extra: llamaindex
|
|
41
|
+
Requires-Dist: llama-index-core>=0.11; extra == 'llamaindex'
|
|
42
|
+
Provides-Extra: openai
|
|
43
|
+
Requires-Dist: openai>=1.40; extra == 'openai'
|
|
44
|
+
Provides-Extra: otel
|
|
45
|
+
Requires-Dist: opentelemetry-sdk>=1.27; extra == 'otel'
|
|
46
|
+
Provides-Extra: report
|
|
47
|
+
Requires-Dist: jinja2>=3.1; extra == 'report'
|
|
48
|
+
Provides-Extra: semantic
|
|
49
|
+
Requires-Dist: torch>=2.2; extra == 'semantic'
|
|
50
|
+
Requires-Dist: transformers>=4.44; extra == 'semantic'
|
|
51
|
+
Description-Content-Type: text/markdown
|
|
52
|
+
|
|
53
|
+
# counterfix
|
|
54
|
+
|
|
55
|
+
**Find why an LLM call failed, and get a fix that was tested to work.**
|
|
56
|
+
|
|
57
|
+
Most LLM evaluation tools tell you *that* a model failed. counterfix is built to tell you
|
|
58
|
+
*why*, and to hand back a fix it has already checked. When a call fails, it changes one
|
|
59
|
+
thing at a time (adds context, removes a false premise, rewords the prompt, gives the
|
|
60
|
+
model a tool, raises `max_tokens`) and re-runs the call. The change that fixes the
|
|
61
|
+
failure is both the diagnosis and the prescription. We call this **counterfactual
|
|
62
|
+
diagnosis**.
|
|
63
|
+
|
|
64
|
+
> **Status: pre-alpha (v0.0.1).** The core records, the `guard()` wrapper and the seed
|
|
65
|
+
> failure set are in place. Detectors and the counterfactual engine are being built.
|
|
66
|
+
> APIs will change.
|
|
67
|
+
|
|
68
|
+
## What it plugs into
|
|
69
|
+
|
|
70
|
+
counterfix is middleware: it sits between your code and any text-in, text-out LLM.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from openai import OpenAI
|
|
74
|
+
from counterfix import guard
|
|
75
|
+
|
|
76
|
+
client = guard(OpenAI()) # same API as before
|
|
77
|
+
response = client.chat.completions.create(
|
|
78
|
+
model="gpt-4o-mini",
|
|
79
|
+
messages=[{"role": "user", "content": "Why did Einstein win his third Nobel Prize?"}],
|
|
80
|
+
)
|
|
81
|
+
print(response.choices[0].message.content)
|
|
82
|
+
print(response.diagnosis.summary()) # what counterfix found
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The same wrapper works with any OpenAI-compatible endpoint, for example Groq or a local
|
|
86
|
+
Ollama server:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
client = guard(OpenAI(base_url="http://localhost:11434/v1", api_key="ollama"))
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Design rules:
|
|
93
|
+
|
|
94
|
+
- **Fail open.** If counterfix itself errors, your call still returns normally.
|
|
95
|
+
- **Your errors stay yours.** Exceptions from the provider reach your code unchanged.
|
|
96
|
+
- **Safe defaults.** Live mode only flags failures and makes no extra LLM calls.
|
|
97
|
+
- **Light core.** The core depends on 4 small libraries; providers and heavy tools are extras.
|
|
98
|
+
|
|
99
|
+
## Failure types (v1)
|
|
100
|
+
|
|
101
|
+
| Code | Failure |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| F1 | Truncation |
|
|
104
|
+
| F2 | Format break (JSON, schema) |
|
|
105
|
+
| F3 | Over-refusal |
|
|
106
|
+
| F4 | Hallucination from missing knowledge |
|
|
107
|
+
| F5 | False-premise acceptance |
|
|
108
|
+
| F6 | Dropped instruction |
|
|
109
|
+
| F7 | Prompt fragility |
|
|
110
|
+
| F8 | Computation or token-level error |
|
|
111
|
+
| F9 | Ambiguous request |
|
|
112
|
+
|
|
113
|
+
API errors, rate limits and provider content filters are tracked separately, so they are
|
|
114
|
+
never counted as the model's fault.
|
|
115
|
+
|
|
116
|
+
## Install (development)
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
git clone https://github.com/KANSADWALA/CounterFix.git
|
|
120
|
+
cd CounterFix
|
|
121
|
+
pip install -e ".[dev]"
|
|
122
|
+
pytest
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Optional extras: `openai`, `anthropic`, `litellm`, `langchain`, `llamaindex`, `otel`,
|
|
126
|
+
`semantic`, `cache`, `cli`, `report`, `jev`.
|
|
127
|
+
|
|
128
|
+
## Roadmap
|
|
129
|
+
|
|
130
|
+
1. Foundations and seed failure set ✅
|
|
131
|
+
2. Core records and the `guard()` plug ✅
|
|
132
|
+
3. Cheap live detectors (F1 to F3) and the prompt linter
|
|
133
|
+
4. LLM-judge and sampling detectors
|
|
134
|
+
5. Diagnose mode and HTML report
|
|
135
|
+
6. Counterfactual engine with verified fixes
|
|
136
|
+
7. Model failure profiles
|
|
137
|
+
8. More plugs: decorator, Anthropic, LiteLLM, LangChain, OpenTelemetry
|
|
138
|
+
9. Evaluation on public benchmarks
|
|
139
|
+
10. Release and paper
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
counterfix/__init__.py,sha256=WRIy8CRT8dGLfCV5sC9Mp8JtFjVAqd_SJrq8Zbztqok,768
|
|
2
|
+
counterfix/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
counterfix/adapters/__init__.py,sha256=v1HMre8Ma3K0C8WhzVd623tjCo0_4Cbytz8KPkLp7_o,288
|
|
4
|
+
counterfix/adapters/_attach.py,sha256=Y5aBsrid0ki6Zpby-eKRVrcuyJfgAFAJnZXTWkDxAxQ,1435
|
|
5
|
+
counterfix/adapters/openai_compat.py,sha256=aWFZuV702dI-gF_pFr8tpKc9f6td2Up2SpVT8IC7ge0,7869
|
|
6
|
+
counterfix/core/__init__.py,sha256=ygkjZBEiTJxqtkaTuwc07l8D3Qu-gpJ4YPiaJU_U4RI,605
|
|
7
|
+
counterfix/core/config.py,sha256=1jENGUwsL9V8K99P1f7Imx1uNuSTRqQgx2I3i-js85w,1399
|
|
8
|
+
counterfix/core/detector.py,sha256=BydbM_8oGgmlCduN3RtSrAcUyDENSmWjqUBrHUxpBgs,2067
|
|
9
|
+
counterfix/core/diagnosis.py,sha256=R2Rwn60ci2JtJogbxIGOzg-bsRVj3c--3c1tQLAFOl0,2367
|
|
10
|
+
counterfix/core/record.py,sha256=0suFx5r53CFn6Wwpyb4hhopSL1ABzCS2p2SO_eVpIck,3989
|
|
11
|
+
counterfix/core/types.py,sha256=DHgJOvMzoA40c8tCgMX2YTWTM9KQrzr3quLLnDvmmzQ,2369
|
|
12
|
+
counterfix/detectors/__init__.py,sha256=SAjOJ2h0ad6h61DC7VdXb0i7NJk3wfelRw5x096YdRc,456
|
|
13
|
+
counterfix-0.0.1.dist-info/METADATA,sha256=8wUSncTn5lAz2IN4r5lOe8RC6SL9FeIgDKno_XiTyJc,4965
|
|
14
|
+
counterfix-0.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
15
|
+
counterfix-0.0.1.dist-info/licenses/LICENSE,sha256=85-Dud9Oh3wzc3TUrRb0OAG4sHuVYYmm9SL5jIP4Xos,1075
|
|
16
|
+
counterfix-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shubham Kansadwala
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|