ErrorAI 1.9.1__tar.gz → 1.9.2__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.
- {errorai-1.9.1 → errorai-1.9.2}/ErrorAI.egg-info/PKG-INFO +1 -1
- {errorai-1.9.1 → errorai-1.9.2}/PKG-INFO +1 -1
- errorai-1.9.2/errorai/bootstrap.py +218 -0
- {errorai-1.9.1 → errorai-1.9.2}/errorai/runtime.py +5 -1
- {errorai-1.9.1 → errorai-1.9.2}/pyproject.toml +1 -1
- errorai-1.9.1/errorai/bootstrap.py +0 -100
- {errorai-1.9.1 → errorai-1.9.2}/ErrorAI.egg-info/SOURCES.txt +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/ErrorAI.egg-info/dependency_links.txt +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/ErrorAI.egg-info/entry_points.txt +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/ErrorAI.egg-info/requires.txt +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/ErrorAI.egg-info/top_level.txt +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/README.md +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/errorai/__init__.py +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/errorai/__main__.py +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/errorai/cli.py +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/errorai/compat.py +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/errorai/config.py +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/errorai/environment.py +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/errorai/pipeline.py +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/errorai/providers.py +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/errorai/universal.py +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/setup.cfg +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/tests/test_bootstrap.py +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/tests/test_config.py +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/tests/test_runtime.py +0 -0
- {errorai-1.9.1 → errorai-1.9.2}/tests/test_safety.py +0 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from .config import ModelConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ModelProvider(ABC):
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def suggest_patch(self, snippet: str, error_message: str) -> str | None:
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RulesOnlyProvider(ModelProvider):
|
|
19
|
+
def suggest_patch(self, snippet: str, error_message: str) -> str | None:
|
|
20
|
+
lowered = error_message.lower()
|
|
21
|
+
if "expected ':'" in lowered and not snippet.rstrip().endswith(":"):
|
|
22
|
+
return f"{snippet.rstrip()}:"
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class LlamaCppProvider(ModelProvider):
|
|
28
|
+
config: ModelConfig
|
|
29
|
+
model_path: Path
|
|
30
|
+
_llm: object | None = None
|
|
31
|
+
|
|
32
|
+
def _get_llm(self):
|
|
33
|
+
if self._llm is not None:
|
|
34
|
+
return self._llm
|
|
35
|
+
from llama_cpp import Llama # type: ignore
|
|
36
|
+
|
|
37
|
+
self._llm = Llama(
|
|
38
|
+
model_path=str(self.model_path),
|
|
39
|
+
n_ctx=self.config.context_window,
|
|
40
|
+
verbose=False,
|
|
41
|
+
)
|
|
42
|
+
return self._llm
|
|
43
|
+
|
|
44
|
+
def suggest_patch(self, snippet: str, error_message: str) -> str | None:
|
|
45
|
+
llm = self._get_llm()
|
|
46
|
+
prompt = (
|
|
47
|
+
"You are fixing Python code. Return only the fixed single line.\n"
|
|
48
|
+
f"Error: {error_message}\n"
|
|
49
|
+
f"Code: {snippet}\n"
|
|
50
|
+
"Fix:"
|
|
51
|
+
)
|
|
52
|
+
response = llm(
|
|
53
|
+
prompt=prompt,
|
|
54
|
+
temperature=self.config.temperature,
|
|
55
|
+
max_tokens=128,
|
|
56
|
+
stop=["\n\n"],
|
|
57
|
+
)
|
|
58
|
+
text = response["choices"][0]["text"].strip()
|
|
59
|
+
return text or None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class OnnxProvider(ModelProvider):
|
|
64
|
+
config: ModelConfig
|
|
65
|
+
model_path: Path
|
|
66
|
+
_model: object | None = None
|
|
67
|
+
_tokenizer: object | None = None
|
|
68
|
+
|
|
69
|
+
def _get_model(self):
|
|
70
|
+
if self._model is not None and self._tokenizer is not None:
|
|
71
|
+
return self._model, self._tokenizer
|
|
72
|
+
|
|
73
|
+
# optimum wraps onnxruntime with a HF-style generate() API, so we
|
|
74
|
+
# don't have to hand-roll KV-cache decoding against a raw .onnx graph.
|
|
75
|
+
from optimum.onnxruntime import ORTModelForCausalLM # type: ignore
|
|
76
|
+
from transformers import AutoTokenizer # type: ignore
|
|
77
|
+
|
|
78
|
+
model_dir = self.model_path if self.model_path.is_dir() else self.model_path.parent
|
|
79
|
+
|
|
80
|
+
self._tokenizer = AutoTokenizer.from_pretrained(model_dir)
|
|
81
|
+
self._model = ORTModelForCausalLM.from_pretrained(
|
|
82
|
+
model_dir,
|
|
83
|
+
subfolder="onnx",
|
|
84
|
+
file_name="model_int8.onnx",
|
|
85
|
+
use_cache=True,
|
|
86
|
+
)
|
|
87
|
+
return self._model, self._tokenizer
|
|
88
|
+
|
|
89
|
+
def suggest_patch(self, snippet: str, error_message: str) -> str | None:
|
|
90
|
+
model, tokenizer = self._get_model()
|
|
91
|
+
|
|
92
|
+
prompt = (
|
|
93
|
+
"You are fixing Python code. Return only the fixed single line.\n"
|
|
94
|
+
f"Error: {error_message}\n"
|
|
95
|
+
f"Code: {snippet}\n"
|
|
96
|
+
"Fix:"
|
|
97
|
+
)
|
|
98
|
+
inputs = tokenizer(prompt, return_tensors="pt")
|
|
99
|
+
max_new_tokens = 64
|
|
100
|
+
eos_token_id = tokenizer.eos_token_id
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
output_ids = model.generate(
|
|
104
|
+
**inputs,
|
|
105
|
+
max_new_tokens=max_new_tokens,
|
|
106
|
+
do_sample=self.config.temperature > 0,
|
|
107
|
+
temperature=max(self.config.temperature, 1e-4),
|
|
108
|
+
eos_token_id=eos_token_id,
|
|
109
|
+
pad_token_id=eos_token_id,
|
|
110
|
+
)
|
|
111
|
+
except Exception:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
generated = output_ids[0][inputs["input_ids"].shape[-1]:]
|
|
115
|
+
text = tokenizer.decode(generated, skip_special_tokens=True).strip()
|
|
116
|
+
# Model can wander onto a second line/explanation; keep only the fix.
|
|
117
|
+
text = text.splitlines()[0].strip() if text else ""
|
|
118
|
+
return text or None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_PROSE_PREFIXES = (
|
|
122
|
+
"here", "here's", "sure", "the fix", "this fixes", "note:", "note that",
|
|
123
|
+
"explanation", "you should", "to fix", "i ", "i'd", "the corrected",
|
|
124
|
+
"certainly", "of course", "this line", "this code", "in python",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _clean_line_response(text: str | None, original_line: str) -> str | None:
|
|
129
|
+
"""Reduce a chat-model reply down to a single bare code line.
|
|
130
|
+
|
|
131
|
+
Models asked for "just the fix" routinely still wrap it in a markdown
|
|
132
|
+
fence, prepend "Here's the fix:", or tack on an explanatory comment.
|
|
133
|
+
None of that should ever reach Applier.apply_line_change, since the user
|
|
134
|
+
is agreeing to apply exactly one corrected line, not a paragraph.
|
|
135
|
+
"""
|
|
136
|
+
if not text:
|
|
137
|
+
return None
|
|
138
|
+
text = text.strip()
|
|
139
|
+
if text.startswith("```"):
|
|
140
|
+
text = re.sub(r"^```[a-zA-Z0-9]*\n?", "", text)
|
|
141
|
+
text = re.sub(r"\n?```$", "", text).strip()
|
|
142
|
+
|
|
143
|
+
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
|
144
|
+
lines = [ln for ln in lines if not ln.startswith("```")]
|
|
145
|
+
if not lines:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
candidate = lines[0]
|
|
149
|
+
lowered = candidate.lower()
|
|
150
|
+
if any(lowered.startswith(p) for p in _PROSE_PREFIXES):
|
|
151
|
+
# Try the next non-prose line instead of giving up outright.
|
|
152
|
+
remaining = [ln for ln in lines[1:] if not any(ln.lower().startswith(p) for p in _PROSE_PREFIXES)]
|
|
153
|
+
if not remaining:
|
|
154
|
+
return None
|
|
155
|
+
candidate = remaining[0]
|
|
156
|
+
|
|
157
|
+
# Strip a trailing comment the model added that wasn't in the original --
|
|
158
|
+
# that's commentary, not part of the fix.
|
|
159
|
+
if "#" not in original_line and "#" in candidate:
|
|
160
|
+
candidate = candidate.split("#", 1)[0].rstrip()
|
|
161
|
+
|
|
162
|
+
return candidate or None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class HttpApiProvider(ModelProvider):
|
|
167
|
+
"""Uses a free, keyless, hosted chat-completions API instead of a local model.
|
|
168
|
+
|
|
169
|
+
Default target is OVHcloud AI Endpoints' anonymous tier (no signup, no
|
|
170
|
+
API key, ~2 req/min per IP): https://endpoints.ai.cloud.ovh.net
|
|
171
|
+
Trade-off vs the local ONNX provider: the error line + message leave the
|
|
172
|
+
machine over the network. Only use this provider if that's acceptable.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
config: ModelConfig
|
|
176
|
+
last_error: str | None = None
|
|
177
|
+
|
|
178
|
+
def suggest_patch(self, snippet: str, error_message: str) -> str | None:
|
|
179
|
+
prompt = (
|
|
180
|
+
"Fix this single line of Python code so it no longer raises the "
|
|
181
|
+
"error below. Reply with ONLY the corrected line of code and "
|
|
182
|
+
"nothing else -- no explanation, no markdown fences, no comments, "
|
|
183
|
+
"no extra lines.\n\n"
|
|
184
|
+
f"Error: {error_message}\n"
|
|
185
|
+
f"Line: {snippet}\n"
|
|
186
|
+
"Fixed line:"
|
|
187
|
+
)
|
|
188
|
+
payload = json.dumps(
|
|
189
|
+
{
|
|
190
|
+
"model": self.config.http_api_model,
|
|
191
|
+
"messages": [
|
|
192
|
+
{"role": "system", "content": "You output only corrected code, never prose."},
|
|
193
|
+
{"role": "user", "content": prompt},
|
|
194
|
+
],
|
|
195
|
+
"max_tokens": 100,
|
|
196
|
+
"temperature": 0.0,
|
|
197
|
+
}
|
|
198
|
+
).encode("utf-8")
|
|
199
|
+
|
|
200
|
+
request = urllib.request.Request(
|
|
201
|
+
self.config.http_api_base_url,
|
|
202
|
+
data=payload,
|
|
203
|
+
headers={"Content-Type": "application/json"},
|
|
204
|
+
method="POST",
|
|
205
|
+
)
|
|
206
|
+
try:
|
|
207
|
+
with urllib.request.urlopen(request, timeout=self.config.http_api_timeout) as response:
|
|
208
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
209
|
+
text = body["choices"][0]["message"]["content"]
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
# Any failure here (network down, SSL/proxy quirks, malformed
|
|
212
|
+
# response, endpoint changed shape, etc.) should degrade to "no
|
|
213
|
+
# fix found", never bubble up and look like a crash. Stash the
|
|
214
|
+
# real reason on the instance so the caller can log it if useful.
|
|
215
|
+
self.last_error = f"{type(exc).__name__}: {exc}"
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
return _clean_line_response(text, snippet)
|
|
@@ -218,7 +218,11 @@ class RuntimeManager:
|
|
|
218
218
|
if not plan or not filename or "<" in filename:
|
|
219
219
|
self.reporter.log("exception.analyzed", {"analysis": analysis, "fixed": False})
|
|
220
220
|
print(f"[errorai] Caught {analysis['type']}: {analysis['message']}")
|
|
221
|
-
|
|
221
|
+
provider_reason = getattr(self.provider, "last_error", None)
|
|
222
|
+
if provider_reason:
|
|
223
|
+
print(f"[errorai] No fix: provider request failed ({provider_reason})")
|
|
224
|
+
else:
|
|
225
|
+
print("[errorai] No automatic fix rule matched this error.")
|
|
222
226
|
return False
|
|
223
227
|
|
|
224
228
|
print(f"[errorai] Caught {analysis['type']}: {analysis['message']}")
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
import urllib.request
|
|
6
|
-
|
|
7
|
-
from .config import ModelConfig
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass(frozen=True)
|
|
11
|
-
class BootstrapStatus:
|
|
12
|
-
ready: bool
|
|
13
|
-
mode: str
|
|
14
|
-
detail: str
|
|
15
|
-
model_path: Path | None = None
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def model_path(config: ModelConfig) -> Path:
|
|
19
|
-
base = config.cache_dir / "models"
|
|
20
|
-
if config.provider == "onnx":
|
|
21
|
-
# ONNX text generation needs a full model directory (config.json,
|
|
22
|
-
# tokenizer files, model.onnx, etc.), not a single downloaded file.
|
|
23
|
-
return base / config.name
|
|
24
|
-
return base / f"{config.name}.gguf"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def _model_ready(path: Path, config: ModelConfig) -> bool:
|
|
28
|
-
if config.provider == "onnx":
|
|
29
|
-
return path.exists() and path.is_dir() and any(path.iterdir())
|
|
30
|
-
return path.exists()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _download_onnx_model(config: ModelConfig, destination: Path) -> None:
|
|
34
|
-
from huggingface_hub import snapshot_download
|
|
35
|
-
|
|
36
|
-
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
-
downloaded_dir = Path(
|
|
38
|
-
snapshot_download(
|
|
39
|
-
repo_id=config.repo_id,
|
|
40
|
-
cache_dir=str(config.cache_dir / "hf_cache"),
|
|
41
|
-
allow_patterns=[
|
|
42
|
-
"*.json",
|
|
43
|
-
"*.txt",
|
|
44
|
-
"onnx/model_int8.onnx",
|
|
45
|
-
],
|
|
46
|
-
)
|
|
47
|
-
)
|
|
48
|
-
if destination.exists() or destination.is_symlink():
|
|
49
|
-
return
|
|
50
|
-
try:
|
|
51
|
-
destination.symlink_to(downloaded_dir, target_is_directory=True)
|
|
52
|
-
except OSError:
|
|
53
|
-
# Symlinks can fail on some Windows setups without dev mode/admin
|
|
54
|
-
# rights; fall back to a plain copy of the downloaded snapshot.
|
|
55
|
-
import shutil
|
|
56
|
-
|
|
57
|
-
shutil.copytree(downloaded_dir, destination)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _download_gguf_model(config: ModelConfig, destination: Path) -> None:
|
|
61
|
-
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
-
with urllib.request.urlopen(config.model_url, timeout=config.download_timeout) as response:
|
|
63
|
-
with destination.open("wb") as handle:
|
|
64
|
-
while True:
|
|
65
|
-
chunk = response.read(1 << 20)
|
|
66
|
-
if not chunk:
|
|
67
|
-
break
|
|
68
|
-
handle.write(chunk)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def ensure_model(config: ModelConfig, explicit: bool = False) -> BootstrapStatus:
|
|
72
|
-
path = model_path(config)
|
|
73
|
-
if _model_ready(path, config):
|
|
74
|
-
return BootstrapStatus(True, "model-ready", "Local model available.", path)
|
|
75
|
-
if not explicit and not config.auto_bootstrap:
|
|
76
|
-
return BootstrapStatus(False, "rules-only", "Auto-bootstrap is disabled.", None)
|
|
77
|
-
try:
|
|
78
|
-
if config.provider == "onnx":
|
|
79
|
-
_download_onnx_model(config, path)
|
|
80
|
-
else:
|
|
81
|
-
_download_gguf_model(config, path)
|
|
82
|
-
except Exception as exc:
|
|
83
|
-
return BootstrapStatus(
|
|
84
|
-
False,
|
|
85
|
-
"rules-only",
|
|
86
|
-
f"Model bootstrap failed ({exc.__class__.__name__}: {exc}); continuing without model.",
|
|
87
|
-
None,
|
|
88
|
-
)
|
|
89
|
-
if not _model_ready(path, config):
|
|
90
|
-
return BootstrapStatus(
|
|
91
|
-
False, "rules-only", "Model download finished but expected files are missing.", None
|
|
92
|
-
)
|
|
93
|
-
return BootstrapStatus(True, "model-ready", "Model bootstrap completed.", path)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def model_status(config: ModelConfig) -> BootstrapStatus:
|
|
97
|
-
path = model_path(config)
|
|
98
|
-
if _model_ready(path, config):
|
|
99
|
-
return BootstrapStatus(True, "model-ready", "Model files exist.", path)
|
|
100
|
-
return BootstrapStatus(False, "rules-only", "Model is not installed.", None)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|