Proper batch gui that lets you manage batch history and reuse batches
This commit is contained in:
parent
8a0cda9e26
commit
300953a04d
8 changed files with 2254 additions and 355 deletions
100
TODO.md
100
TODO.md
|
|
@ -6,106 +6,6 @@
|
||||||
- Add an optional **per-game custom prompt** in the UI that merges with the default (custom rules layered on top of shared instructions, not a full replacement unless explicitly chosen).
|
- Add an optional **per-game custom prompt** in the UI that merges with the default (custom rules layered on top of shared instructions, not a full replacement unless explicitly chosen).
|
||||||
- Surface this in settings / project setup so each game folder can carry its own tone, terminology notes, or content warnings without forking the global prompt.
|
- Surface this in settings / project setup so each game folder can carry its own tone, terminology notes, or content warnings without forking the global prompt.
|
||||||
|
|
||||||
## Batch history tab
|
|
||||||
|
|
||||||
- Add a **Batch** tab (or dedicated panel) in the GUI to manage past Anthropic batch runs saved locally.
|
|
||||||
- Persist batch metadata locally (batch id, submit time, file set, status, cost estimate, etc.) alongside existing `log/batch_*.json` artifacts (`batch_state.json`, `batch_results.json`, `batch_requests.json`).
|
|
||||||
- Actions:
|
|
||||||
- **List batches** — browse all batches the tool has submitted or tracked for this project.
|
|
||||||
- **Redownload** — fetch results again from Anthropic for a completed batch (e.g. after a crash before consume, or to recover results). **Not in sketch yet.**
|
|
||||||
- **Cancel batch** — cancel an in-flight batch where the API allows it (`in_progress` → `canceling` → `ended`).
|
|
||||||
- **Usage / cost** — sum real billed tokens from batch results (input, output, cache read/write, thinking).
|
|
||||||
- Resume / link into the existing collect → submit → consume flow when a batch is still pending or fetched but not consumed.
|
|
||||||
|
|
||||||
### Sketch: CLI batch manager (adapt for DazedMTLTool)
|
|
||||||
|
|
||||||
Reference from sibling tooling (`batches.py`). Port to this repo using `_get_anthropic_client()` / `fetchTranslationBatches()` in `util/translation.py` and `log/batch_state.json` (maps to sketch's `tl/_batch_state.json`).
|
|
||||||
|
|
||||||
```python
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
batches.py — list and cancel Anthropic Message Batches (the ones DazedMTLTool
|
|
||||||
submits). Handy when a run is stuck polling or you submitted by mistake.
|
|
||||||
|
|
||||||
set ANTHROPIC_API_KEY=sk-... # or key= in .env
|
|
||||||
|
|
||||||
python tooling/batches.py # list recent batches (default)
|
|
||||||
python tooling/batches.py list --limit 50
|
|
||||||
python tooling/batches.py cancel <batch_id> [<batch_id> ...]
|
|
||||||
python tooling/batches.py cancel --all # cancel every in-progress batch
|
|
||||||
python tooling/batches.py cancel --mine # cancel batch(es) in log/batch_state.json
|
|
||||||
python tooling/batches.py usage [batch_id] # real billed tokens + estimated cost
|
|
||||||
|
|
||||||
# TODO: redownload <batch_id> — re-fetch results into log/batch_results.json
|
|
||||||
|
|
||||||
Only an in-progress batch can be cancelled; cancellation finishes any in-flight
|
|
||||||
requests (in_progress -> canceling -> ended). Already-ended batches can't be
|
|
||||||
cancelled.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import argparse
|
|
||||||
import collections
|
|
||||||
|
|
||||||
# Adapt imports to DazedMTLTool:
|
|
||||||
# from util.translation import _get_anthropic_client, BATCH_STATE_FILE, _read_batch_file, fetchTranslationBatches
|
|
||||||
|
|
||||||
CANCELABLE = {"in_progress"}
|
|
||||||
|
|
||||||
|
|
||||||
def _counts(b):
|
|
||||||
rc = getattr(b, "request_counts", None)
|
|
||||||
if not rc:
|
|
||||||
return ""
|
|
||||||
parts = []
|
|
||||||
for k in ("processing", "succeeded", "errored", "canceled", "expired"):
|
|
||||||
v = getattr(rc, k, None)
|
|
||||||
if v:
|
|
||||||
parts.append(f"{k[:4]}={v}")
|
|
||||||
return " ".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _mine_batch_ids():
|
|
||||||
"""Return batch ids from log/batch_state.json (DazedMTLTool format)."""
|
|
||||||
# state = _read_batch_file(BATCH_STATE_FILE) or {}
|
|
||||||
# return [b["id"] for b in state.get("batches", [])]
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_list(client, limit):
|
|
||||||
mine = set(_mine_batch_ids())
|
|
||||||
print(f"{'BATCH ID':<28} {'STATUS':<12} {'CREATED':<22} COUNTS")
|
|
||||||
print("-" * 90)
|
|
||||||
n = 0
|
|
||||||
for b in client.messages.batches.list(limit=limit):
|
|
||||||
n += 1
|
|
||||||
mark = " <- yours" if b.id in mine else ""
|
|
||||||
created = str(getattr(b, "created_at", "") or "")[:22]
|
|
||||||
print(f"{b.id:<28} {b.processing_status:<12} {created:<22} {_counts(b)}{mark}")
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_cancel(client, ids, do_all, do_mine, limit):
|
|
||||||
# targets from ids, --mine (log/batch_state.json), or --all (in_progress only)
|
|
||||||
# client.messages.batches.cancel(bid)
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_usage(client, bid, model):
|
|
||||||
# Sum usage across client.messages.batches.results(bid)
|
|
||||||
# Price with getPricingConfig(model) + 50% batch discount on I/O
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
# cmd_redownload(client, bid):
|
|
||||||
# # TODO: client.messages.batches.results(bid) -> merge into log/batch_results.json
|
|
||||||
# # Wire custom_id map from batch_state.json entries
|
|
||||||
# ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**GUI tab (later):** same operations as buttons on a saved local history table — list, cancel, usage, redownload, resume consume.
|
|
||||||
|
|
||||||
## Local translation cache (game retranslate)
|
## Local translation cache (game retranslate)
|
||||||
|
|
||||||
- Persist every successful translation locally so a full retranslate from scratch can reuse prior results instead of paying again.
|
- Persist every successful translation locally so a full retranslate from scratch can reuse prior results instead of paying again.
|
||||||
|
|
|
||||||
467
gui/batch_tab.py
Normal file
467
gui/batch_tab.py
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Batch history tab - list / cancel / usage / redownload / resume."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QPushButton,
|
||||||
|
QLabel,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QHeaderView,
|
||||||
|
QTextEdit,
|
||||||
|
QMessageBox,
|
||||||
|
QAbstractItemView,
|
||||||
|
QSplitter,
|
||||||
|
)
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
class _BatchOpsWorker(QThread):
|
||||||
|
"""Run a blocking batch-history callable off the UI thread."""
|
||||||
|
|
||||||
|
done = pyqtSignal(bool, str, object) # ok, message, payload
|
||||||
|
log = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, task, project_root: Path):
|
||||||
|
super().__init__()
|
||||||
|
self._task = task
|
||||||
|
self._project_root = project_root
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
old = os.getcwd()
|
||||||
|
try:
|
||||||
|
os.chdir(str(self._project_root))
|
||||||
|
load_dotenv()
|
||||||
|
ok, msg, payload = self._task(self.log.emit)
|
||||||
|
self.done.emit(bool(ok), str(msg), payload)
|
||||||
|
except Exception as exc:
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
self.log.emit(traceback.format_exc())
|
||||||
|
self.done.emit(False, f"Error: {exc}", None)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.chdir(old)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BatchTab(QWidget):
|
||||||
|
"""Manage past / in-flight Anthropic Message Batches for this project."""
|
||||||
|
|
||||||
|
COLUMNS = [
|
||||||
|
"Batch ID",
|
||||||
|
"Status",
|
||||||
|
"Created",
|
||||||
|
"Requests",
|
||||||
|
"Model",
|
||||||
|
"Estimate",
|
||||||
|
"Actual $",
|
||||||
|
"Files",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.parent_window = parent
|
||||||
|
self.project_root = Path(
|
||||||
|
getattr(parent, "project_root", None) or Path(__file__).resolve().parent.parent
|
||||||
|
)
|
||||||
|
self._worker: _BatchOpsWorker | None = None
|
||||||
|
self._rows: list[dict] = []
|
||||||
|
self._loaded_once = False
|
||||||
|
self._init_ui()
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
super().showEvent(event)
|
||||||
|
# Refresh every time the Batches page is shown so live status stays current.
|
||||||
|
if self._worker is not None and self._worker.isRunning():
|
||||||
|
return
|
||||||
|
self._loaded_once = True
|
||||||
|
QTimer.singleShot(0, lambda: self.refresh_list(live=True))
|
||||||
|
|
||||||
|
def _init_ui(self):
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(16, 16, 16, 16)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
|
||||||
|
title = QLabel("Batch History")
|
||||||
|
title.setStyleSheet("color:#ffffff;font-size:18px;font-weight:bold;")
|
||||||
|
layout.addWidget(title)
|
||||||
|
|
||||||
|
hint = QLabel(
|
||||||
|
"Local Anthropic Message Batch runs for this project. "
|
||||||
|
"Cancel / redownload / usage never submit a new batch."
|
||||||
|
)
|
||||||
|
hint.setWordWrap(True)
|
||||||
|
hint.setStyleSheet("color:#9d9d9d;font-size:12px;")
|
||||||
|
layout.addWidget(hint)
|
||||||
|
|
||||||
|
toolbar = QHBoxLayout()
|
||||||
|
toolbar.setSpacing(8)
|
||||||
|
|
||||||
|
self.refresh_btn = QPushButton("Refresh")
|
||||||
|
self.refresh_btn.setToolTip("Reload local history and refresh live status for active batches")
|
||||||
|
self.refresh_btn.clicked.connect(lambda: self.refresh_list(live=True))
|
||||||
|
|
||||||
|
self.cancel_btn = QPushButton("Cancel")
|
||||||
|
self.cancel_btn.setToolTip("Cancel selected in-progress batch(es)")
|
||||||
|
self.cancel_btn.clicked.connect(self.cancel_selected)
|
||||||
|
|
||||||
|
self.usage_btn = QPushButton("Usage")
|
||||||
|
self.usage_btn.setToolTip("Sum real billed tokens for the selected ended batch")
|
||||||
|
self.usage_btn.clicked.connect(self.usage_selected)
|
||||||
|
|
||||||
|
self.redownload_btn = QPushButton("Redownload")
|
||||||
|
self.redownload_btn.setToolTip("Re-fetch results into log/batch_results.json (no re-submit)")
|
||||||
|
self.redownload_btn.clicked.connect(self.redownload_selected)
|
||||||
|
|
||||||
|
self.resume_btn = QPushButton("Resume")
|
||||||
|
self.resume_btn.setToolTip("Activate this batch and continue poll/consume on the Translation tab")
|
||||||
|
self.resume_btn.clicked.connect(self.resume_selected)
|
||||||
|
|
||||||
|
for btn in (
|
||||||
|
self.refresh_btn,
|
||||||
|
self.cancel_btn,
|
||||||
|
self.usage_btn,
|
||||||
|
self.redownload_btn,
|
||||||
|
self.resume_btn,
|
||||||
|
):
|
||||||
|
btn.setStyleSheet(
|
||||||
|
"QPushButton{background-color:#3c3c3c;color:#cccccc;border:1px solid #555555;"
|
||||||
|
"border-radius:4px;padding:6px 12px;}"
|
||||||
|
"QPushButton:hover{border-color:#007acc;}"
|
||||||
|
"QPushButton:disabled{color:#666666;border-color:#444444;}"
|
||||||
|
)
|
||||||
|
toolbar.addWidget(btn)
|
||||||
|
toolbar.addStretch()
|
||||||
|
layout.addLayout(toolbar)
|
||||||
|
|
||||||
|
splitter = QSplitter(Qt.Vertical)
|
||||||
|
|
||||||
|
self.table = QTableWidget(0, len(self.COLUMNS))
|
||||||
|
self.table.setHorizontalHeaderLabels(self.COLUMNS)
|
||||||
|
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
|
self.table.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||||
|
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||||
|
self.table.setAlternatingRowColors(True)
|
||||||
|
self.table.verticalHeader().setVisible(False)
|
||||||
|
self.table.setStyleSheet(
|
||||||
|
"QTableWidget{background-color:#1e1e1e;color:#cccccc;gridline-color:#3a3a3a;"
|
||||||
|
"alternate-background-color:#252526;}"
|
||||||
|
"QHeaderView::section{background-color:#2d2d30;color:#cccccc;padding:4px;"
|
||||||
|
"border:1px solid #3a3a3a;}"
|
||||||
|
)
|
||||||
|
header = self.table.horizontalHeader()
|
||||||
|
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
|
for i in range(1, len(self.COLUMNS)):
|
||||||
|
header.setSectionResizeMode(i, QHeaderView.ResizeToContents)
|
||||||
|
self.table.itemSelectionChanged.connect(self._update_button_states)
|
||||||
|
splitter.addWidget(self.table)
|
||||||
|
|
||||||
|
self.log = QTextEdit()
|
||||||
|
self.log.setReadOnly(True)
|
||||||
|
self.log.setPlaceholderText("Operation log…")
|
||||||
|
self.log.setStyleSheet(
|
||||||
|
"QTextEdit{background-color:#1e1e1e;color:#cccccc;border:1px solid #3a3a3a;"
|
||||||
|
"font-family:monospace;font-size:12px;}"
|
||||||
|
)
|
||||||
|
splitter.addWidget(self.log)
|
||||||
|
splitter.setStretchFactor(0, 3)
|
||||||
|
splitter.setStretchFactor(1, 1)
|
||||||
|
layout.addWidget(splitter, 1)
|
||||||
|
|
||||||
|
self._update_button_states()
|
||||||
|
|
||||||
|
def _append_log(self, text: str):
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
self.log.append(text.rstrip())
|
||||||
|
|
||||||
|
def _selected_entries(self) -> list[dict]:
|
||||||
|
rows = sorted({idx.row() for idx in self.table.selectedIndexes()})
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
if 0 <= r < len(self._rows):
|
||||||
|
out.append(self._rows[r])
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _update_button_states(self):
|
||||||
|
busy = self._worker is not None and self._worker.isRunning()
|
||||||
|
entries = self._selected_entries()
|
||||||
|
n = len(entries)
|
||||||
|
self.refresh_btn.setEnabled(not busy)
|
||||||
|
self.cancel_btn.setEnabled(
|
||||||
|
not busy and n >= 1 and any(
|
||||||
|
(e.get("api_status") == "in_progress") or (e.get("status") == "submitted")
|
||||||
|
for e in entries
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.usage_btn.setEnabled(not busy and n == 1)
|
||||||
|
self.redownload_btn.setEnabled(
|
||||||
|
not busy
|
||||||
|
and n == 1
|
||||||
|
and entries[0].get("status") in ("ended", "fetched", "consumed", "submitted", "canceling")
|
||||||
|
)
|
||||||
|
self.resume_btn.setEnabled(
|
||||||
|
not busy
|
||||||
|
and n == 1
|
||||||
|
and entries[0].get("status")
|
||||||
|
in ("submitted", "canceling", "ended", "fetched")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _set_busy(self, busy: bool):
|
||||||
|
if busy:
|
||||||
|
for btn in (
|
||||||
|
self.refresh_btn,
|
||||||
|
self.cancel_btn,
|
||||||
|
self.usage_btn,
|
||||||
|
self.redownload_btn,
|
||||||
|
self.resume_btn,
|
||||||
|
):
|
||||||
|
btn.setEnabled(False)
|
||||||
|
else:
|
||||||
|
self._update_button_states()
|
||||||
|
|
||||||
|
def _clear_worker(self):
|
||||||
|
if self.sender() is self._worker:
|
||||||
|
self._worker = None
|
||||||
|
|
||||||
|
def _run_task(self, task, on_done=None):
|
||||||
|
if self._worker is not None and self._worker.isRunning():
|
||||||
|
QMessageBox.information(self, "Busy", "A batch operation is already running.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._set_busy(True)
|
||||||
|
worker = _BatchOpsWorker(task, self.project_root)
|
||||||
|
self._worker = worker
|
||||||
|
|
||||||
|
def _finished(ok, msg, payload):
|
||||||
|
self._set_busy(False)
|
||||||
|
if msg:
|
||||||
|
self._append_log(msg)
|
||||||
|
if on_done:
|
||||||
|
try:
|
||||||
|
on_done(ok, msg, payload)
|
||||||
|
except Exception as exc:
|
||||||
|
self._append_log(f"[BATCH] UI update failed: {exc}")
|
||||||
|
|
||||||
|
worker.log.connect(self._append_log)
|
||||||
|
worker.done.connect(_finished)
|
||||||
|
# Release the reference only after run() returns. Clearing in the done
|
||||||
|
# slot aborts with "QThread: Destroyed while thread is still running".
|
||||||
|
worker.finished.connect(worker.deleteLater)
|
||||||
|
worker.finished.connect(self._clear_worker)
|
||||||
|
worker.start()
|
||||||
|
|
||||||
|
def _populate_table(self, entries: list[dict]):
|
||||||
|
self._rows = list(entries or [])
|
||||||
|
self.table.setRowCount(len(self._rows))
|
||||||
|
for r, entry in enumerate(self._rows):
|
||||||
|
est = entry.get("cost_estimate") or {}
|
||||||
|
est_s = ""
|
||||||
|
if isinstance(est, dict) and est.get("batch_cached_cost") is not None:
|
||||||
|
try:
|
||||||
|
est_s = f"${float(est['batch_cached_cost']):.2f}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
est_s = ""
|
||||||
|
actual = entry.get("actual_cost")
|
||||||
|
actual_s = f"${actual:.4f}" if isinstance(actual, (int, float)) else ""
|
||||||
|
files = entry.get("file_set") or []
|
||||||
|
if not isinstance(files, list):
|
||||||
|
files = [str(files)]
|
||||||
|
files_s = ", ".join(str(f) for f in files[:3])
|
||||||
|
if len(files) > 3:
|
||||||
|
files_s += f" (+{len(files) - 3})"
|
||||||
|
values = [
|
||||||
|
str(entry.get("id") or ""),
|
||||||
|
str(entry.get("status") or ""),
|
||||||
|
str(entry.get("created_at") or "")[:19],
|
||||||
|
str(entry.get("request_count") or ""),
|
||||||
|
str(entry.get("model") or ""),
|
||||||
|
est_s,
|
||||||
|
actual_s,
|
||||||
|
files_s,
|
||||||
|
]
|
||||||
|
for c, val in enumerate(values):
|
||||||
|
item = QTableWidgetItem(val)
|
||||||
|
if c == 0:
|
||||||
|
item.setData(Qt.UserRole, entry.get("id"))
|
||||||
|
self.table.setItem(r, c, item)
|
||||||
|
self._update_button_states()
|
||||||
|
|
||||||
|
def refresh_list(self, live: bool = True):
|
||||||
|
def task(log):
|
||||||
|
from util.batch_history import list_local_batches
|
||||||
|
|
||||||
|
log("[BATCH] Loading local history...")
|
||||||
|
entries = list_local_batches(refresh_live=live)
|
||||||
|
log(f"[BATCH] {len(entries)} batch(es) in history.")
|
||||||
|
return True, f"Loaded {len(entries)} batch(es).", entries
|
||||||
|
|
||||||
|
def done(ok, _msg, payload):
|
||||||
|
if ok and isinstance(payload, list):
|
||||||
|
self._populate_table(payload)
|
||||||
|
|
||||||
|
self._run_task(task, on_done=done)
|
||||||
|
|
||||||
|
def cancel_selected(self):
|
||||||
|
entries = self._selected_entries()
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
|
ids = [e["id"] for e in entries if e.get("id")]
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Cancel Batch?",
|
||||||
|
"Cancel the selected in-progress batch(es)?\n\n"
|
||||||
|
"Anthropic may still finish and bill requests that were already "
|
||||||
|
"in flight when cancel is received.\n"
|
||||||
|
"This does not submit a new batch.",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
def task(log):
|
||||||
|
from util.batch_history import cancel_batches
|
||||||
|
|
||||||
|
log(f"[BATCH] Canceling {len(ids)} batch(es)…")
|
||||||
|
results = cancel_batches(ids)
|
||||||
|
lines = []
|
||||||
|
for r in results:
|
||||||
|
if r.get("ok"):
|
||||||
|
lines.append(f" canceled {r['id']} -> {r.get('api_status')}")
|
||||||
|
else:
|
||||||
|
lines.append(f" failed {r['id']}: {r.get('error')}")
|
||||||
|
for line in lines:
|
||||||
|
log(line)
|
||||||
|
return True, "Cancel finished.", results
|
||||||
|
|
||||||
|
def done(ok, _msg, _payload):
|
||||||
|
self.refresh_list(live=False)
|
||||||
|
|
||||||
|
self._run_task(task, on_done=done)
|
||||||
|
|
||||||
|
def usage_selected(self):
|
||||||
|
entries = self._selected_entries()
|
||||||
|
if len(entries) != 1:
|
||||||
|
return
|
||||||
|
bid = entries[0]["id"]
|
||||||
|
|
||||||
|
def task(log):
|
||||||
|
from util.batch_history import usage_for_batch
|
||||||
|
|
||||||
|
log(f"[BATCH] Computing usage for {bid}…")
|
||||||
|
info = usage_for_batch(bid)
|
||||||
|
u = info.get("usage") or {}
|
||||||
|
log(
|
||||||
|
f"[BATCH] tokens: in={u.get('input_tokens', 0)} out={u.get('output_tokens', 0)} "
|
||||||
|
f"cache_read={u.get('cache_read_input_tokens', 0)} "
|
||||||
|
f"cache_write={u.get('cache_creation_input_tokens', 0)} "
|
||||||
|
f"thinking={u.get('thinking_tokens', 0)}"
|
||||||
|
)
|
||||||
|
cost = info.get("actual_cost")
|
||||||
|
if cost is not None:
|
||||||
|
log(f"[BATCH] estimated billed cost (batch 50% off): ${cost:.4f}")
|
||||||
|
return True, "Usage updated.", info
|
||||||
|
|
||||||
|
def done(ok, _msg, _payload):
|
||||||
|
self.refresh_list(live=False)
|
||||||
|
|
||||||
|
self._run_task(task, on_done=done)
|
||||||
|
|
||||||
|
def redownload_selected(self):
|
||||||
|
entries = self._selected_entries()
|
||||||
|
if len(entries) != 1:
|
||||||
|
return
|
||||||
|
bid = entries[0]["id"]
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Redownload Results?",
|
||||||
|
f"Re-fetch results for {bid} into log/batch_results.json?\n\n"
|
||||||
|
"This does not create a new batch or re-collect.",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
def task(log):
|
||||||
|
from util.batch_history import redownload_batch
|
||||||
|
|
||||||
|
log(f"[BATCH] Redownloading {bid}…")
|
||||||
|
info = redownload_batch(bid)
|
||||||
|
log(
|
||||||
|
f"[BATCH] redownload ok={info.get('succeeded')} err={info.get('errored')} "
|
||||||
|
f"cost≈{info.get('actual_cost')}"
|
||||||
|
)
|
||||||
|
return True, "Redownload finished.", info
|
||||||
|
|
||||||
|
def done(ok, msg, payload):
|
||||||
|
self.refresh_list(live=False)
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Resume Consume?",
|
||||||
|
"Results are ready locally. Switch to Translation and resume consume now?",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.Yes:
|
||||||
|
self._resume_with_state("fetched", entries[0])
|
||||||
|
|
||||||
|
self._run_task(task, on_done=done)
|
||||||
|
|
||||||
|
def resume_selected(self):
|
||||||
|
entries = self._selected_entries()
|
||||||
|
if len(entries) != 1:
|
||||||
|
return
|
||||||
|
entry = entries[0]
|
||||||
|
bid = entry["id"]
|
||||||
|
|
||||||
|
def task(log):
|
||||||
|
from util.batch_history import activate_for_resume
|
||||||
|
|
||||||
|
log(f"[BATCH] Activating {bid} for resume…")
|
||||||
|
state = activate_for_resume(bid)
|
||||||
|
log(f"[BATCH] active resume state: {state}")
|
||||||
|
return True, f"Activated ({state}).", {"state": state, "entry": entry}
|
||||||
|
|
||||||
|
def done(ok, _msg, payload):
|
||||||
|
self.refresh_list(live=False)
|
||||||
|
if not ok or not payload:
|
||||||
|
return
|
||||||
|
self._resume_with_state(payload["state"], payload.get("entry") or entry)
|
||||||
|
|
||||||
|
self._run_task(task, on_done=done)
|
||||||
|
|
||||||
|
def _resume_with_state(self, resume_state: str, entry: dict):
|
||||||
|
parent = self.parent_window
|
||||||
|
if parent is None or not hasattr(parent, "translation_tab"):
|
||||||
|
QMessageBox.warning(self, "Resume", "Translation tab is not available.")
|
||||||
|
return
|
||||||
|
tt = parent.translation_tab
|
||||||
|
file_set = entry.get("file_set") or []
|
||||||
|
if file_set and hasattr(tt, "select_files_by_name"):
|
||||||
|
tt.select_files_by_name(file_set)
|
||||||
|
# Switch to Translation page (index 0).
|
||||||
|
if hasattr(parent, "switch_page"):
|
||||||
|
parent.switch_page(0)
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Start Resume?",
|
||||||
|
f"Start Batch Translate resume ({resume_state}) on the Translation tab?\n\n"
|
||||||
|
"This will not clear batch files or submit a new batch.",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
tt.start_translation(forced_resume_state=resume_state)
|
||||||
20
gui/main.py
20
gui/main.py
|
|
@ -595,6 +595,7 @@ from gui.config_tab import ConfigTab
|
||||||
from gui.translation_tab import TranslationTab
|
from gui.translation_tab import TranslationTab
|
||||||
from gui.workflow_tab import WorkflowTab
|
from gui.workflow_tab import WorkflowTab
|
||||||
from gui.wolf_workflow_tab import WolfWorkflowTab
|
from gui.wolf_workflow_tab import WolfWorkflowTab
|
||||||
|
from gui.batch_tab import BatchTab
|
||||||
|
|
||||||
class DazedMTLGUI(QMainWindow):
|
class DazedMTLGUI(QMainWindow):
|
||||||
"""Main GUI window for the DazedMTLTool."""
|
"""Main GUI window for the DazedMTLTool."""
|
||||||
|
|
@ -830,9 +831,16 @@ class DazedMTLGUI(QMainWindow):
|
||||||
sidebar_layout.addWidget(btn_workflow)
|
sidebar_layout.addWidget(btn_workflow)
|
||||||
self.nav_buttons.append(btn_workflow)
|
self.nav_buttons.append(btn_workflow)
|
||||||
|
|
||||||
# Configuration button (third)
|
# Batch history button (third)
|
||||||
|
btn_batches = self.create_nav_button("📦", "Batches")
|
||||||
|
btn_batches.setToolTip("Batches — Anthropic Message Batch history")
|
||||||
|
btn_batches.clicked.connect(lambda: self.switch_page(2))
|
||||||
|
sidebar_layout.addWidget(btn_batches)
|
||||||
|
self.nav_buttons.append(btn_batches)
|
||||||
|
|
||||||
|
# Configuration button (fourth)
|
||||||
btn_config = self.create_nav_button("⚙️", "Configuration")
|
btn_config = self.create_nav_button("⚙️", "Configuration")
|
||||||
btn_config.clicked.connect(lambda: self.switch_page(2))
|
btn_config.clicked.connect(lambda: self.switch_page(3))
|
||||||
sidebar_layout.addWidget(btn_config)
|
sidebar_layout.addWidget(btn_config)
|
||||||
self.nav_buttons.append(btn_config)
|
self.nav_buttons.append(btn_config)
|
||||||
|
|
||||||
|
|
@ -884,6 +892,8 @@ class DazedMTLGUI(QMainWindow):
|
||||||
|
|
||||||
def setup_tabs(self):
|
def setup_tabs(self):
|
||||||
"""Set up all the tabs in the interface."""
|
"""Set up all the tabs in the interface."""
|
||||||
|
self.project_root = PROJECT_ROOT
|
||||||
|
|
||||||
# Translation Execution Tab (index 0)
|
# Translation Execution Tab (index 0)
|
||||||
self.translation_tab = TranslationTab(self)
|
self.translation_tab = TranslationTab(self)
|
||||||
self.content_stack.addWidget(self.translation_tab)
|
self.content_stack.addWidget(self.translation_tab)
|
||||||
|
|
@ -892,7 +902,11 @@ class DazedMTLGUI(QMainWindow):
|
||||||
# RPGMaker and Wolf guided panels while keeping a single sidebar button.
|
# RPGMaker and Wolf guided panels while keeping a single sidebar button.
|
||||||
self.content_stack.addWidget(self._create_workflow_container())
|
self.content_stack.addWidget(self._create_workflow_container())
|
||||||
|
|
||||||
# Configuration Tab (index 2)
|
# Batch History Tab (index 2)
|
||||||
|
self.batch_tab = BatchTab(self)
|
||||||
|
self.content_stack.addWidget(self.batch_tab)
|
||||||
|
|
||||||
|
# Configuration Tab (index 3)
|
||||||
self.config_tab = ConfigTab()
|
self.config_tab = ConfigTab()
|
||||||
self.config_tab.config_changed.connect(self.on_config_changed)
|
self.config_tab.config_changed.connect(self.on_config_changed)
|
||||||
self.content_stack.addWidget(self.config_tab)
|
self.content_stack.addWidget(self.config_tab)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import datetime
|
import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -128,7 +129,18 @@ def main():
|
||||||
if resume_state:
|
if resume_state:
|
||||||
confirm = ""
|
confirm = ""
|
||||||
while confirm not in ("y", "n"):
|
while confirm not in ("y", "n"):
|
||||||
confirm = input(f"A previous batch run was interrupted ({resume_state}). Resume it? (y/n)\n").strip().lower()
|
if resume_state == "queued":
|
||||||
|
prompt = (
|
||||||
|
"A previous collect finished but was not submitted (queued). "
|
||||||
|
"Resume and submit that queue? (y/n)\n"
|
||||||
|
"(n discards the queue and re-collects - live collect charges again)\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
prompt = (
|
||||||
|
f"A previous batch run was interrupted ({resume_state}). Resume it? (y/n)\n"
|
||||||
|
"(n discards it and can bill again)\n"
|
||||||
|
)
|
||||||
|
confirm = input(prompt).strip().lower()
|
||||||
if confirm == "n":
|
if confirm == "n":
|
||||||
resume_state = None
|
resume_state = None
|
||||||
|
|
||||||
|
|
@ -322,21 +334,72 @@ files to translate are in the /files folder and that you picked the right game e
|
||||||
tqdm.write("[BATCH] No requests queued — nothing needed the API.")
|
tqdm.write("[BATCH] No requests queued — nothing needed the API.")
|
||||||
run_consume = False
|
run_consume = False
|
||||||
else:
|
else:
|
||||||
estimateBatchCost()
|
est = estimateBatchCost()
|
||||||
confirm = ""
|
confirm = ""
|
||||||
while confirm not in ("y", "n"):
|
while confirm not in ("y", "n"):
|
||||||
confirm = input("Submit batch? (y/n)\n").strip().lower()
|
confirm = input("Submit batch? (y/n)\n").strip().lower()
|
||||||
if confirm == "n":
|
if confirm == "n":
|
||||||
tqdm.write("[BATCH] Not submitted. The queue is kept in log/batch_requests.json.")
|
tqdm.write(
|
||||||
|
"[BATCH] Not submitted. The queue is kept in log/batch_requests.json "
|
||||||
|
"(resume Batch Translate later to submit without re-collecting)."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
runTranslationBatches(poll)
|
from util.translation import submitTranslationBatches, checkTranslationBatches, fetchTranslationBatches
|
||||||
|
if not submitTranslationBatches(cost_estimate=est):
|
||||||
|
run_consume = False
|
||||||
|
else:
|
||||||
|
tqdm.write(
|
||||||
|
f"[BATCH] polling every {poll}s (Ctrl-C is safe - resume later)..."
|
||||||
|
)
|
||||||
|
while not checkTranslationBatches():
|
||||||
|
time.sleep(poll)
|
||||||
|
fetchTranslationBatches()
|
||||||
|
elif resume_state == "queued":
|
||||||
|
tqdm.write(
|
||||||
|
Fore.CYAN
|
||||||
|
+ "[BATCH] Resuming queued requests (skipping re-collect)..."
|
||||||
|
+ Fore.RESET
|
||||||
|
)
|
||||||
|
if pendingBatchRequests() == 0:
|
||||||
|
tqdm.write("[BATCH] Queue is empty - nothing to submit.")
|
||||||
|
run_consume = False
|
||||||
|
else:
|
||||||
|
est = estimateBatchCost()
|
||||||
|
confirm = ""
|
||||||
|
while confirm not in ("y", "n"):
|
||||||
|
confirm = input("Submit batch? (y/n)\n").strip().lower()
|
||||||
|
if confirm == "n":
|
||||||
|
tqdm.write("[BATCH] Not submitted. Queue kept.")
|
||||||
|
return
|
||||||
|
from util.translation import submitTranslationBatches, checkTranslationBatches, fetchTranslationBatches
|
||||||
|
if not submitTranslationBatches(cost_estimate=est):
|
||||||
|
run_consume = False
|
||||||
|
else:
|
||||||
|
tqdm.write(
|
||||||
|
f"[BATCH] polling every {poll}s (Ctrl-C is safe - resume later)..."
|
||||||
|
)
|
||||||
|
while not checkTranslationBatches():
|
||||||
|
time.sleep(poll)
|
||||||
|
fetchTranslationBatches()
|
||||||
elif resume_state == "submitted":
|
elif resume_state == "submitted":
|
||||||
tqdm.write(Fore.CYAN + "[BATCH] Resuming the submitted batch..." + Fore.RESET)
|
tqdm.write(Fore.CYAN + "[BATCH] Resuming the submitted batch..." + Fore.RESET)
|
||||||
runTranslationBatches(poll)
|
runTranslationBatches(poll)
|
||||||
else: # "fetched" — results already downloaded, just write the files
|
else: # "fetched" - results already downloaded, just write the files
|
||||||
tqdm.write(Fore.CYAN + "[BATCH] Resuming from fetched results..." + Fore.RESET)
|
tqdm.write(Fore.CYAN + "[BATCH] Resuming from fetched results..." + Fore.RESET)
|
||||||
|
|
||||||
if run_consume:
|
if run_consume:
|
||||||
|
try:
|
||||||
|
from util.batch_history import missing_result_count
|
||||||
|
present, expected = missing_result_count()
|
||||||
|
if expected and present < expected:
|
||||||
|
tqdm.write(
|
||||||
|
Fore.YELLOW
|
||||||
|
+ f"[BATCH] WARNING: only {present}/{expected} results present. "
|
||||||
|
"Missing keys fall back to the live API (full price)."
|
||||||
|
+ Fore.RESET
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Pass 2 — write the translated files from the fetched results.
|
# Pass 2 — write the translated files from the fetched results.
|
||||||
# Anything the batch missed falls back to the live API.
|
# Anything the batch missed falls back to the live API.
|
||||||
tqdm.write(Fore.CYAN + "[BATCH] Pass 2/2: writing translated files..." + Fore.RESET)
|
tqdm.write(Fore.CYAN + "[BATCH] Pass 2/2: writing translated files..." + Fore.RESET)
|
||||||
|
|
|
||||||
265
tests/test_batch_history.py
Normal file
265
tests/test_batch_history.py
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for durable batch history and spend-safe ops."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import util.translation as T
|
||||||
|
import util.batch_history as BH
|
||||||
|
|
||||||
|
|
||||||
|
class BatchHistoryTestBase(unittest.TestCase):
|
||||||
|
"""Isolate batch JSON files to a temp dir."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
tmp = Path(self._tmp.name)
|
||||||
|
self._orig = {
|
||||||
|
"QUEUE": T.BATCH_QUEUE_FILE,
|
||||||
|
"STATE": T.BATCH_STATE_FILE,
|
||||||
|
"RESULTS": T.BATCH_RESULTS_FILE,
|
||||||
|
"LOCK": T.BATCH_LOCK_FILE,
|
||||||
|
"HISTORY": BH.BATCH_HISTORY_FILE,
|
||||||
|
"results_mem": T._batch_results,
|
||||||
|
"pending": dict(T._batch_queue_pending),
|
||||||
|
}
|
||||||
|
T.BATCH_QUEUE_FILE = tmp / "batch_requests.json"
|
||||||
|
T.BATCH_STATE_FILE = tmp / "batch_state.json"
|
||||||
|
T.BATCH_RESULTS_FILE = tmp / "batch_results.json"
|
||||||
|
T.BATCH_LOCK_FILE = tmp / "batch_files.lock"
|
||||||
|
BH.BATCH_HISTORY_FILE = tmp / "batch_history.json"
|
||||||
|
# batch_history imports queue/state/results paths at call time via T.* —
|
||||||
|
# but it also imported BATCH_* as names. Rebind module-level aliases.
|
||||||
|
BH.BATCH_QUEUE_FILE = T.BATCH_QUEUE_FILE
|
||||||
|
BH.BATCH_STATE_FILE = T.BATCH_STATE_FILE
|
||||||
|
BH.BATCH_RESULTS_FILE = T.BATCH_RESULTS_FILE
|
||||||
|
T._batch_results = None
|
||||||
|
T._batch_queue_pending = {}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
T.BATCH_QUEUE_FILE = self._orig["QUEUE"]
|
||||||
|
T.BATCH_STATE_FILE = self._orig["STATE"]
|
||||||
|
T.BATCH_RESULTS_FILE = self._orig["RESULTS"]
|
||||||
|
T.BATCH_LOCK_FILE = self._orig["LOCK"]
|
||||||
|
BH.BATCH_HISTORY_FILE = self._orig["HISTORY"]
|
||||||
|
BH.BATCH_QUEUE_FILE = T.BATCH_QUEUE_FILE
|
||||||
|
BH.BATCH_STATE_FILE = T.BATCH_STATE_FILE
|
||||||
|
BH.BATCH_RESULTS_FILE = T.BATCH_RESULTS_FILE
|
||||||
|
T._batch_results = self._orig["results_mem"]
|
||||||
|
T._batch_queue_pending = self._orig["pending"]
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class BatchRunStateTests(BatchHistoryTestBase):
|
||||||
|
def test_none_when_empty(self):
|
||||||
|
self.assertIsNone(T.batchRunState())
|
||||||
|
|
||||||
|
def test_queued_when_only_queue(self):
|
||||||
|
T._write_batch_file(T.BATCH_QUEUE_FILE, {"k1": {"payload": "x", "language": "English", "params": {}}})
|
||||||
|
self.assertEqual(T.batchRunState(), "queued")
|
||||||
|
|
||||||
|
def test_submitted_when_state_has_batches(self):
|
||||||
|
T._write_batch_file(
|
||||||
|
T.BATCH_STATE_FILE,
|
||||||
|
{"batches": [{"id": "msgbatch_1", "custom_ids": {"req-000000": "k1"}}]},
|
||||||
|
)
|
||||||
|
self.assertEqual(T.batchRunState(), "submitted")
|
||||||
|
|
||||||
|
def test_fetched_when_results_present(self):
|
||||||
|
T._write_batch_file(T.BATCH_RESULTS_FILE, {"k1": {"text": "hi"}})
|
||||||
|
self.assertEqual(T.batchRunState(), "fetched")
|
||||||
|
|
||||||
|
def test_fetched_when_state_status_fetched(self):
|
||||||
|
T._write_batch_file(
|
||||||
|
T.BATCH_STATE_FILE,
|
||||||
|
{"status": "fetched", "batch_ids": ["msgbatch_1"], "batches": []},
|
||||||
|
)
|
||||||
|
self.assertEqual(T.batchRunState(), "fetched")
|
||||||
|
|
||||||
|
|
||||||
|
class HistorySurvivalTests(BatchHistoryTestBase):
|
||||||
|
def test_history_survives_fetch_marker_and_clear(self):
|
||||||
|
custom_ids = {"req-000000": "cachekey1", "req-000001": "cachekey2"}
|
||||||
|
BH.record_submit(
|
||||||
|
[{"id": "msgbatch_abc", "custom_ids": custom_ids}],
|
||||||
|
model="claude-sonnet-4-5",
|
||||||
|
file_set=["Map001.json"],
|
||||||
|
cost_estimate={"batch_cached_cost": 1.23, "model": "claude-sonnet-4-5"},
|
||||||
|
)
|
||||||
|
BH.record_fetch(["msgbatch_abc"], succeeded=2, errored=0, usage={"input_tokens": 10}, actual_cost=0.5)
|
||||||
|
T._write_batch_file(T.BATCH_RESULTS_FILE, {"cachekey1": {"text": "A"}, "cachekey2": {"text": "B"}})
|
||||||
|
T._write_batch_file(
|
||||||
|
T.BATCH_STATE_FILE,
|
||||||
|
{"status": "fetched", "batch_ids": ["msgbatch_abc"], "batches": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
T.clearBatchFiles()
|
||||||
|
|
||||||
|
# Active files gone…
|
||||||
|
self.assertFalse(T.BATCH_RESULTS_FILE.exists())
|
||||||
|
self.assertFalse(T.BATCH_STATE_FILE.exists())
|
||||||
|
# …but history retains custom_ids and is marked consumed.
|
||||||
|
history = BH.read_history()
|
||||||
|
entry = history["batches"][0]
|
||||||
|
self.assertEqual(entry["id"], "msgbatch_abc")
|
||||||
|
self.assertEqual(entry["custom_ids"], custom_ids)
|
||||||
|
self.assertEqual(entry["status"], BH.STATUS_CONSUMED)
|
||||||
|
self.assertEqual(entry["file_set"], ["Map001.json"])
|
||||||
|
|
||||||
|
def test_clear_does_not_wipe_history_file(self):
|
||||||
|
BH.upsert_history_entry("msgbatch_keep", status=BH.STATUS_SUBMITTED, custom_ids={"a": "b"})
|
||||||
|
T.clearBatchFiles()
|
||||||
|
self.assertTrue(BH.BATCH_HISTORY_FILE.exists())
|
||||||
|
self.assertEqual(len(BH.read_history()["batches"]), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class RedownloadTests(BatchHistoryTestBase):
|
||||||
|
def test_redownload_rebuilds_results_from_custom_ids(self):
|
||||||
|
custom_ids = {"req-000000": "keyA", "req-000001": "keyB"}
|
||||||
|
BH.upsert_history_entry(
|
||||||
|
"msgbatch_rd",
|
||||||
|
status=BH.STATUS_ENDED,
|
||||||
|
model="claude-sonnet-4-5",
|
||||||
|
custom_ids=custom_ids,
|
||||||
|
request_count=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
usage = SimpleNamespace(
|
||||||
|
input_tokens=100,
|
||||||
|
output_tokens=50,
|
||||||
|
cache_read_input_tokens=10,
|
||||||
|
cache_creation_input_tokens=20,
|
||||||
|
thinking_tokens=5,
|
||||||
|
)
|
||||||
|
msg = SimpleNamespace(
|
||||||
|
content=[SimpleNamespace(text='{"Line1":"Hi"}')],
|
||||||
|
usage=usage,
|
||||||
|
)
|
||||||
|
ok_result = SimpleNamespace(type="succeeded", message=msg)
|
||||||
|
row = SimpleNamespace(custom_id="req-000000", result=ok_result)
|
||||||
|
row2_usage = SimpleNamespace(
|
||||||
|
input_tokens=80,
|
||||||
|
output_tokens=40,
|
||||||
|
cache_read_input_tokens=0,
|
||||||
|
cache_creation_input_tokens=0,
|
||||||
|
thinking_tokens=0,
|
||||||
|
)
|
||||||
|
msg2 = SimpleNamespace(content=[SimpleNamespace(text='{"Line1":"Yo"}')], usage=row2_usage)
|
||||||
|
row2 = SimpleNamespace(custom_id="req-000001", result=SimpleNamespace(type="succeeded", message=msg2))
|
||||||
|
|
||||||
|
fake_batch = SimpleNamespace(id="msgbatch_rd", processing_status="ended")
|
||||||
|
client = mock.MagicMock()
|
||||||
|
client.messages.batches.retrieve.return_value = fake_batch
|
||||||
|
client.messages.batches.results.return_value = [row, row2]
|
||||||
|
|
||||||
|
with mock.patch.object(BH, "_get_anthropic_client", return_value=client):
|
||||||
|
with mock.patch.object(BH, "getPricingConfig", return_value={"inputAPICost": 3.0, "outputAPICost": 15.0}):
|
||||||
|
info = BH.redownload_batch("msgbatch_rd")
|
||||||
|
|
||||||
|
self.assertEqual(info["succeeded"], 2)
|
||||||
|
results = T._read_batch_file(T.BATCH_RESULTS_FILE)
|
||||||
|
self.assertIn("keyA", results)
|
||||||
|
self.assertIn("keyB", results)
|
||||||
|
self.assertEqual(results["keyA"]["text"], '{"Line1":"Hi"}')
|
||||||
|
state = T._read_batch_file(T.BATCH_STATE_FILE)
|
||||||
|
self.assertEqual(state.get("status"), "fetched")
|
||||||
|
self.assertEqual(T.batchRunState(), "fetched")
|
||||||
|
entry = BH.read_history()["batches"][0]
|
||||||
|
self.assertEqual(entry["status"], BH.STATUS_FETCHED)
|
||||||
|
self.assertEqual(entry["custom_ids"], custom_ids)
|
||||||
|
|
||||||
|
|
||||||
|
class CancelTests(BatchHistoryTestBase):
|
||||||
|
def test_cancel_updates_history_and_active_state(self):
|
||||||
|
T._write_batch_file(
|
||||||
|
T.BATCH_STATE_FILE,
|
||||||
|
{
|
||||||
|
"batches": [
|
||||||
|
{"id": "msgbatch_c1", "custom_ids": {"req-000000": "k"}},
|
||||||
|
{"id": "msgbatch_c2", "custom_ids": {"req-000000": "k2"}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
BH.upsert_history_entry("msgbatch_c1", status=BH.STATUS_SUBMITTED, custom_ids={"req-000000": "k"})
|
||||||
|
BH.upsert_history_entry("msgbatch_c2", status=BH.STATUS_SUBMITTED, custom_ids={"req-000000": "k2"})
|
||||||
|
|
||||||
|
before = SimpleNamespace(id="msgbatch_c1", processing_status="in_progress")
|
||||||
|
after = SimpleNamespace(id="msgbatch_c1", processing_status="canceling")
|
||||||
|
client = mock.MagicMock()
|
||||||
|
client.messages.batches.retrieve.return_value = before
|
||||||
|
client.messages.batches.cancel.return_value = after
|
||||||
|
|
||||||
|
with mock.patch.object(BH, "_get_anthropic_client", return_value=client):
|
||||||
|
results = BH.cancel_batches(["msgbatch_c1"])
|
||||||
|
|
||||||
|
self.assertTrue(results[0]["ok"])
|
||||||
|
entry = next(e for e in BH.read_history()["batches"] if e["id"] == "msgbatch_c1")
|
||||||
|
self.assertEqual(entry["status"], BH.STATUS_CANCELING)
|
||||||
|
state = T._read_batch_file(T.BATCH_STATE_FILE)
|
||||||
|
ids = [b["id"] for b in state.get("batches", [])]
|
||||||
|
self.assertNotIn("msgbatch_c1", ids)
|
||||||
|
self.assertIn("msgbatch_c2", ids)
|
||||||
|
|
||||||
|
|
||||||
|
class UsageTests(BatchHistoryTestBase):
|
||||||
|
def test_usage_sums_cache_and_thinking(self):
|
||||||
|
BH.upsert_history_entry(
|
||||||
|
"msgbatch_u",
|
||||||
|
status=BH.STATUS_ENDED,
|
||||||
|
model="claude-sonnet-4-5",
|
||||||
|
custom_ids={"req-000000": "k"},
|
||||||
|
)
|
||||||
|
usage = SimpleNamespace(
|
||||||
|
input_tokens=1000,
|
||||||
|
output_tokens=200,
|
||||||
|
cache_read_input_tokens=500,
|
||||||
|
cache_creation_input_tokens=100,
|
||||||
|
thinking_tokens=50,
|
||||||
|
)
|
||||||
|
msg = SimpleNamespace(content=[SimpleNamespace(text="ok")], usage=usage)
|
||||||
|
row = SimpleNamespace(custom_id="req-000000", result=SimpleNamespace(type="succeeded", message=msg))
|
||||||
|
client = mock.MagicMock()
|
||||||
|
client.messages.batches.retrieve.return_value = SimpleNamespace(processing_status="ended")
|
||||||
|
client.messages.batches.results.return_value = [row]
|
||||||
|
|
||||||
|
with mock.patch.object(BH, "_get_anthropic_client", return_value=client):
|
||||||
|
with mock.patch.object(BH, "getPricingConfig", return_value={"inputAPICost": 3.0, "outputAPICost": 15.0}):
|
||||||
|
info = BH.usage_for_batch("msgbatch_u")
|
||||||
|
|
||||||
|
u = info["usage"]
|
||||||
|
self.assertEqual(u["input_tokens"], 1000)
|
||||||
|
self.assertEqual(u["output_tokens"], 200)
|
||||||
|
self.assertEqual(u["cache_read_input_tokens"], 500)
|
||||||
|
self.assertEqual(u["cache_creation_input_tokens"], 100)
|
||||||
|
self.assertEqual(u["thinking_tokens"], 50)
|
||||||
|
self.assertIsInstance(info["actual_cost"], float)
|
||||||
|
self.assertGreater(info["actual_cost"], 0)
|
||||||
|
entry = BH.read_history()["batches"][0]
|
||||||
|
self.assertEqual(entry["usage"]["thinking_tokens"], 50)
|
||||||
|
|
||||||
|
|
||||||
|
class ActivateResumeTests(BatchHistoryTestBase):
|
||||||
|
def test_activate_submitted_restores_state(self):
|
||||||
|
custom_ids = {"req-000000": "k"}
|
||||||
|
BH.upsert_history_entry(
|
||||||
|
"msgbatch_act",
|
||||||
|
status=BH.STATUS_SUBMITTED,
|
||||||
|
custom_ids=custom_ids,
|
||||||
|
model="claude-sonnet-4-5",
|
||||||
|
file_set=["a.json"],
|
||||||
|
)
|
||||||
|
state = BH.activate_for_resume("msgbatch_act")
|
||||||
|
self.assertEqual(state, "submitted")
|
||||||
|
disk = T._read_batch_file(T.BATCH_STATE_FILE)
|
||||||
|
self.assertEqual(disk["batches"][0]["id"], "msgbatch_act")
|
||||||
|
self.assertEqual(disk["batches"][0]["custom_ids"], custom_ids)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
620
util/batch_history.py
Normal file
620
util/batch_history.py
Normal file
|
|
@ -0,0 +1,620 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Durable Anthropic Message Batch history and spend-safe management ops.
|
||||||
|
|
||||||
|
Active run files (queue / state / results) still live in util.translation.
|
||||||
|
This module keeps an append-only index so batch ids and custom_id maps survive
|
||||||
|
fetch and clear, enabling cancel / usage / redownload / resume without
|
||||||
|
re-submitting (and re-billing) work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Optional
|
||||||
|
|
||||||
|
from util.translation import (
|
||||||
|
BATCH_LOCK,
|
||||||
|
BATCH_QUEUE_FILE,
|
||||||
|
BATCH_RESULTS_FILE,
|
||||||
|
BATCH_STATE_FILE,
|
||||||
|
_batch_file_lock,
|
||||||
|
_get_anthropic_client,
|
||||||
|
_read_batch_file,
|
||||||
|
_write_batch_file,
|
||||||
|
getPricingConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
BATCH_HISTORY_FILE = Path("log/batch_history.json")
|
||||||
|
|
||||||
|
# Local lifecycle statuses (not 1:1 with Anthropic processing_status).
|
||||||
|
STATUS_QUEUED = "queued"
|
||||||
|
STATUS_SUBMITTED = "submitted"
|
||||||
|
STATUS_CANCELING = "canceling"
|
||||||
|
STATUS_ENDED = "ended"
|
||||||
|
STATUS_FETCHED = "fetched"
|
||||||
|
STATUS_CONSUMED = "consumed"
|
||||||
|
STATUS_CANCELED = "canceled"
|
||||||
|
STATUS_ERROR = "error"
|
||||||
|
|
||||||
|
TERMINAL_STATUSES = frozenset({
|
||||||
|
STATUS_CONSUMED,
|
||||||
|
STATUS_CANCELED,
|
||||||
|
STATUS_ERROR,
|
||||||
|
})
|
||||||
|
CANCELABLE_API = frozenset({"in_progress"})
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_history() -> dict:
|
||||||
|
return {"batches": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_history_unlocked() -> dict:
|
||||||
|
"""Read history under an existing batch file lock."""
|
||||||
|
data = _read_batch_file(BATCH_HISTORY_FILE)
|
||||||
|
if not data:
|
||||||
|
return _empty_history()
|
||||||
|
if not isinstance(data.get("batches"), list):
|
||||||
|
print(f"[BATCH] Corrupt history (missing batches list): {BATCH_HISTORY_FILE}", flush=True)
|
||||||
|
return _empty_history()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _write_history_unlocked(data: dict) -> None:
|
||||||
|
_write_batch_file(BATCH_HISTORY_FILE, data)
|
||||||
|
|
||||||
|
|
||||||
|
def read_history() -> dict:
|
||||||
|
"""Return the full history document (copy-safe for callers)."""
|
||||||
|
with _batch_file_lock():
|
||||||
|
return copy.deepcopy(_read_history_unlocked())
|
||||||
|
|
||||||
|
|
||||||
|
def _find_entry(history: dict, batch_id: str) -> Optional[dict]:
|
||||||
|
for entry in history.get("batches", []):
|
||||||
|
if entry.get("id") == batch_id:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_history_entry(batch_id: str, **fields: Any) -> dict:
|
||||||
|
"""Create or update one history row. Returns the updated entry (copy)."""
|
||||||
|
with BATCH_LOCK:
|
||||||
|
with _batch_file_lock():
|
||||||
|
history = _read_history_unlocked()
|
||||||
|
entry = _find_entry(history, batch_id)
|
||||||
|
now = _utc_now()
|
||||||
|
if entry is None:
|
||||||
|
entry = {
|
||||||
|
"id": batch_id,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"status": STATUS_SUBMITTED,
|
||||||
|
"model": "",
|
||||||
|
"request_count": 0,
|
||||||
|
"file_set": [],
|
||||||
|
"cost_estimate": None,
|
||||||
|
"custom_ids": {},
|
||||||
|
"usage": None,
|
||||||
|
"actual_cost": None,
|
||||||
|
"notes": "",
|
||||||
|
"request_counts": None,
|
||||||
|
"api_status": None,
|
||||||
|
}
|
||||||
|
history.setdefault("batches", []).append(entry)
|
||||||
|
for key, value in fields.items():
|
||||||
|
if value is not None or key in ("notes", "usage", "actual_cost", "cost_estimate", "request_counts"):
|
||||||
|
entry[key] = value
|
||||||
|
entry["updated_at"] = now
|
||||||
|
_write_history_unlocked(history)
|
||||||
|
return copy.deepcopy(entry)
|
||||||
|
|
||||||
|
|
||||||
|
def record_submit(
|
||||||
|
batches: list[dict],
|
||||||
|
*,
|
||||||
|
model: str = "",
|
||||||
|
file_set: Optional[list] = None,
|
||||||
|
cost_estimate: Optional[dict] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Record newly submitted Anthropic batches into durable history."""
|
||||||
|
file_set = list(file_set or [])
|
||||||
|
for info in batches:
|
||||||
|
bid = info.get("id")
|
||||||
|
if not bid:
|
||||||
|
continue
|
||||||
|
custom_ids = dict(info.get("custom_ids") or {})
|
||||||
|
upsert_history_entry(
|
||||||
|
bid,
|
||||||
|
status=STATUS_SUBMITTED,
|
||||||
|
model=model or (cost_estimate or {}).get("model") or "",
|
||||||
|
request_count=len(custom_ids),
|
||||||
|
file_set=file_set,
|
||||||
|
cost_estimate=copy.deepcopy(cost_estimate) if cost_estimate else None,
|
||||||
|
custom_ids=custom_ids,
|
||||||
|
api_status="in_progress",
|
||||||
|
notes="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_fetch(
|
||||||
|
batch_ids: Iterable[str],
|
||||||
|
*,
|
||||||
|
succeeded: int = 0,
|
||||||
|
errored: int = 0,
|
||||||
|
usage: Optional[dict] = None,
|
||||||
|
actual_cost: Optional[float] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Mark batches as fetched after results land locally."""
|
||||||
|
note = f"fetched ok={succeeded} err={errored}"
|
||||||
|
for bid in batch_ids:
|
||||||
|
fields = {
|
||||||
|
"status": STATUS_FETCHED,
|
||||||
|
"api_status": "ended",
|
||||||
|
"notes": note,
|
||||||
|
}
|
||||||
|
if usage is not None:
|
||||||
|
fields["usage"] = copy.deepcopy(usage)
|
||||||
|
if actual_cost is not None:
|
||||||
|
fields["actual_cost"] = actual_cost
|
||||||
|
upsert_history_entry(bid, **fields)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_batches_consumed(batch_ids: Iterable[str]) -> None:
|
||||||
|
"""Mark history rows consumed after a successful consume + clear."""
|
||||||
|
for bid in batch_ids:
|
||||||
|
upsert_history_entry(bid, status=STATUS_CONSUMED, notes="consumed")
|
||||||
|
|
||||||
|
|
||||||
|
def mark_batches_canceled(batch_ids: Iterable[str], *, api_status: str = "canceling") -> None:
|
||||||
|
local = STATUS_CANCELED if api_status == "ended" else STATUS_CANCELING
|
||||||
|
for bid in batch_ids:
|
||||||
|
upsert_history_entry(
|
||||||
|
bid,
|
||||||
|
status=local,
|
||||||
|
api_status=api_status,
|
||||||
|
notes=f"cancel requested ({api_status})",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_local_batches(*, refresh_live: bool = False) -> list[dict]:
|
||||||
|
"""Return history entries newest-first. Optionally refresh non-terminal via API."""
|
||||||
|
history = read_history()
|
||||||
|
entries = list(history.get("batches") or [])
|
||||||
|
if refresh_live:
|
||||||
|
for entry in entries:
|
||||||
|
status = entry.get("status")
|
||||||
|
if status in TERMINAL_STATUSES or status == STATUS_CONSUMED:
|
||||||
|
continue
|
||||||
|
if status in (STATUS_FETCHED, STATUS_QUEUED):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
refresh_batch_status(entry["id"])
|
||||||
|
except Exception as exc:
|
||||||
|
upsert_history_entry(entry["id"], notes=f"refresh failed: {exc}")
|
||||||
|
history = read_history()
|
||||||
|
entries = list(history.get("batches") or [])
|
||||||
|
entries.sort(key=lambda e: e.get("created_at") or "", reverse=True)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_batch_status(batch_id: str) -> dict:
|
||||||
|
"""Retrieve live Anthropic status and update the history row."""
|
||||||
|
client = _get_anthropic_client()
|
||||||
|
b = client.messages.batches.retrieve(batch_id)
|
||||||
|
api_status = getattr(b, "processing_status", None) or ""
|
||||||
|
counts = getattr(b, "request_counts", None)
|
||||||
|
counts_dict = None
|
||||||
|
if counts is not None:
|
||||||
|
counts_dict = {
|
||||||
|
k: getattr(counts, k, 0) or 0
|
||||||
|
for k in ("processing", "succeeded", "errored", "canceled", "expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = None
|
||||||
|
with _batch_file_lock():
|
||||||
|
history = _read_history_unlocked()
|
||||||
|
entry = _find_entry(history, batch_id)
|
||||||
|
prev_status = (entry or {}).get("status")
|
||||||
|
|
||||||
|
# Map API status onto local lifecycle without clobbering fetched/consumed.
|
||||||
|
local_status = None
|
||||||
|
if prev_status in (STATUS_FETCHED, STATUS_CONSUMED):
|
||||||
|
local_status = prev_status
|
||||||
|
elif api_status == "in_progress":
|
||||||
|
local_status = STATUS_SUBMITTED
|
||||||
|
elif api_status == "canceling":
|
||||||
|
local_status = STATUS_CANCELING
|
||||||
|
elif api_status == "ended":
|
||||||
|
# Ended after cancel vs normal end - inspect counts when available.
|
||||||
|
if counts_dict and (counts_dict.get("canceled") or 0) > 0 and (counts_dict.get("succeeded") or 0) == 0:
|
||||||
|
local_status = STATUS_CANCELED
|
||||||
|
else:
|
||||||
|
local_status = STATUS_ENDED
|
||||||
|
|
||||||
|
fields: dict[str, Any] = {
|
||||||
|
"api_status": api_status,
|
||||||
|
"request_counts": counts_dict,
|
||||||
|
}
|
||||||
|
if local_status:
|
||||||
|
fields["status"] = local_status
|
||||||
|
return upsert_history_entry(batch_id, **fields)
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_batches(batch_ids: Iterable[str]) -> list[dict]:
|
||||||
|
"""Cancel in-progress batches. Never submits new work.
|
||||||
|
|
||||||
|
Also updates active batch_state so resume does not keep polling canceled ids.
|
||||||
|
"""
|
||||||
|
client = _get_anthropic_client()
|
||||||
|
results = []
|
||||||
|
canceled_active = []
|
||||||
|
for bid in batch_ids:
|
||||||
|
try:
|
||||||
|
b = client.messages.batches.retrieve(bid)
|
||||||
|
api_status = getattr(b, "processing_status", "") or ""
|
||||||
|
if api_status not in CANCELABLE_API:
|
||||||
|
results.append({"id": bid, "ok": False, "error": f"not cancelable ({api_status})"})
|
||||||
|
upsert_history_entry(bid, notes=f"cancel skipped: {api_status}", api_status=api_status)
|
||||||
|
continue
|
||||||
|
b = client.messages.batches.cancel(bid)
|
||||||
|
new_status = getattr(b, "processing_status", "canceling") or "canceling"
|
||||||
|
mark_batches_canceled([bid], api_status=new_status)
|
||||||
|
canceled_active.append(bid)
|
||||||
|
results.append({"id": bid, "ok": True, "api_status": new_status})
|
||||||
|
except Exception as exc:
|
||||||
|
results.append({"id": bid, "ok": False, "error": str(exc)})
|
||||||
|
upsert_history_entry(bid, notes=f"cancel failed: {exc}", status=STATUS_ERROR)
|
||||||
|
|
||||||
|
if canceled_active:
|
||||||
|
_remove_active_batch_ids(canceled_active)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_active_batch_ids(batch_ids: list[str]) -> None:
|
||||||
|
"""Drop canceled ids from active state so poll/resume does not wait on them."""
|
||||||
|
id_set = set(batch_ids)
|
||||||
|
with BATCH_LOCK:
|
||||||
|
with _batch_file_lock():
|
||||||
|
state = _read_batch_file(BATCH_STATE_FILE)
|
||||||
|
batches = [b for b in (state.get("batches") or []) if b.get("id") not in id_set]
|
||||||
|
if not batches:
|
||||||
|
# No active submitted batches left.
|
||||||
|
if state.get("status") == "fetched" or state.get("batch_ids"):
|
||||||
|
state["batches"] = []
|
||||||
|
_write_batch_file(BATCH_STATE_FILE, state)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if BATCH_STATE_FILE.exists():
|
||||||
|
BATCH_STATE_FILE.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
state["batches"] = batches
|
||||||
|
_write_batch_file(BATCH_STATE_FILE, state)
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_from_message(u) -> dict:
|
||||||
|
cr = getattr(u, "cache_read_input_tokens", 0) or 0
|
||||||
|
cw = getattr(u, "cache_creation_input_tokens", 0) or 0
|
||||||
|
inp = getattr(u, "input_tokens", 0) or 0
|
||||||
|
out = getattr(u, "output_tokens", 0) or 0
|
||||||
|
# Adaptive thinking may surface under several names depending on SDK version.
|
||||||
|
thinking = (
|
||||||
|
getattr(u, "thinking_tokens", None)
|
||||||
|
or getattr(u, "output_thinking_tokens", None)
|
||||||
|
or 0
|
||||||
|
) or 0
|
||||||
|
return {
|
||||||
|
"input_tokens": inp,
|
||||||
|
"output_tokens": out,
|
||||||
|
"cache_read_input_tokens": cr,
|
||||||
|
"cache_creation_input_tokens": cw,
|
||||||
|
"thinking_tokens": thinking,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _price_usage(usage: dict, model: str) -> float:
|
||||||
|
"""Price real batch usage with cache multipliers and 50% batch discount."""
|
||||||
|
pricing = getPricingConfig(model)
|
||||||
|
br = pricing["inputAPICost"] / 1_000_000
|
||||||
|
orr = pricing["outputAPICost"] / 1_000_000
|
||||||
|
cr = usage.get("cache_read_input_tokens", 0) or 0
|
||||||
|
cw = usage.get("cache_creation_input_tokens", 0) or 0
|
||||||
|
inp = usage.get("input_tokens", 0) or 0
|
||||||
|
out = usage.get("output_tokens", 0) or 0
|
||||||
|
thinking = usage.get("thinking_tokens", 0) or 0
|
||||||
|
# Thinking tokens are billed as output on Anthropic.
|
||||||
|
raw = cr * br * 0.10 + cw * br * 2.00 + inp * br + (out + thinking) * orr
|
||||||
|
return raw * 0.50
|
||||||
|
|
||||||
|
|
||||||
|
def _sum_usage_from_results(client, batch_id: str, custom_ids: dict) -> tuple[dict, int, int]:
|
||||||
|
"""Stream batch results and sum usage. Returns (usage, succeeded, errored)."""
|
||||||
|
totals = {
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 0,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
"thinking_tokens": 0,
|
||||||
|
}
|
||||||
|
succeeded = 0
|
||||||
|
errored = 0
|
||||||
|
for r in client.messages.batches.results(batch_id):
|
||||||
|
res = r.result
|
||||||
|
if res.type != "succeeded":
|
||||||
|
errored += 1
|
||||||
|
continue
|
||||||
|
succeeded += 1
|
||||||
|
part = _usage_from_message(res.message.usage)
|
||||||
|
for k, v in part.items():
|
||||||
|
totals[k] = totals.get(k, 0) + (v or 0)
|
||||||
|
# custom_ids unused for summing but kept for API symmetry / future filters
|
||||||
|
_ = custom_ids
|
||||||
|
return totals, succeeded, errored
|
||||||
|
|
||||||
|
|
||||||
|
def usage_for_batch(batch_id: str, model: Optional[str] = None) -> dict:
|
||||||
|
"""Sum real billed tokens from Anthropic results and persist onto history."""
|
||||||
|
history = read_history()
|
||||||
|
entry = _find_entry(history, batch_id)
|
||||||
|
if entry is None:
|
||||||
|
raise ValueError(f"Unknown batch id (not in local history): {batch_id}")
|
||||||
|
|
||||||
|
client = _get_anthropic_client()
|
||||||
|
# Ensure batch has ended before streaming results.
|
||||||
|
b = client.messages.batches.retrieve(batch_id)
|
||||||
|
api_status = getattr(b, "processing_status", "") or ""
|
||||||
|
if api_status != "ended":
|
||||||
|
raise ValueError(f"Batch {batch_id} is not ended (status={api_status})")
|
||||||
|
|
||||||
|
custom_ids = dict(entry.get("custom_ids") or {})
|
||||||
|
usage, succeeded, errored = _sum_usage_from_results(client, batch_id, custom_ids)
|
||||||
|
use_model = model or entry.get("model") or ""
|
||||||
|
cost = _price_usage(usage, use_model) if use_model else None
|
||||||
|
updated = upsert_history_entry(
|
||||||
|
batch_id,
|
||||||
|
usage=usage,
|
||||||
|
actual_cost=cost,
|
||||||
|
api_status="ended",
|
||||||
|
notes=f"usage ok={succeeded} err={errored}",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": batch_id,
|
||||||
|
"usage": usage,
|
||||||
|
"actual_cost": cost,
|
||||||
|
"succeeded": succeeded,
|
||||||
|
"errored": errored,
|
||||||
|
"model": use_model,
|
||||||
|
"entry": updated,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _result_entry_from_message(msg) -> dict:
|
||||||
|
text = "".join(getattr(b, "text", "") or "" for b in msg.content)
|
||||||
|
u = msg.usage
|
||||||
|
part = _usage_from_message(u)
|
||||||
|
cr = part["cache_read_input_tokens"]
|
||||||
|
cw = part["cache_creation_input_tokens"]
|
||||||
|
inp = part["input_tokens"]
|
||||||
|
out = part["output_tokens"]
|
||||||
|
entry = {
|
||||||
|
"text": text,
|
||||||
|
"prompt_tokens": inp + cr + cw,
|
||||||
|
"completion_tokens": out,
|
||||||
|
"cache_read_input_tokens": cr,
|
||||||
|
"cache_creation_input_tokens": cw,
|
||||||
|
}
|
||||||
|
if part.get("thinking_tokens"):
|
||||||
|
entry["thinking_tokens"] = part["thinking_tokens"]
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def download_batch_results(
|
||||||
|
batch_id: str,
|
||||||
|
custom_ids: dict,
|
||||||
|
*,
|
||||||
|
client=None,
|
||||||
|
) -> tuple[dict, list, dict]:
|
||||||
|
"""Download one ended batch into a cache-key -> result map.
|
||||||
|
|
||||||
|
Returns (results, errored_list, usage_totals).
|
||||||
|
"""
|
||||||
|
client = client or _get_anthropic_client()
|
||||||
|
results, errored = {}, []
|
||||||
|
usage_totals = {
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 0,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
"thinking_tokens": 0,
|
||||||
|
}
|
||||||
|
for r in client.messages.batches.results(batch_id):
|
||||||
|
key = custom_ids.get(r.custom_id)
|
||||||
|
if key is None:
|
||||||
|
continue
|
||||||
|
res = r.result
|
||||||
|
if res.type != "succeeded":
|
||||||
|
detail = res.type
|
||||||
|
err = getattr(res, "error", None)
|
||||||
|
if err is not None:
|
||||||
|
inner = getattr(err, "error", err)
|
||||||
|
detail = (
|
||||||
|
f"{res.type} | {getattr(inner, 'type', '')}: "
|
||||||
|
f"{str(getattr(inner, 'message', '') or err)[:200]}"
|
||||||
|
)
|
||||||
|
errored.append((r.custom_id, detail))
|
||||||
|
continue
|
||||||
|
msg = res.message
|
||||||
|
results[key] = _result_entry_from_message(msg)
|
||||||
|
part = _usage_from_message(msg.usage)
|
||||||
|
for k, v in part.items():
|
||||||
|
usage_totals[k] = usage_totals.get(k, 0) + (v or 0)
|
||||||
|
return results, errored, usage_totals
|
||||||
|
|
||||||
|
|
||||||
|
def redownload_batch(batch_id: str) -> dict:
|
||||||
|
"""Re-fetch results into batch_results.json using stored custom_ids. No re-submit."""
|
||||||
|
history = read_history()
|
||||||
|
entry = _find_entry(history, batch_id)
|
||||||
|
if entry is None:
|
||||||
|
raise ValueError(f"Unknown batch id (not in local history): {batch_id}")
|
||||||
|
custom_ids = dict(entry.get("custom_ids") or {})
|
||||||
|
if not custom_ids:
|
||||||
|
raise ValueError(f"Batch {batch_id} has no stored custom_ids - cannot redownload")
|
||||||
|
|
||||||
|
client = _get_anthropic_client()
|
||||||
|
b = client.messages.batches.retrieve(batch_id)
|
||||||
|
api_status = getattr(b, "processing_status", "") or ""
|
||||||
|
if api_status != "ended":
|
||||||
|
raise ValueError(f"Batch {batch_id} is not ended (status={api_status})")
|
||||||
|
|
||||||
|
results, errored, usage = download_batch_results(batch_id, custom_ids, client=client)
|
||||||
|
model = entry.get("model") or ""
|
||||||
|
cost = _price_usage(usage, model) if model else None
|
||||||
|
|
||||||
|
import util.translation as T
|
||||||
|
|
||||||
|
with T.BATCH_LOCK:
|
||||||
|
with _batch_file_lock():
|
||||||
|
merged = _read_batch_file(BATCH_RESULTS_FILE)
|
||||||
|
merged.update(results)
|
||||||
|
_write_batch_file(BATCH_RESULTS_FILE, merged)
|
||||||
|
# Activate fetched state without re-submit; keep custom_ids in history.
|
||||||
|
_write_batch_file(
|
||||||
|
BATCH_STATE_FILE,
|
||||||
|
{"status": "fetched", "batch_ids": [batch_id], "batches": []},
|
||||||
|
)
|
||||||
|
# Queue is no longer needed for consume.
|
||||||
|
try:
|
||||||
|
if BATCH_QUEUE_FILE.exists():
|
||||||
|
BATCH_QUEUE_FILE.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
T._batch_results = None
|
||||||
|
|
||||||
|
record_fetch([batch_id], succeeded=len(results), errored=len(errored), usage=usage, actual_cost=cost)
|
||||||
|
return {
|
||||||
|
"id": batch_id,
|
||||||
|
"succeeded": len(results),
|
||||||
|
"errored": len(errored),
|
||||||
|
"errors": errored[:20],
|
||||||
|
"usage": usage,
|
||||||
|
"actual_cost": cost,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def activate_for_resume(batch_id: str) -> str:
|
||||||
|
"""Ensure active files match a history entry for Translation-tab resume.
|
||||||
|
|
||||||
|
Returns the batchRunState string to pass as batch_resume_state:
|
||||||
|
'submitted' | 'fetched'.
|
||||||
|
"""
|
||||||
|
history = read_history()
|
||||||
|
entry = _find_entry(history, batch_id)
|
||||||
|
if entry is None:
|
||||||
|
raise ValueError(f"Unknown batch id: {batch_id}")
|
||||||
|
|
||||||
|
status = entry.get("status")
|
||||||
|
custom_ids = dict(entry.get("custom_ids") or {})
|
||||||
|
|
||||||
|
if status in (STATUS_FETCHED, STATUS_ENDED, STATUS_CONSUMED):
|
||||||
|
# Prefer local results; redownload if missing.
|
||||||
|
with _batch_file_lock():
|
||||||
|
results = _read_batch_file(BATCH_RESULTS_FILE)
|
||||||
|
if not results:
|
||||||
|
if status == STATUS_CONSUMED:
|
||||||
|
raise ValueError(
|
||||||
|
f"Batch {batch_id} was already consumed and local results are gone. "
|
||||||
|
"Use Redownload first."
|
||||||
|
)
|
||||||
|
redownload_batch(batch_id)
|
||||||
|
else:
|
||||||
|
with BATCH_LOCK:
|
||||||
|
with _batch_file_lock():
|
||||||
|
_write_batch_file(
|
||||||
|
BATCH_STATE_FILE,
|
||||||
|
{"status": "fetched", "batch_ids": [batch_id], "batches": []},
|
||||||
|
)
|
||||||
|
return "fetched"
|
||||||
|
|
||||||
|
if status in (STATUS_SUBMITTED, STATUS_CANCELING, STATUS_ENDED):
|
||||||
|
# Restore active state so poll/fetch can continue.
|
||||||
|
with BATCH_LOCK:
|
||||||
|
with _batch_file_lock():
|
||||||
|
state = _read_batch_file(BATCH_STATE_FILE)
|
||||||
|
batches = list(state.get("batches") or [])
|
||||||
|
existing = {b.get("id") for b in batches}
|
||||||
|
if batch_id not in existing:
|
||||||
|
batches.append({"id": batch_id, "custom_ids": custom_ids})
|
||||||
|
state = {
|
||||||
|
"batches": batches,
|
||||||
|
"submitted_at": state.get("submitted_at") or entry.get("created_at"),
|
||||||
|
"model": entry.get("model") or state.get("model") or "",
|
||||||
|
"file_set": entry.get("file_set") or state.get("file_set") or [],
|
||||||
|
"cost_estimate": entry.get("cost_estimate") or state.get("cost_estimate"),
|
||||||
|
}
|
||||||
|
_write_batch_file(BATCH_STATE_FILE, state)
|
||||||
|
if status == STATUS_ENDED:
|
||||||
|
# Already ended on Anthropic - fetch path via resume submitted → poll sees ended.
|
||||||
|
return "submitted"
|
||||||
|
return "submitted"
|
||||||
|
|
||||||
|
if status == STATUS_CANCELED:
|
||||||
|
raise ValueError(f"Batch {batch_id} was canceled - nothing to resume")
|
||||||
|
|
||||||
|
raise ValueError(f"Batch {batch_id} cannot be resumed from status={status}")
|
||||||
|
|
||||||
|
|
||||||
|
def missing_result_count() -> tuple[int, int]:
|
||||||
|
"""Return (present, expected) result counts for the active fetched run.
|
||||||
|
|
||||||
|
expected is summed from history request_count for active fetched batch ids.
|
||||||
|
"""
|
||||||
|
with _batch_file_lock():
|
||||||
|
results = _read_batch_file(BATCH_RESULTS_FILE)
|
||||||
|
state = _read_batch_file(BATCH_STATE_FILE)
|
||||||
|
history = _read_history_unlocked()
|
||||||
|
|
||||||
|
present = len(results)
|
||||||
|
ids = list(state.get("batch_ids") or [])
|
||||||
|
if not ids:
|
||||||
|
ids = [b.get("id") for b in (state.get("batches") or []) if b.get("id")]
|
||||||
|
expected = 0
|
||||||
|
for bid in ids:
|
||||||
|
entry = _find_entry(history, bid)
|
||||||
|
if entry:
|
||||||
|
expected += int(entry.get("request_count") or len(entry.get("custom_ids") or {}))
|
||||||
|
if not expected:
|
||||||
|
expected = present
|
||||||
|
return present, expected
|
||||||
|
|
||||||
|
|
||||||
|
def active_fetched_batch_ids() -> list[str]:
|
||||||
|
"""Batch ids associated with the current fetched active run."""
|
||||||
|
with _batch_file_lock():
|
||||||
|
state = _read_batch_file(BATCH_STATE_FILE)
|
||||||
|
ids = list(state.get("batch_ids") or [])
|
||||||
|
if ids:
|
||||||
|
return ids
|
||||||
|
return [b.get("id") for b in (state.get("batches") or []) if b.get("id")]
|
||||||
|
|
||||||
|
|
||||||
|
def on_clear_active_files(*, had_results: bool, fetched_ids: Optional[list] = None) -> None:
|
||||||
|
"""Called from clearBatchFiles - mark fetched ids consumed, never wipe history."""
|
||||||
|
if not had_results and not fetched_ids:
|
||||||
|
return
|
||||||
|
ids = list(fetched_ids or [])
|
||||||
|
if not ids and had_results:
|
||||||
|
# Fall back: mark any currently-fetched history rows as consumed.
|
||||||
|
history = read_history()
|
||||||
|
ids = [e["id"] for e in history.get("batches", []) if e.get("status") == STATUS_FETCHED]
|
||||||
|
if ids:
|
||||||
|
mark_batches_consumed(ids)
|
||||||
|
|
@ -756,9 +756,12 @@ def _read_batch_file(path):
|
||||||
if path.exists():
|
if path.exists():
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return data if isinstance(data, dict) else {}
|
if isinstance(data, dict):
|
||||||
except Exception:
|
return data
|
||||||
pass
|
print(f"[BATCH] Corrupt batch file (not a JSON object): {path}", flush=True)
|
||||||
|
except Exception as exc:
|
||||||
|
# State/history corruption must not look like an empty run - log it.
|
||||||
|
print(f"[BATCH] Failed to read {path}: {exc}", flush=True)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -840,30 +843,54 @@ def pendingBatchRequests():
|
||||||
|
|
||||||
|
|
||||||
def batchRunState():
|
def batchRunState():
|
||||||
"""'submitted' when a batch is still in flight, 'fetched' when results are
|
"""Resume detector for an interrupted batch run.
|
||||||
waiting to be consumed, else None. Lets an interrupted batch run resume
|
|
||||||
instead of re-collecting and paying for a second submission."""
|
Returns:
|
||||||
|
'submitted' - Anthropic batch(es) in flight (or ended but not fetched)
|
||||||
|
'fetched' - results on disk waiting for consume
|
||||||
|
'queued' - collect finished / submit declined; queue still on disk
|
||||||
|
None - nothing to resume
|
||||||
|
"""
|
||||||
with _batch_file_lock():
|
with _batch_file_lock():
|
||||||
if _read_batch_file(BATCH_STATE_FILE).get("batches"):
|
state = _read_batch_file(BATCH_STATE_FILE)
|
||||||
|
if state.get("batches"):
|
||||||
return "submitted"
|
return "submitted"
|
||||||
if _read_batch_file(BATCH_RESULTS_FILE):
|
if state.get("status") == "fetched" or _read_batch_file(BATCH_RESULTS_FILE):
|
||||||
return "fetched"
|
return "fetched"
|
||||||
|
if _read_batch_file(BATCH_QUEUE_FILE):
|
||||||
|
return "queued"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def clearBatchFiles():
|
def clearBatchFiles():
|
||||||
"""Remove queue/state/results left over from any previous batch run."""
|
"""Remove active queue/state/results. Never deletes durable batch history."""
|
||||||
global _batch_results, _batch_queue_pending
|
global _batch_results, _batch_queue_pending
|
||||||
with BATCH_LOCK:
|
with BATCH_LOCK:
|
||||||
_batch_results = None
|
fetched_ids = []
|
||||||
_batch_queue_pending = {}
|
had_results = False
|
||||||
with _batch_file_lock():
|
with _batch_file_lock():
|
||||||
|
state = _read_batch_file(BATCH_STATE_FILE)
|
||||||
|
had_results = bool(_read_batch_file(BATCH_RESULTS_FILE)) or state.get("status") == "fetched"
|
||||||
|
if had_results:
|
||||||
|
fetched_ids = list(state.get("batch_ids") or [])
|
||||||
|
if not fetched_ids:
|
||||||
|
fetched_ids = [b.get("id") for b in (state.get("batches") or []) if b.get("id")]
|
||||||
for path in (BATCH_QUEUE_FILE, BATCH_STATE_FILE, BATCH_RESULTS_FILE):
|
for path in (BATCH_QUEUE_FILE, BATCH_STATE_FILE, BATCH_RESULTS_FILE):
|
||||||
try:
|
try:
|
||||||
if path.exists():
|
if path.exists():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
_batch_results = None
|
||||||
|
_batch_queue_pending = {}
|
||||||
|
# Only mark history consumed when clearing after a successful fetch/consume,
|
||||||
|
# never when discarding a still-submitted or queued run.
|
||||||
|
if had_results:
|
||||||
|
try:
|
||||||
|
from util.batch_history import on_clear_active_files
|
||||||
|
on_clear_active_files(had_results=True, fetched_ids=fetched_ids or None)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[BATCH] history consume mark failed: {exc}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
def _get_anthropic_client():
|
def _get_anthropic_client():
|
||||||
|
|
@ -968,11 +995,13 @@ def estimateBatchCost(model=None):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def submitTranslationBatches():
|
def submitTranslationBatches(file_set=None, cost_estimate=None):
|
||||||
"""Submit the queued requests to the Anthropic Message Batches API.
|
"""Submit the queued requests to the Anthropic Message Batches API.
|
||||||
|
|
||||||
Splits at the API limits and saves the custom_id -> cache-key mapping so
|
Splits at the API limits and saves the custom_id -> cache-key mapping so
|
||||||
fetchTranslationBatches can route results back. Returns the batch ids."""
|
fetchTranslationBatches can route results back. Also appends durable
|
||||||
|
history entries (custom_ids survive later fetch/clear). Returns the batch ids.
|
||||||
|
"""
|
||||||
flush_batch_queue()
|
flush_batch_queue()
|
||||||
with _batch_file_lock():
|
with _batch_file_lock():
|
||||||
queue = _read_batch_file(BATCH_QUEUE_FILE)
|
queue = _read_batch_file(BATCH_QUEUE_FILE)
|
||||||
|
|
@ -983,6 +1012,16 @@ def submitTranslationBatches():
|
||||||
client = _get_anthropic_client()
|
client = _get_anthropic_client()
|
||||||
batches = []
|
batches = []
|
||||||
requests, id_map, size = [], {}, 0
|
requests, id_map, size = [], {}, 0
|
||||||
|
models = set()
|
||||||
|
for entry in queue.values():
|
||||||
|
m = (entry.get("params") or {}).get("model")
|
||||||
|
if m:
|
||||||
|
models.add(m)
|
||||||
|
model = (
|
||||||
|
(cost_estimate or {}).get("model")
|
||||||
|
or next(iter(models), None)
|
||||||
|
or os.getenv("model", "")
|
||||||
|
)
|
||||||
|
|
||||||
def _submit():
|
def _submit():
|
||||||
nonlocal requests, id_map, size
|
nonlocal requests, id_map, size
|
||||||
|
|
@ -1003,48 +1042,134 @@ def submitTranslationBatches():
|
||||||
_submit()
|
_submit()
|
||||||
_submit()
|
_submit()
|
||||||
|
|
||||||
|
submitted_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
state_doc = {
|
||||||
|
"batches": batches,
|
||||||
|
"submitted_at": submitted_at,
|
||||||
|
"model": model,
|
||||||
|
"file_set": list(file_set or []),
|
||||||
|
"cost_estimate": cost_estimate,
|
||||||
|
"request_count": sum(len(b.get("custom_ids") or {}) for b in batches),
|
||||||
|
}
|
||||||
with BATCH_LOCK:
|
with BATCH_LOCK:
|
||||||
with _batch_file_lock():
|
with _batch_file_lock():
|
||||||
_write_batch_file(BATCH_STATE_FILE, {"batches": batches})
|
_write_batch_file(BATCH_STATE_FILE, state_doc)
|
||||||
|
try:
|
||||||
|
from util.batch_history import record_submit
|
||||||
|
record_submit(
|
||||||
|
batches,
|
||||||
|
model=model,
|
||||||
|
file_set=file_set,
|
||||||
|
cost_estimate=cost_estimate,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[BATCH] history record_submit failed: {exc}", flush=True)
|
||||||
return [b["id"] for b in batches]
|
return [b["id"] for b in batches]
|
||||||
|
|
||||||
|
|
||||||
def checkTranslationBatches():
|
def checkTranslationBatches():
|
||||||
"""Print the processing status of submitted batches. True when all ended."""
|
"""Print the processing status of submitted batches. True when all ended.
|
||||||
|
|
||||||
|
Also returns a structured status list as the second value when called as
|
||||||
|
``ended, statuses = checkTranslationBatchStatuses()`` - prefer that helper
|
||||||
|
for UI work. Kept for CLI/print compatibility.
|
||||||
|
"""
|
||||||
|
ended, _statuses = checkTranslationBatchStatuses(print_status=True)
|
||||||
|
return ended
|
||||||
|
|
||||||
|
|
||||||
|
def checkTranslationBatchStatuses(print_status=True):
|
||||||
|
"""Return (all_ended, statuses) for submitted batches.
|
||||||
|
|
||||||
|
Each status dict: id, api_status, counts{processing,succeeded,errored,canceled,expired}.
|
||||||
|
"""
|
||||||
with _batch_file_lock():
|
with _batch_file_lock():
|
||||||
state = _read_batch_file(BATCH_STATE_FILE)
|
state = _read_batch_file(BATCH_STATE_FILE)
|
||||||
if not state.get("batches"):
|
if not state.get("batches"):
|
||||||
print("[BATCH] No submitted batches — submit the queue first.", flush=True)
|
if print_status:
|
||||||
return False
|
print("[BATCH] No submitted batches - submit the queue first.", flush=True)
|
||||||
|
return False, []
|
||||||
client = _get_anthropic_client()
|
client = _get_anthropic_client()
|
||||||
all_ended = True
|
all_ended = True
|
||||||
|
statuses = []
|
||||||
for info in state["batches"]:
|
for info in state["batches"]:
|
||||||
b = client.messages.batches.retrieve(info["id"])
|
bid = info["id"]
|
||||||
counts = getattr(b, "request_counts", None)
|
b = client.messages.batches.retrieve(bid)
|
||||||
suffix = f" counts: {counts}" if counts else ""
|
api_status = getattr(b, "processing_status", "") or ""
|
||||||
print(f"[BATCH] {time.strftime('%H:%M:%S')} {b.id}: {b.processing_status}{suffix}", flush=True)
|
counts_obj = getattr(b, "request_counts", None)
|
||||||
if b.processing_status != "ended":
|
counts = {
|
||||||
|
k: int(getattr(counts_obj, k, 0) or 0)
|
||||||
|
for k in ("processing", "succeeded", "errored", "canceled", "expired")
|
||||||
|
} if counts_obj is not None else {
|
||||||
|
"processing": 0, "succeeded": 0, "errored": 0, "canceled": 0, "expired": 0,
|
||||||
|
}
|
||||||
|
statuses.append({
|
||||||
|
"id": bid,
|
||||||
|
"api_status": api_status,
|
||||||
|
"counts": counts,
|
||||||
|
"request_count": len(info.get("custom_ids") or {}),
|
||||||
|
})
|
||||||
|
if print_status:
|
||||||
|
parts = [f"{k[:4]}={v}" for k, v in counts.items() if v]
|
||||||
|
suffix = (" " + " ".join(parts)) if parts else ""
|
||||||
|
print(
|
||||||
|
f"[BATCH] {time.strftime('%H:%M:%S')} {bid}: {api_status}{suffix}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
if api_status != "ended":
|
||||||
all_ended = False
|
all_ended = False
|
||||||
return all_ended
|
return all_ended, statuses
|
||||||
|
|
||||||
|
|
||||||
def fetchTranslationBatches():
|
def fetchTranslationBatches(batches=None):
|
||||||
"""Download finished batch results into the local results store.
|
"""Download finished batch results into the local results store.
|
||||||
|
|
||||||
Successes are stored keyed by the payload cache key for the consume pass;
|
Successes are stored keyed by the payload cache key for the consume pass;
|
||||||
errored/expired requests are reported and simply fall back to the live API
|
errored/expired requests are reported and simply fall back to the live API
|
||||||
during consume. Returns (succeeded, errored) counts."""
|
during consume. Durable history retains custom_ids for later redownload.
|
||||||
|
|
||||||
|
batches: optional list of {id, custom_ids} (defaults to active batch_state).
|
||||||
|
Returns (succeeded, errored) counts.
|
||||||
|
"""
|
||||||
global _batch_results
|
global _batch_results
|
||||||
with _batch_file_lock():
|
with _batch_file_lock():
|
||||||
state = _read_batch_file(BATCH_STATE_FILE)
|
state = _read_batch_file(BATCH_STATE_FILE)
|
||||||
if not state.get("batches"):
|
batch_list = batches if batches is not None else (state.get("batches") or [])
|
||||||
print("[BATCH] No submitted batches — nothing to fetch.", flush=True)
|
if not batch_list:
|
||||||
|
print("[BATCH] No submitted batches - nothing to fetch.", flush=True)
|
||||||
return 0, 0
|
return 0, 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
from util.batch_history import download_batch_results, record_fetch, _price_usage
|
||||||
|
except Exception:
|
||||||
|
download_batch_results = None
|
||||||
|
record_fetch = None
|
||||||
|
_price_usage = None
|
||||||
|
|
||||||
client = _get_anthropic_client()
|
client = _get_anthropic_client()
|
||||||
results, errored = {}, []
|
results, errored = {}, []
|
||||||
for info in state["batches"]:
|
usage_totals = {
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 0,
|
||||||
|
"cache_creation_input_tokens": 0,
|
||||||
|
"thinking_tokens": 0,
|
||||||
|
}
|
||||||
|
batch_ids = []
|
||||||
|
for info in batch_list:
|
||||||
|
bid = info.get("id")
|
||||||
|
if bid:
|
||||||
|
batch_ids.append(bid)
|
||||||
id_map = info.get("custom_ids", {})
|
id_map = info.get("custom_ids", {})
|
||||||
for r in client.messages.batches.results(info["id"]):
|
if download_batch_results is not None:
|
||||||
|
part, err_part, usage_part = download_batch_results(bid, id_map, client=client)
|
||||||
|
results.update(part)
|
||||||
|
errored.extend(err_part)
|
||||||
|
for k, v in usage_part.items():
|
||||||
|
usage_totals[k] = usage_totals.get(k, 0) + (v or 0)
|
||||||
|
continue
|
||||||
|
# Fallback if batch_history import failed - preserve prior behaviour.
|
||||||
|
for r in client.messages.batches.results(bid):
|
||||||
key = id_map.get(r.custom_id)
|
key = id_map.get(r.custom_id)
|
||||||
if key is None:
|
if key is None:
|
||||||
continue
|
continue
|
||||||
|
|
@ -1066,26 +1191,61 @@ def fetchTranslationBatches():
|
||||||
out = getattr(u, "output_tokens", 0) or 0
|
out = getattr(u, "output_tokens", 0) or 0
|
||||||
results[key] = {
|
results[key] = {
|
||||||
"text": text,
|
"text": text,
|
||||||
# prompt_tokens matches _AnthropicCompat: total incl. cache fields.
|
|
||||||
"prompt_tokens": inp + cr + cw,
|
"prompt_tokens": inp + cr + cw,
|
||||||
"completion_tokens": out,
|
"completion_tokens": out,
|
||||||
"cache_read_input_tokens": cr,
|
"cache_read_input_tokens": cr,
|
||||||
"cache_creation_input_tokens": cw,
|
"cache_creation_input_tokens": cw,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model = state.get("model") or os.getenv("model", "")
|
||||||
|
actual_cost = None
|
||||||
|
if _price_usage is not None and model:
|
||||||
|
try:
|
||||||
|
actual_cost = _price_usage(usage_totals, model)
|
||||||
|
except Exception:
|
||||||
|
actual_cost = None
|
||||||
|
|
||||||
with BATCH_LOCK:
|
with BATCH_LOCK:
|
||||||
with _batch_file_lock():
|
with _batch_file_lock():
|
||||||
merged = _read_batch_file(BATCH_RESULTS_FILE)
|
merged = _read_batch_file(BATCH_RESULTS_FILE)
|
||||||
merged.update(results)
|
merged.update(results)
|
||||||
_write_batch_file(BATCH_RESULTS_FILE, merged)
|
_write_batch_file(BATCH_RESULTS_FILE, merged)
|
||||||
# Queue and state are consumed; only the results store remains.
|
# Drop the queue; keep a lightweight fetched marker (ids for consume→history).
|
||||||
for path in (BATCH_QUEUE_FILE, BATCH_STATE_FILE):
|
# custom_ids stay in durable history - do not destroy recovery maps.
|
||||||
try:
|
try:
|
||||||
if path.exists():
|
if BATCH_QUEUE_FILE.exists():
|
||||||
path.unlink()
|
BATCH_QUEUE_FILE.unlink()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
_write_batch_file(
|
||||||
|
BATCH_STATE_FILE,
|
||||||
|
{
|
||||||
|
"status": "fetched",
|
||||||
|
"batch_ids": batch_ids,
|
||||||
|
"batches": [],
|
||||||
|
"model": model,
|
||||||
|
"file_set": state.get("file_set") or [],
|
||||||
|
"cost_estimate": state.get("cost_estimate"),
|
||||||
|
"fetched_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
},
|
||||||
|
)
|
||||||
_batch_results = None
|
_batch_results = None
|
||||||
|
|
||||||
|
if record_fetch is not None:
|
||||||
|
try:
|
||||||
|
record_fetch(
|
||||||
|
batch_ids,
|
||||||
|
succeeded=len(results),
|
||||||
|
errored=len(errored),
|
||||||
|
usage=usage_totals,
|
||||||
|
actual_cost=actual_cost,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[BATCH] history record_fetch failed: {exc}", flush=True)
|
||||||
|
|
||||||
print(f"[BATCH] fetched {len(results)} results ({len(errored)} errored).", flush=True)
|
print(f"[BATCH] fetched {len(results)} results ({len(errored)} errored).", flush=True)
|
||||||
|
if actual_cost is not None:
|
||||||
|
print(f"[BATCH] actual batch usage cost (est.): ${actual_cost:.4f}", flush=True)
|
||||||
for cid, why in errored[:20]:
|
for cid, why in errored[:20]:
|
||||||
print(f"[BATCH] ! {cid}: {why}", flush=True)
|
print(f"[BATCH] ! {cid}: {why}", flush=True)
|
||||||
if len(errored) > 20:
|
if len(errored) > 20:
|
||||||
|
|
@ -2626,6 +2786,29 @@ def translateAI(text, history, config, filename=None, pbar=None, lock=None, mism
|
||||||
with lock:
|
with lock:
|
||||||
pbar.update(len(tItem) if isinstance(tItem, list) else 1)
|
pbar.update(len(tItem) if isinstance(tItem, list) else 1)
|
||||||
|
|
||||||
|
# Consume pass: still record what was applied. Cache hits used to
|
||||||
|
# skip the log entirely, which left the GUI Translation Log empty
|
||||||
|
# after a resume even though translated/ was written.
|
||||||
|
if batch_phase == "consume" and not config.estimateMode:
|
||||||
|
try:
|
||||||
|
if isinstance(cached_result, list):
|
||||||
|
out_payload = {
|
||||||
|
f"Line{i+1}": string for i, string in enumerate(cached_result)
|
||||||
|
}
|
||||||
|
formatted_output = json.dumps(out_payload, indent=4, ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
formatted_output = json.dumps(
|
||||||
|
{"Line1": cached_result}, indent=4, ensure_ascii=False
|
||||||
|
)
|
||||||
|
Path(config.logFilePath).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(config.logFilePath, "a", encoding="utf-8") as logFile:
|
||||||
|
logFile.write("[CACHE] Applied cached translation (no new API call)\n")
|
||||||
|
logFile.write(f"Input:\n{subbedT}\n")
|
||||||
|
logFile.write(f"Output:\n{formatted_output}\n")
|
||||||
|
logFile.flush()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Create context — static_system is the stable prompt.txt content;
|
# Create context — static_system is the stable prompt.txt content;
|
||||||
|
|
@ -2701,6 +2884,7 @@ def translateAI(text, history, config, filename=None, pbar=None, lock=None, mism
|
||||||
max_retries = 2 # 1 initial attempt + 2 retries
|
max_retries = 2 # 1 initial attempt + 2 retries
|
||||||
final_translations = None
|
final_translations = None
|
||||||
last_raw_translation = ""
|
last_raw_translation = ""
|
||||||
|
from_batch = False
|
||||||
numLines = len(clean_tItem) if isinstance(tItem, list) else 1
|
numLines = len(clean_tItem) if isinstance(tItem, list) else 1
|
||||||
|
|
||||||
for attempt in range(max_retries + 1):
|
for attempt in range(max_retries + 1):
|
||||||
|
|
@ -2726,7 +2910,7 @@ def translateAI(text, history, config, filename=None, pbar=None, lock=None, mism
|
||||||
if pbar:
|
if pbar:
|
||||||
pbar.write(f"Retrying translation... (Attempt {attempt + 1}/{max_retries + 1})")
|
pbar.write(f"Retrying translation... (Attempt {attempt + 1}/{max_retries + 1})")
|
||||||
|
|
||||||
# Translate — the consume pass tries the fetched batch result first;
|
# Translate - the consume pass tries the fetched batch result first;
|
||||||
# a missing or invalid result falls through to the live API.
|
# a missing or invalid result falls through to the live API.
|
||||||
from_batch = False
|
from_batch = False
|
||||||
if batch_phase == "consume" and attempt == 0:
|
if batch_phase == "consume" and attempt == 0:
|
||||||
|
|
@ -2944,6 +3128,8 @@ def translateAI(text, history, config, filename=None, pbar=None, lock=None, mism
|
||||||
# Only open and write to log file when we have something to log
|
# Only open and write to log file when we have something to log
|
||||||
try:
|
try:
|
||||||
with open(config.logFilePath, "a", encoding="utf-8") as logFile:
|
with open(config.logFilePath, "a", encoding="utf-8") as logFile:
|
||||||
|
if from_batch:
|
||||||
|
logFile.write("[BATCH] Applied Anthropic batch result\n")
|
||||||
logFile.write(f"Input:\n{subbedT}\n")
|
logFile.write(f"Input:\n{subbedT}\n")
|
||||||
logFile.write(f"Output:\n{formatted_output}\n")
|
logFile.write(f"Output:\n{formatted_output}\n")
|
||||||
logFile.flush() # Ensure data is written to disk immediately
|
logFile.flush() # Ensure data is written to disk immediately
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue