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.
@@ -0,0 +1,2 @@
1
+ """gmail-cleanup-agent — LLM-driven Gmail triage."""
2
+ __version__ = "0.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})"