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 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)
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.