gmail-llm-cleanup 1.1.2__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.
- gmail_cleanup/__init__.py +2 -0
- gmail_cleanup/__main__.py +72 -0
- gmail_cleanup/applylog.py +274 -0
- gmail_cleanup/backends/__init__.py +18 -0
- gmail_cleanup/backends/claude.py +39 -0
- gmail_cleanup/backends/ollama.py +114 -0
- gmail_cleanup/backends/openai.py +129 -0
- gmail_cleanup/cli.py +948 -0
- gmail_cleanup/gmail_client.py +457 -0
- gmail_cleanup/portforward.py +159 -0
- gmail_cleanup/prompt.py +356 -0
- gmail_llm_cleanup-1.1.2.dist-info/METADATA +606 -0
- gmail_llm_cleanup-1.1.2.dist-info/RECORD +17 -0
- gmail_llm_cleanup-1.1.2.dist-info/WHEEL +5 -0
- gmail_llm_cleanup-1.1.2.dist-info/entry_points.txt +2 -0
- gmail_llm_cleanup-1.1.2.dist-info/licenses/LICENSE +21 -0
- gmail_llm_cleanup-1.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Entry point for `python -m gmail_cleanup ...` and the
|
|
2
|
+
`gmail-cleanup` console script.
|
|
3
|
+
|
|
4
|
+
Before dispatching to the Click CLI, we load runtime configuration from
|
|
5
|
+
`config/backend.env` (if present). This is the preferred way to set the
|
|
6
|
+
backend env vars (GCA_BACKEND, OLLAMA_*, OPENAI_*, CLAUDE_*, ANTHROPIC_*)
|
|
7
|
+
without having to re-export them in every shell session.
|
|
8
|
+
|
|
9
|
+
Precedence (highest wins):
|
|
10
|
+
1. Variables already exported in the current shell (or set inline:
|
|
11
|
+
`OPENAI_MODEL=foo python -m gmail_cleanup ...`).
|
|
12
|
+
2. Values in `config/backend.env`.
|
|
13
|
+
3. Defaults baked into each backend.
|
|
14
|
+
|
|
15
|
+
Shell-exported values winning is intentional: it lets you keep a
|
|
16
|
+
known-good `config/backend.env` checked in (locally) while doing
|
|
17
|
+
one-off runs with overrides.
|
|
18
|
+
|
|
19
|
+
`config/backend.env.example` is the template; copy it to
|
|
20
|
+
`config/backend.env` and fill in the values for whichever backend you
|
|
21
|
+
want to use.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from dotenv import load_dotenv
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_backend_env_path() -> Path:
|
|
31
|
+
"""Where to look for the optional `backend.env` dotenv file.
|
|
32
|
+
|
|
33
|
+
Order (highest precedence first):
|
|
34
|
+
1. $GMAIL_CLEANUP_CONFIG_DIR/backend.env (explicit override; Docker uses this)
|
|
35
|
+
2. ./config/backend.env in the current working directory
|
|
36
|
+
3. <package-root>/config/backend.env — only meaningful for editable
|
|
37
|
+
installs (`pip install -e .`); harmless on wheel installs since
|
|
38
|
+
load_dotenv silently no-ops on missing files.
|
|
39
|
+
"""
|
|
40
|
+
env = os.environ.get("GMAIL_CLEANUP_CONFIG_DIR")
|
|
41
|
+
if env:
|
|
42
|
+
return Path(env).expanduser() / "backend.env"
|
|
43
|
+
cwd_path = Path.cwd() / "config" / "backend.env"
|
|
44
|
+
if cwd_path.is_file():
|
|
45
|
+
return cwd_path
|
|
46
|
+
# Editable-install fallback: __file__ -> src/gmail_cleanup/__main__.py;
|
|
47
|
+
# parents[2] is the repo root next to its config/ dir.
|
|
48
|
+
return Path(__file__).resolve().parents[2] / "config" / "backend.env"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main():
|
|
52
|
+
"""Console-script entry point used by both `python -m gmail_cleanup`
|
|
53
|
+
and the installed `gmail-cleanup` command. Loads backend.env, runs
|
|
54
|
+
the optional pre-run hook, then dispatches to the Click CLI."""
|
|
55
|
+
load_dotenv(_resolve_backend_env_path(), override=False)
|
|
56
|
+
|
|
57
|
+
# Import AFTER load_dotenv so the backend factory sees the loaded env
|
|
58
|
+
# vars when it inspects os.environ.
|
|
59
|
+
from .cli import main as cli_main
|
|
60
|
+
from .portforward import maybe_start_pre_run
|
|
61
|
+
|
|
62
|
+
# Optional: if PRE_RUN_COMMAND is configured (e.g., a kubectl
|
|
63
|
+
# port-forward to reach an in-cluster Ollama / llama.cpp), launch it
|
|
64
|
+
# in the background and wait for the port to open before main()
|
|
65
|
+
# runs. Cleanup happens via an atexit hook.
|
|
66
|
+
maybe_start_pre_run()
|
|
67
|
+
|
|
68
|
+
cli_main()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Replay decision-log entries to Gmail without re-classifying.
|
|
2
|
+
|
|
3
|
+
Reads a decision log (e.g. dry-run.log), keeps the latest decision per
|
|
4
|
+
thread ID, and replays each one via Gmail's batch HTTP endpoint:
|
|
5
|
+
|
|
6
|
+
- action="trash" -> users().threads().trash(...)
|
|
7
|
+
- action="keep", label=X -> users().threads().modify(addLabelIds=[X])
|
|
8
|
+
- action="keep", label=None -> no Gmail call (already kept)
|
|
9
|
+
- action="error" -> skipped (never applied)
|
|
10
|
+
|
|
11
|
+
Single-threaded — the existing GmailClient is not thread-safe (see
|
|
12
|
+
cli._apply_decisions), and concurrent use can segfault httplib2.
|
|
13
|
+
|
|
14
|
+
Resumable: applied IDs are checkpointed to a state file after each
|
|
15
|
+
batch. Re-runs skip already-applied IDs. Two audit logs:
|
|
16
|
+
--dry-run -> replay-preview.log
|
|
17
|
+
--apply -> applied.log
|
|
18
|
+
|
|
19
|
+
429s from Gmail's per-user concurrent limit are retried per batch with
|
|
20
|
+
exponential backoff; persistent failures fall through as errors.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import datetime as _dt
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import time
|
|
29
|
+
from collections import Counter
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from .gmail_client import GmailClient
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("gmail_cleanup.applylog")
|
|
35
|
+
|
|
36
|
+
BATCH_SIZE = 20 # Sub-requests per batch. Gmail's batch endpoint accepts up
|
|
37
|
+
# to 100 but per-user concurrent ceiling is ~20; larger
|
|
38
|
+
# batches add 429s without improving the ~3.3 ops/sec ceiling.
|
|
39
|
+
MAX_429_RETRIES = 5
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_latest_decisions(log_path: Path) -> dict[str, dict]:
|
|
43
|
+
"""For each thread ID, return the LATEST decision record from the log."""
|
|
44
|
+
latest: dict[str, dict] = {}
|
|
45
|
+
with log_path.open() as fh:
|
|
46
|
+
for line in fh:
|
|
47
|
+
line = line.strip()
|
|
48
|
+
if not line.startswith('{"id":'):
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
r = json.loads(line)
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
continue
|
|
54
|
+
latest[r["id"]] = r
|
|
55
|
+
return latest
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _execute_with_retry(service, items, label_ids, audit_fh, counters,
|
|
59
|
+
decisions_by_id, batch_idx):
|
|
60
|
+
"""Execute a batch of trash/modify requests with 429-retry. Returns the
|
|
61
|
+
set of IDs that completed successfully. Persistent 429s after
|
|
62
|
+
MAX_429_RETRIES are logged as errors."""
|
|
63
|
+
successful: set[str] = set()
|
|
64
|
+
pending = list(items)
|
|
65
|
+
for attempt in range(MAX_429_RETRIES + 1):
|
|
66
|
+
if not pending:
|
|
67
|
+
break
|
|
68
|
+
retry: list[dict] = []
|
|
69
|
+
|
|
70
|
+
def cb(request_id, response, exception, _retry=retry):
|
|
71
|
+
if exception is None:
|
|
72
|
+
successful.add(request_id)
|
|
73
|
+
d = decisions_by_id[request_id]
|
|
74
|
+
if d["action"] == "trash":
|
|
75
|
+
counters["trash"] += 1
|
|
76
|
+
result = "trash"
|
|
77
|
+
else:
|
|
78
|
+
counters["keep_labeled"] += 1
|
|
79
|
+
result = "keep_labeled"
|
|
80
|
+
audit_fh.write(json.dumps({
|
|
81
|
+
"id": request_id, "result": result,
|
|
82
|
+
"label": d.get("label")}) + "\n")
|
|
83
|
+
return
|
|
84
|
+
err = str(exception)
|
|
85
|
+
if "429" in err or "rate" in err.lower() or "concurrent" in err.lower():
|
|
86
|
+
_retry.append(decisions_by_id[request_id])
|
|
87
|
+
else:
|
|
88
|
+
counters["error"] += 1
|
|
89
|
+
audit_fh.write(json.dumps({
|
|
90
|
+
"id": request_id, "result": "error",
|
|
91
|
+
"err": err[:200]}) + "\n")
|
|
92
|
+
|
|
93
|
+
batch = service.new_batch_http_request(callback=cb)
|
|
94
|
+
for d in pending:
|
|
95
|
+
tid = d["id"]
|
|
96
|
+
if d["action"] == "trash":
|
|
97
|
+
req = service.users().threads().trash(userId="me", id=tid)
|
|
98
|
+
else:
|
|
99
|
+
req = service.users().threads().modify(
|
|
100
|
+
userId="me", id=tid,
|
|
101
|
+
body={"addLabelIds": [label_ids[d["label"]]]})
|
|
102
|
+
batch.add(req, request_id=tid)
|
|
103
|
+
try:
|
|
104
|
+
batch.execute()
|
|
105
|
+
except Exception as e: # noqa: BLE001
|
|
106
|
+
logger.error("batch %d transport failed on attempt %d: %s",
|
|
107
|
+
batch_idx, attempt + 1, e)
|
|
108
|
+
retry = pending[:]
|
|
109
|
+
|
|
110
|
+
if retry:
|
|
111
|
+
sleep_for = 1.5 * (2 ** attempt) # 1.5, 3, 6, 12, 24, 48
|
|
112
|
+
logger.warning(" batch %d: %d items hit 429 on attempt %d, "
|
|
113
|
+
"backing off %.1fs", batch_idx, len(retry),
|
|
114
|
+
attempt + 1, sleep_for)
|
|
115
|
+
time.sleep(sleep_for)
|
|
116
|
+
pending = retry
|
|
117
|
+
|
|
118
|
+
for d in pending:
|
|
119
|
+
counters["error"] += 1
|
|
120
|
+
audit_fh.write(json.dumps({
|
|
121
|
+
"id": d["id"], "result": "error",
|
|
122
|
+
"err": f"429 after {MAX_429_RETRIES} retries"}) + "\n")
|
|
123
|
+
return successful
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run_apply_log(
|
|
127
|
+
*,
|
|
128
|
+
log_file: Path,
|
|
129
|
+
state_file: Path,
|
|
130
|
+
apply: bool,
|
|
131
|
+
limit: int | None,
|
|
132
|
+
batch_size: int,
|
|
133
|
+
batch_sleep: float,
|
|
134
|
+
credentials: Path,
|
|
135
|
+
token: Path,
|
|
136
|
+
audit_log: Path,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Replay decisions from `log_file` to Gmail.
|
|
139
|
+
|
|
140
|
+
`audit_log` receives one JSON record per replayed decision;
|
|
141
|
+
`state_file` tracks applied IDs across runs so an interrupted
|
|
142
|
+
apply can resume cleanly.
|
|
143
|
+
"""
|
|
144
|
+
mode = "APPLY (mutating Gmail)" if apply else "dry-run (no mutations)"
|
|
145
|
+
logger.info("mode: %s", mode)
|
|
146
|
+
logger.info("log file: %s", log_file)
|
|
147
|
+
logger.info("audit log: %s", audit_log)
|
|
148
|
+
|
|
149
|
+
logger.info("loading decisions...")
|
|
150
|
+
decisions = load_latest_decisions(log_file)
|
|
151
|
+
actions = Counter(d["action"] for d in decisions.values())
|
|
152
|
+
logger.info("loaded %d unique thread decisions: %s",
|
|
153
|
+
len(decisions), dict(actions))
|
|
154
|
+
|
|
155
|
+
applied_ids: set[str] = set()
|
|
156
|
+
if state_file.exists():
|
|
157
|
+
try:
|
|
158
|
+
applied_ids = set(json.loads(state_file.read_text()).get("applied", []))
|
|
159
|
+
logger.info("resume: %d already applied per %s",
|
|
160
|
+
len(applied_ids), state_file.name)
|
|
161
|
+
except Exception as e: # noqa: BLE001
|
|
162
|
+
logger.warning("could not read %s; starting fresh: %s", state_file, e)
|
|
163
|
+
|
|
164
|
+
pending = [
|
|
165
|
+
d for tid, d in decisions.items()
|
|
166
|
+
if tid not in applied_ids and d["action"] in ("trash", "keep")
|
|
167
|
+
]
|
|
168
|
+
if limit is not None:
|
|
169
|
+
pending = pending[:limit]
|
|
170
|
+
logger.info("pending actions: %d (after resume + --limit)", len(pending))
|
|
171
|
+
|
|
172
|
+
if not pending:
|
|
173
|
+
logger.info("nothing to do; exiting")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
client = GmailClient(credentials, token)
|
|
177
|
+
client.authorize()
|
|
178
|
+
service = client._service # noqa: SLF001
|
|
179
|
+
label_ids: dict[str, str] = client.list_labels()
|
|
180
|
+
|
|
181
|
+
needed_labels = {
|
|
182
|
+
d.get("label") for d in pending
|
|
183
|
+
if d["action"] == "keep" and d.get("label")
|
|
184
|
+
}
|
|
185
|
+
missing = sorted(needed_labels - set(label_ids.keys()))
|
|
186
|
+
if missing:
|
|
187
|
+
if apply:
|
|
188
|
+
for name in missing:
|
|
189
|
+
logger.info("creating label %r", name)
|
|
190
|
+
label_ids[name] = client.create_label(name)
|
|
191
|
+
else:
|
|
192
|
+
for name in missing:
|
|
193
|
+
label_ids[name] = f"<would-create:{name}>"
|
|
194
|
+
logger.info("[dry-run] would create %d new label(s): %s",
|
|
195
|
+
len(missing), missing)
|
|
196
|
+
|
|
197
|
+
audit_fh = audit_log.open("a")
|
|
198
|
+
audit_fh.write(
|
|
199
|
+
f"\n=== {_dt.datetime.now(_dt.timezone.utc).isoformat()} "
|
|
200
|
+
f"apply-log starting (apply={apply}, limit={limit}) ===\n")
|
|
201
|
+
audit_fh.flush()
|
|
202
|
+
|
|
203
|
+
counters: Counter[str] = Counter()
|
|
204
|
+
start = time.time()
|
|
205
|
+
decisions_by_id = {d["id"]: d for d in pending}
|
|
206
|
+
|
|
207
|
+
chunks = [pending[i:i + batch_size] for i in range(0, len(pending), batch_size)]
|
|
208
|
+
total = len(pending)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
for batch_idx, chunk in enumerate(chunks, 1):
|
|
212
|
+
to_apply: list[dict] = []
|
|
213
|
+
for d in chunk:
|
|
214
|
+
tid = d["id"]
|
|
215
|
+
action = d["action"]
|
|
216
|
+
label = d.get("label")
|
|
217
|
+
if action == "keep" and not label:
|
|
218
|
+
counters["keep_nolabel"] += 1
|
|
219
|
+
audit_fh.write(json.dumps(
|
|
220
|
+
{"id": tid, "result": "keep_nolabel", "label": None}) + "\n")
|
|
221
|
+
if apply:
|
|
222
|
+
applied_ids.add(tid)
|
|
223
|
+
continue
|
|
224
|
+
if not apply:
|
|
225
|
+
if action == "trash":
|
|
226
|
+
counters["trash"] += 1
|
|
227
|
+
audit_fh.write(json.dumps(
|
|
228
|
+
{"id": tid, "result": "trash", "label": None}) + "\n")
|
|
229
|
+
else:
|
|
230
|
+
counters["keep_labeled"] += 1
|
|
231
|
+
audit_fh.write(json.dumps(
|
|
232
|
+
{"id": tid, "result": "keep_labeled", "label": label}) + "\n")
|
|
233
|
+
continue
|
|
234
|
+
if action == "keep" and label and not label_ids.get(label):
|
|
235
|
+
counters["error"] += 1
|
|
236
|
+
audit_fh.write(json.dumps(
|
|
237
|
+
{"id": tid, "result": "error",
|
|
238
|
+
"err": f"label {label!r} not resolved"}) + "\n")
|
|
239
|
+
continue
|
|
240
|
+
to_apply.append(d)
|
|
241
|
+
|
|
242
|
+
if to_apply:
|
|
243
|
+
successful = _execute_with_retry(
|
|
244
|
+
service, to_apply, label_ids, audit_fh, counters,
|
|
245
|
+
decisions_by_id, batch_idx)
|
|
246
|
+
if apply:
|
|
247
|
+
applied_ids.update(successful)
|
|
248
|
+
|
|
249
|
+
audit_fh.flush()
|
|
250
|
+
if apply:
|
|
251
|
+
state_file.write_text(json.dumps({"applied": sorted(applied_ids)}))
|
|
252
|
+
|
|
253
|
+
done = min(batch_idx * batch_size, total)
|
|
254
|
+
elapsed = time.time() - start
|
|
255
|
+
rate = done / elapsed if elapsed > 0 else 0
|
|
256
|
+
eta_min = int((total - done) / rate / 60) if rate > 0 else 0
|
|
257
|
+
if batch_idx % 5 == 0 or batch_idx == len(chunks):
|
|
258
|
+
logger.info(
|
|
259
|
+
"batch %d/%d (%d/%d, %.1f%%) | %.1f ops/sec | ETA %d min | %s",
|
|
260
|
+
batch_idx, len(chunks), done, total,
|
|
261
|
+
100 * done / total, rate, eta_min, dict(counters))
|
|
262
|
+
|
|
263
|
+
if apply and batch_idx < len(chunks):
|
|
264
|
+
time.sleep(batch_sleep)
|
|
265
|
+
finally:
|
|
266
|
+
audit_fh.flush()
|
|
267
|
+
audit_fh.close()
|
|
268
|
+
if apply:
|
|
269
|
+
state_file.write_text(json.dumps({"applied": sorted(applied_ids)}))
|
|
270
|
+
|
|
271
|
+
elapsed = time.time() - start
|
|
272
|
+
logger.info("DONE in %.1f min (%.1f ops/sec). mode=%s counters=%s",
|
|
273
|
+
elapsed / 60, total / elapsed if elapsed else 0,
|
|
274
|
+
mode, dict(counters))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""LLM backends — pluggable via GCA_BACKEND env var."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from .ollama import OllamaBackend
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_backend():
|
|
9
|
+
name = os.environ.get("GCA_BACKEND", "ollama").lower()
|
|
10
|
+
if name == "ollama":
|
|
11
|
+
return OllamaBackend()
|
|
12
|
+
if name == "claude":
|
|
13
|
+
from .claude import ClaudeBackend
|
|
14
|
+
return ClaudeBackend()
|
|
15
|
+
if name == "openai":
|
|
16
|
+
from .openai import OpenAIBackend
|
|
17
|
+
return OpenAIBackend()
|
|
18
|
+
raise ValueError(f"Unknown GCA_BACKEND: {name!r}")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Claude API backend. Uses the Anthropic SDK; requires ANTHROPIC_API_KEY."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ClaudeBackend:
|
|
9
|
+
"""Claude backend. Configure via env:
|
|
10
|
+
ANTHROPIC_API_KEY required
|
|
11
|
+
CLAUDE_MODEL default claude-haiku-4-5-20251001
|
|
12
|
+
CLAUDE_MAX_TOKENS default 4000
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
try:
|
|
17
|
+
import anthropic
|
|
18
|
+
except ImportError as e:
|
|
19
|
+
raise ImportError(
|
|
20
|
+
"Install anthropic: `pip install anthropic`"
|
|
21
|
+
) from e
|
|
22
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
23
|
+
if not api_key:
|
|
24
|
+
raise RuntimeError("ANTHROPIC_API_KEY is not set")
|
|
25
|
+
self.client = anthropic.Anthropic(api_key=api_key)
|
|
26
|
+
self.model = os.environ.get("CLAUDE_MODEL", "claude-haiku-4-5-20251001")
|
|
27
|
+
self.max_tokens = int(os.environ.get("CLAUDE_MAX_TOKENS", "4000"))
|
|
28
|
+
|
|
29
|
+
def classify_batch(self, prompt: str) -> str:
|
|
30
|
+
msg = self.client.messages.create(
|
|
31
|
+
model=self.model,
|
|
32
|
+
max_tokens=self.max_tokens,
|
|
33
|
+
messages=[{"role": "user", "content": prompt}],
|
|
34
|
+
)
|
|
35
|
+
# Concatenate all text blocks (Haiku usually returns one)
|
|
36
|
+
return "".join(b.text for b in msg.content if hasattr(b, "text"))
|
|
37
|
+
|
|
38
|
+
def __repr__(self):
|
|
39
|
+
return f"ClaudeBackend(model={self.model})"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Ollama backend. Calls /api/chat with `format: json` so the model is
|
|
2
|
+
constrained to emit JSON output."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Errors worth retrying — transient network issues from `kubectl port-forward`
|
|
17
|
+
# dying mid-stream, or from intermittent server-side hiccups. We do NOT
|
|
18
|
+
# retry on HTTPStatusError (semantic errors should fail loud).
|
|
19
|
+
_RETRYABLE_EXCEPTIONS = (
|
|
20
|
+
httpx.RemoteProtocolError, # peer closed connection mid-response
|
|
21
|
+
httpx.ConnectError, # couldn't establish connection
|
|
22
|
+
httpx.ReadError, # read failed mid-stream
|
|
23
|
+
httpx.WriteError, # write failed
|
|
24
|
+
httpx.ConnectTimeout, # connect timeout
|
|
25
|
+
httpx.ReadTimeout, # read timeout (rare for fast LLM)
|
|
26
|
+
OSError, # ECONNRESET, EPIPE etc. surface as OSError
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OllamaBackend:
|
|
31
|
+
"""Ollama backend. Configure via env:
|
|
32
|
+
OLLAMA_HOST default http://localhost:11434
|
|
33
|
+
OLLAMA_MODEL default qwen3.6:35b-a3b-iq3_xxs-fixed
|
|
34
|
+
OLLAMA_NUM_CTX default 8192
|
|
35
|
+
OLLAMA_KEEP_ALIVE default 60m (keep model warm between batches)
|
|
36
|
+
OLLAMA_TIMEOUT default 300 (per-call read timeout, seconds)
|
|
37
|
+
OLLAMA_RETRIES default 5 (transient retries on connection errors)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self):
|
|
41
|
+
self.host = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
|
|
42
|
+
self.model = os.environ.get("OLLAMA_MODEL", "qwen3.6:35b-a3b-iq3_xxs-fixed")
|
|
43
|
+
self.num_ctx = int(os.environ.get("OLLAMA_NUM_CTX", "8192"))
|
|
44
|
+
self.keep_alive = os.environ.get("OLLAMA_KEEP_ALIVE", "60m")
|
|
45
|
+
self.timeout = float(os.environ.get("OLLAMA_TIMEOUT", "300"))
|
|
46
|
+
self.retries = int(os.environ.get("OLLAMA_RETRIES", "5"))
|
|
47
|
+
# Single httpx client reused across calls — keepalive saves the
|
|
48
|
+
# TCP+TLS handshake, and means a `kubectl port-forward` blip
|
|
49
|
+
# surfaces as a single retryable error rather than a slow stream
|
|
50
|
+
# of fresh-connection failures.
|
|
51
|
+
self._client = httpx.Client(timeout=self.timeout, http2=False)
|
|
52
|
+
|
|
53
|
+
def classify_batch(self, prompt: str) -> str:
|
|
54
|
+
"""Send the prompt to Ollama, return the raw `message.content`
|
|
55
|
+
string. Retries up to OLLAMA_RETRIES times on transient network
|
|
56
|
+
errors (connection reset, timeout, peer closed)."""
|
|
57
|
+
body = {
|
|
58
|
+
"model": self.model,
|
|
59
|
+
"stream": False,
|
|
60
|
+
"format": "json",
|
|
61
|
+
"keep_alive": self.keep_alive,
|
|
62
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
63
|
+
"options": {
|
|
64
|
+
"temperature": 0.1,
|
|
65
|
+
"num_ctx": self.num_ctx,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
last_exc: Exception | None = None
|
|
69
|
+
for attempt in range(self.retries + 1):
|
|
70
|
+
try:
|
|
71
|
+
r = self._client.post(f"{self.host}/api/chat", json=body)
|
|
72
|
+
r.raise_for_status()
|
|
73
|
+
data = r.json()
|
|
74
|
+
return data.get("message", {}).get("content", "")
|
|
75
|
+
except _RETRYABLE_EXCEPTIONS as e:
|
|
76
|
+
last_exc = e
|
|
77
|
+
if attempt < self.retries:
|
|
78
|
+
# Exponential backoff with a small floor + ceiling so
|
|
79
|
+
# a flapping port-forward doesn't get retried for hours.
|
|
80
|
+
delay = min(2 ** attempt, 30)
|
|
81
|
+
logger.warning(
|
|
82
|
+
"ollama transient error (%s: %s) — retrying in %ds (attempt %d/%d)",
|
|
83
|
+
type(e).__name__, e, delay, attempt + 1, self.retries
|
|
84
|
+
)
|
|
85
|
+
# Do NOT close/recreate the shared client here. It is
|
|
86
|
+
# shared across all concurrent classify_batch() workers;
|
|
87
|
+
# closing it from one task makes sibling tasks raise
|
|
88
|
+
# RuntimeError("Cannot send a request, as the client has
|
|
89
|
+
# been closed.") on their in-flight .post() calls.
|
|
90
|
+
# RuntimeError isn't retryable, so each sibling fails out
|
|
91
|
+
# as a "backend error" instead of retrying its own
|
|
92
|
+
# transient. httpx.Client is documented thread-safe and
|
|
93
|
+
# its connection pool prunes failed connections on its
|
|
94
|
+
# own, so the sleep-and-retry alone is sufficient.
|
|
95
|
+
time.sleep(delay)
|
|
96
|
+
continue
|
|
97
|
+
break
|
|
98
|
+
# Out of retries — surface to caller with the original exception
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
f"ollama call failed after {self.retries + 1} attempts: "
|
|
101
|
+
f"{type(last_exc).__name__}: {last_exc}"
|
|
102
|
+
) from last_exc
|
|
103
|
+
|
|
104
|
+
def close(self):
|
|
105
|
+
try:
|
|
106
|
+
self._client.close()
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
def __del__(self):
|
|
111
|
+
self.close()
|
|
112
|
+
|
|
113
|
+
def __repr__(self):
|
|
114
|
+
return f"OllamaBackend(model={self.model}, host={self.host})"
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""OpenAI-compatible backend. Works with:
|
|
2
|
+
|
|
3
|
+
- real OpenAI (api.openai.com)
|
|
4
|
+
- LM Studio (http://localhost:1234/v1)
|
|
5
|
+
- llama.cpp server (http://localhost:8080/v1)
|
|
6
|
+
- vLLM, Ollama's /v1 OpenAI shim, and anything else that speaks the
|
|
7
|
+
OpenAI Chat Completions wire format.
|
|
8
|
+
|
|
9
|
+
Selected via GCA_BACKEND=openai. Configure via env:
|
|
10
|
+
OPENAI_API_KEY required for real OpenAI. For LM Studio / llama.cpp /
|
|
11
|
+
vLLM any non-empty placeholder is fine ("not-needed").
|
|
12
|
+
OPENAI_BASE_URL default https://api.openai.com/v1. Point at your
|
|
13
|
+
local server (e.g. http://localhost:1234/v1) for
|
|
14
|
+
LM Studio.
|
|
15
|
+
OPENAI_MODEL default gpt-4o-mini. For local servers, the exact
|
|
16
|
+
model name they advertise (LM Studio shows it in
|
|
17
|
+
the Server tab; llama.cpp uses the file name).
|
|
18
|
+
OPENAI_MAX_TOKENS default 4000
|
|
19
|
+
OPENAI_TEMPERATURE default 0.1
|
|
20
|
+
OPENAI_JSON_MODE default "1" — sends `response_format: json_object`.
|
|
21
|
+
Set to "0" if your local server doesn't support it
|
|
22
|
+
(older llama.cpp builds, some vLLM configs).
|
|
23
|
+
OPENAI_DISABLE_THINKING default "0" — set to "1" when running against a
|
|
24
|
+
reasoning / thinking model (Qwen3, DeepSeek-R1, ...)
|
|
25
|
+
on llama.cpp. Adds
|
|
26
|
+
`extra_body.chat_template_kwargs.enable_thinking=false`
|
|
27
|
+
to each request so the model emits its answer directly
|
|
28
|
+
in `content` instead of burning the entire token
|
|
29
|
+
budget on `reasoning_content` chain-of-thought.
|
|
30
|
+
Real OpenAI ignores unknown extras; some other
|
|
31
|
+
OpenAI-compatible servers (vLLM, older LM Studio
|
|
32
|
+
builds) may reject them.
|
|
33
|
+
See docs/llama-server-setup.md for details.
|
|
34
|
+
OPENAI_RETRIES default 5 — retry transient connection / rate-limit
|
|
35
|
+
errors with exponential backoff (1, 2, 4, 8, 16 s).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import logging
|
|
41
|
+
import os
|
|
42
|
+
import time
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OpenAIBackend:
|
|
49
|
+
def __init__(self):
|
|
50
|
+
try:
|
|
51
|
+
from openai import OpenAI
|
|
52
|
+
except ImportError as e:
|
|
53
|
+
raise ImportError(
|
|
54
|
+
"Install the OpenAI SDK: `pip install openai`"
|
|
55
|
+
) from e
|
|
56
|
+
api_key = os.environ.get("OPENAI_API_KEY") or "not-needed"
|
|
57
|
+
base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
|
58
|
+
# max_retries=0 — let our explicit retry loop below own the
|
|
59
|
+
# retry semantics so the logged attempt count is meaningful and
|
|
60
|
+
# the user-tunable OPENAI_RETRIES is actually authoritative.
|
|
61
|
+
self.client = OpenAI(api_key=api_key, base_url=base_url, max_retries=0)
|
|
62
|
+
self.model = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
|
|
63
|
+
self.max_tokens = int(os.environ.get("OPENAI_MAX_TOKENS", "4000"))
|
|
64
|
+
self.temperature = float(os.environ.get("OPENAI_TEMPERATURE", "0.1"))
|
|
65
|
+
self.json_mode = os.environ.get("OPENAI_JSON_MODE", "1") not in ("0", "false", "no")
|
|
66
|
+
self.retries = int(os.environ.get("OPENAI_RETRIES", "5"))
|
|
67
|
+
self.base_url = base_url
|
|
68
|
+
|
|
69
|
+
def _is_retryable(self, exc: Exception) -> bool:
|
|
70
|
+
"""Connection-level or rate-limit errors that are worth retrying.
|
|
71
|
+
Semantic errors (bad request, unsupported model, auth) are NOT
|
|
72
|
+
retried — they'll just keep failing and we want to fail loud."""
|
|
73
|
+
# Import lazily so the module can be imported without the openai
|
|
74
|
+
# SDK installed (the constructor would have failed first anyway).
|
|
75
|
+
from openai import APIConnectionError, APITimeoutError, RateLimitError, InternalServerError
|
|
76
|
+
return isinstance(exc, (
|
|
77
|
+
APIConnectionError,
|
|
78
|
+
APITimeoutError,
|
|
79
|
+
RateLimitError,
|
|
80
|
+
InternalServerError,
|
|
81
|
+
))
|
|
82
|
+
|
|
83
|
+
def classify_batch(self, prompt: str) -> str:
|
|
84
|
+
kwargs = dict(
|
|
85
|
+
model=self.model,
|
|
86
|
+
messages=[{"role": "user", "content": prompt}],
|
|
87
|
+
max_tokens=self.max_tokens,
|
|
88
|
+
temperature=self.temperature,
|
|
89
|
+
)
|
|
90
|
+
if self.json_mode:
|
|
91
|
+
kwargs["response_format"] = {"type": "json_object"}
|
|
92
|
+
|
|
93
|
+
# Reasoning/thinking-mode models (Qwen3, DeepSeek-R1, etc.) on
|
|
94
|
+
# llama.cpp servers split output between `reasoning_content`
|
|
95
|
+
# (chain-of-thought) and `content` (final answer). When the model
|
|
96
|
+
# spends its entire token budget on reasoning, `content` is empty
|
|
97
|
+
# and the classifier sees "0 decisions returned." Send
|
|
98
|
+
# `chat_template_kwargs.enable_thinking=false` as an OpenAI-API
|
|
99
|
+
# extra body parameter to force the model straight to the answer.
|
|
100
|
+
# Opt-in via env (default off) — real OpenAI ignores unknown
|
|
101
|
+
# extras, but some other OpenAI-compatible servers (vLLM,
|
|
102
|
+
# LM Studio older builds) may reject them.
|
|
103
|
+
if os.environ.get("OPENAI_DISABLE_THINKING", "0") in ("1", "true", "yes"):
|
|
104
|
+
kwargs["extra_body"] = {
|
|
105
|
+
"chat_template_kwargs": {"enable_thinking": False}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
last_exc: Exception | None = None
|
|
109
|
+
for attempt in range(self.retries + 1):
|
|
110
|
+
try:
|
|
111
|
+
resp = self.client.chat.completions.create(**kwargs)
|
|
112
|
+
return resp.choices[0].message.content or ""
|
|
113
|
+
except Exception as e:
|
|
114
|
+
if not self._is_retryable(e) or attempt >= self.retries:
|
|
115
|
+
raise
|
|
116
|
+
last_exc = e
|
|
117
|
+
delay = min(2 ** attempt, 30)
|
|
118
|
+
logger.warning(
|
|
119
|
+
"openai transient error (%s: %s) — retrying in %ds (attempt %d/%d)",
|
|
120
|
+
type(e).__name__, e, delay, attempt + 1, self.retries
|
|
121
|
+
)
|
|
122
|
+
time.sleep(delay)
|
|
123
|
+
# Unreachable in practice — loop either returns or raises
|
|
124
|
+
raise RuntimeError(
|
|
125
|
+
f"openai call failed after {self.retries + 1} attempts"
|
|
126
|
+
) from last_exc
|
|
127
|
+
|
|
128
|
+
def __repr__(self):
|
|
129
|
+
return f"OpenAIBackend(model={self.model}, base_url={self.base_url})"
|