mirror of
https://github.com/guillaumemeyer/watermarks-remover.git
synced 2026-08-22 13:11:57 +02:00
feat: vendor text-watermark detection (Gemini SynthID, Claude seam, MarkLLM) + SynthID image scorer sidecar (#109)
* feat: add vendor text-watermark detection and SynthID image scorer sidecar Adds Layer B watermark detection as a first-class service capability: - text_detectors.py: a registry of text-watermark detectors behind one interface — Google's official SynthID-text detector via the Gemini API (taskType DETECT_TEXT_WATERMARK), a Claude placeholder ready for Anthropic's announced detection API, and the MarkLLM research harness (KGW / SynthID, same-config-only). Fail-soft: unconfigured or errored detectors never block cleaning. - server.py: new POST /detect endpoint, detect_before / detect_after options on /clean (before/after scoring for text and images), an opt-in /inspect "detect" flag, and /capabilities gains text_detectors and scorers.synthid_http. - synthid_score_server.py: a stdlib HTTP sidecar for the reverse-SynthID scorer, so the published core image never bundles the non-commercial upstream code; wired via WATERMARKS_SYNTHID_SCORER_URL. - score_synthid.py: extract score_file() so the CLI and the sidecar share one implementation. - compose.yaml / Dockerfile.synthid / .env.example: wr-synthid-score sidecar service and env wiring. - README + skill docs, plus tests for the detectors, the /detect endpoint, and the image sidecar. * feat: per-candidate watermark detection for Layer B rewrite candidates When --candidates N (N > 1) is combined with --markllm-scheme or WATERMARKS_GEMINI_API_KEY, run every configured text detector from the text_detectors.py registry on each candidate and report per-candidate measurements in --json-stats as candidate_scores entries carrying lexical_divergence, selection_score, selected, and per-detector reports (is_watermarked, score, threshold where the detector provides one). Candidate selection stays purely lexical; the detections are observability for correlating lexical divergence with watermark removal (issue #106). Converges rewrite_text.py onto the shared detector registry: - MarkLLMTextDetector gains constructor overrides (scheme, upstream_dir, model, timeout) plus the checkout-venv interpreter preference and the WATERMARKS_MARKLLM_RLIMIT_AS preexec guard ported from rewrite_text.py; the old _markllm_detect / _venv_python / _markllm_preexec helpers are gone. - run_all_text_detectors() accepts an injected MarkLLM instance and an include_markllm switch so CLI flag gating stays intact. - before/after/cleared semantics unchanged; detection remains fail-soft. * docs: pin Watermarks in the Sand reference to arXiv v5 * fix: mark only one rewrite candidate as selected (#110) --------- Co-authored-by: Zhenxin Ai <142008897+ai-kunkun@users.noreply.github.com>
This commit is contained in:
co-authored by
Zhenxin Ai
parent
6df80e77a4
commit
a2e72ed019
@@ -45,6 +45,7 @@ RUN git clone --depth 1 --filter=blob:none --sparse \
|
||||
|
||||
COPY scripts/requirements-synthid-scorer.txt /app/requirements-synthid-scorer.txt
|
||||
COPY scripts/score_synthid.py /app/score_synthid.py
|
||||
COPY scripts/synthid_score_server.py /app/synthid_score_server.py
|
||||
|
||||
RUN python3 -m pip install --no-cache-dir "pip==26.2.1" \
|
||||
&& python3 -m pip install --no-cache-dir -r /app/requirements-synthid-scorer.txt
|
||||
|
||||
+57
-109
@@ -27,17 +27,16 @@ import itertools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from common import cleaned_path, eprint, read_text_input, write_text_output
|
||||
from text_detectors import MarkLLMTextDetector, run_all_text_detectors
|
||||
from text_unicode import clean_text
|
||||
|
||||
DEFAULT_MARKLLM_MODEL = "facebook/opt-1.3b"
|
||||
@@ -174,100 +173,31 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
raise urllib.error.HTTPError(req.full_url, code, msg, headers, fp)
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
def _per_candidate_detections(
|
||||
candidates: list[str],
|
||||
markllm_detector: MarkLLMTextDetector | None,
|
||||
) -> list[list[dict]]:
|
||||
"""Run every configured text detector on each rewrite candidate.
|
||||
|
||||
|
||||
def _venv_python(upstream: Path) -> Path | None:
|
||||
"""Locate the MarkLLM checkout's venv interpreter, if it exists."""
|
||||
if os.name == "nt":
|
||||
candidate = upstream / ".venv" / "Scripts" / "python.exe"
|
||||
else:
|
||||
candidate = upstream / ".venv" / "bin" / "python"
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
|
||||
def _markllm_preexec() -> Callable[[], None] | None:
|
||||
"""Optional RLIMIT_AS guard for the MarkLLM child; None means "no limit".
|
||||
|
||||
torch/CUDA usually needs large address spaces, so unlike the
|
||||
exiftool/c2patool/SynthID children (common.subprocess_rlimits) this is
|
||||
opt-in via WATERMARKS_MARKLLM_RLIMIT_AS (byte count, hex/octal allowed).
|
||||
POSIX only; on Windows preexec_fn must stay None.
|
||||
Fail-soft: a detector that is unconfigured, times out, or errors yields
|
||||
an ``available: False`` entry and never fails the rewrite. The MarkLLM
|
||||
harness is only included when ``markllm_detector`` is given (i.e. the
|
||||
caller passed --markllm-scheme); other detectors (e.g.
|
||||
gemini-synthid-text) are key-gated by their own environment.
|
||||
"""
|
||||
raw = os.environ.get("WATERMARKS_MARKLLM_RLIMIT_AS")
|
||||
if not raw or os.name != "posix":
|
||||
return None
|
||||
try:
|
||||
limit = int(raw, 0)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _apply() -> None:
|
||||
import resource
|
||||
|
||||
resource.setrlimit(resource.RLIMIT_AS, (limit, limit))
|
||||
|
||||
return _apply
|
||||
|
||||
|
||||
def _markllm_detect(
|
||||
text: str,
|
||||
*,
|
||||
scheme: str,
|
||||
upstream_dir: str,
|
||||
model: str,
|
||||
timeout: float,
|
||||
) -> dict:
|
||||
"""Run the MarkLLM adapter on *text*; never fails the rewrite.
|
||||
|
||||
Returns the adapter's JSON payload, or an ``available: False`` dict with
|
||||
an ``error`` string when the backend is unconfigured or broken. The Layer B
|
||||
rewrite proceeds regardless; MarkLLM verification is best-effort.
|
||||
"""
|
||||
if not upstream_dir:
|
||||
return {"available": False, "error": "no MARKLLM_DIR set"}
|
||||
upstream = Path(upstream_dir).expanduser().resolve()
|
||||
if not upstream.is_dir() or not (upstream / "watermark").is_dir():
|
||||
return {"available": False, "error": f"MarkLLM checkout missing: {upstream}"}
|
||||
venv_python = _venv_python(upstream)
|
||||
if venv_python is None:
|
||||
return {"available": False, "error": f"MarkLLM venv missing: {upstream}"}
|
||||
|
||||
cmd = [
|
||||
str(venv_python),
|
||||
str(SCRIPTS_DIR / "detect_text_watermark.py"),
|
||||
"detect",
|
||||
"-",
|
||||
"--scheme",
|
||||
scheme,
|
||||
"--upstream-dir",
|
||||
str(upstream),
|
||||
"--model",
|
||||
model,
|
||||
"--json",
|
||||
]
|
||||
try:
|
||||
r = subprocess.run(
|
||||
cmd,
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
preexec_fn=_markllm_preexec(),
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError, TimeoutError) as e:
|
||||
return {"available": False, "error": f"MarkLLM adapter error: {e}"}
|
||||
|
||||
if r.returncode != 0:
|
||||
return {
|
||||
"available": False,
|
||||
"error": (r.stderr or "").strip() or f"adapter exited {r.returncode}",
|
||||
}
|
||||
try:
|
||||
return json.loads(r.stdout)
|
||||
except ValueError as e:
|
||||
return {"available": False, "error": f"adapter JSON parse error: {e}"}
|
||||
detections: list[list[dict]] = []
|
||||
for cand in candidates:
|
||||
try:
|
||||
detections.append(
|
||||
run_all_text_detectors(
|
||||
cand,
|
||||
markllm=markllm_detector,
|
||||
include_markllm=markllm_detector is not None,
|
||||
)
|
||||
)
|
||||
except Exception as e: # defensive: the registry contract is fail-soft
|
||||
detections.append([{"available": False, "error": f"candidate detection failed: {e}"}])
|
||||
return detections
|
||||
|
||||
|
||||
def build_prompt(strength: str, text: str, *, lang: str, original_lang: str) -> str:
|
||||
@@ -400,16 +330,17 @@ def rewrite(
|
||||
info["reasoning_effort"] = reasoning_effort
|
||||
|
||||
markllm: dict | None = None
|
||||
markllm_detector: MarkLLMTextDetector | None = None
|
||||
if markllm_scheme:
|
||||
markllm_detector = MarkLLMTextDetector(
|
||||
scheme=markllm_scheme,
|
||||
upstream_dir=markllm_dir,
|
||||
model=markllm_model or DEFAULT_MARKLLM_MODEL,
|
||||
timeout=markllm_timeout,
|
||||
)
|
||||
markllm = {
|
||||
"scheme": markllm_scheme,
|
||||
"before": _markllm_detect(
|
||||
text,
|
||||
scheme=markllm_scheme,
|
||||
upstream_dir=markllm_dir or "",
|
||||
model=markllm_model or DEFAULT_MARKLLM_MODEL,
|
||||
timeout=markllm_timeout,
|
||||
),
|
||||
"before": markllm_detector.detect(text),
|
||||
}
|
||||
if not markllm["before"]["available"]:
|
||||
eprint(f"markllm verification unavailable: {markllm['before']['error']}")
|
||||
@@ -447,7 +378,29 @@ def rewrite(
|
||||
else:
|
||||
info["candidates"] = n
|
||||
out, scores = _select_candidate(text, outs)
|
||||
info["candidate_scores"] = scores
|
||||
selected_idx = max(range(len(outs)), key=lambda i: scores[i])
|
||||
trigger = markllm_scheme is not None or bool(
|
||||
os.environ.get("WATERMARKS_GEMINI_API_KEY", "").strip()
|
||||
)
|
||||
detections = _per_candidate_detections(outs, markllm_detector) if trigger else []
|
||||
info["candidate_scores"] = []
|
||||
for i, cand in enumerate(outs):
|
||||
info["candidate_scores"].append(
|
||||
{
|
||||
"lexical_divergence": _lexical_divergence(text, cand),
|
||||
"selection_score": scores[i],
|
||||
"selected": i == selected_idx,
|
||||
"detections": detections[i] if trigger else [],
|
||||
}
|
||||
)
|
||||
if trigger and detections:
|
||||
names = sorted(
|
||||
{d.get("detector", "?") for dets in detections for d in dets if d.get("available")}
|
||||
)
|
||||
eprint(
|
||||
f"note: running per-candidate watermark detection on {n} candidates"
|
||||
+ (f" ({', '.join(names)})" if names else "")
|
||||
)
|
||||
|
||||
if layer_a_after:
|
||||
out, stats = clean_text(out)
|
||||
@@ -461,13 +414,8 @@ def rewrite(
|
||||
)
|
||||
|
||||
if markllm:
|
||||
after = _markllm_detect(
|
||||
out,
|
||||
scheme=markllm["scheme"],
|
||||
upstream_dir=markllm_dir or "",
|
||||
model=markllm_model or DEFAULT_MARKLLM_MODEL,
|
||||
timeout=markllm_timeout,
|
||||
)
|
||||
assert markllm_detector is not None # set together with markllm above
|
||||
after = markllm_detector.detect(out)
|
||||
markllm["after"] = after
|
||||
before = markllm["before"]
|
||||
if before.get("available") and after.get("available"):
|
||||
|
||||
@@ -11,6 +11,9 @@ Exit codes:
|
||||
1 scorer runtime error
|
||||
2 bad input (missing/unreadable image, bad args)
|
||||
3 scorer unavailable (not configured / missing deps / missing codebook)
|
||||
|
||||
The scoring logic lives in :func:score_file so the CLI and the HTTP
|
||||
sidecar (synthid_score_server.py) share one implementation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,6 +24,7 @@ import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def resolve_upstream(raw: str | None) -> Path | None:
|
||||
@@ -32,6 +36,90 @@ def resolve_upstream(raw: str | None) -> Path | None:
|
||||
return upstream
|
||||
|
||||
|
||||
def score_file(
|
||||
path: Path,
|
||||
*,
|
||||
upstream_dir: str | None = None,
|
||||
codebook: Path | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[int, dict[str, Any] | None]:
|
||||
"""Score *path* with the reverse-SynthID extractor.
|
||||
|
||||
Returns (exit_code, payload) matching the CLI exit-code contract:
|
||||
0 = scored (payload present), 2 = bad input (payload None),
|
||||
3 = scorer unavailable (payload None). Errors are printed to stderr so
|
||||
callers parsing stdout JSON are never corrupted.
|
||||
"""
|
||||
if not path.is_file():
|
||||
print(f"not a file: {path}", file=sys.stderr)
|
||||
return 2, None
|
||||
|
||||
raw_upstream = upstream_dir or os.environ.get("REVERSE_SYNTHID_DIR")
|
||||
upstream = resolve_upstream(str(raw_upstream) if raw_upstream else None)
|
||||
if upstream is None:
|
||||
print(
|
||||
"SynthID scorer not configured: set REVERSE_SYNTHID_DIR or pass --upstream-dir",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3, None
|
||||
|
||||
extraction = upstream / "src" / "extraction"
|
||||
if not extraction.is_dir():
|
||||
print(f"upstream extraction dir not found: {extraction}", file=sys.stderr)
|
||||
return 3, None
|
||||
|
||||
codebook_path = codebook or upstream / "artifacts" / "spectral_codebook_v4.npz"
|
||||
codebook_path = Path(codebook_path).expanduser().resolve()
|
||||
if not codebook_path.is_file():
|
||||
print(f"codebook not found: {codebook_path}", file=sys.stderr)
|
||||
return 3, None
|
||||
|
||||
sys.path.insert(0, str(extraction))
|
||||
try:
|
||||
import cv2
|
||||
from robust_extractor import RobustSynthIDExtractor
|
||||
from synthid_bypass_v4 import SpectralCodebookV4
|
||||
except ImportError as e:
|
||||
print(f"optional scorer dependencies missing: {e}", file=sys.stderr)
|
||||
return 3, None
|
||||
|
||||
try:
|
||||
img = cv2.imread(str(path))
|
||||
if img is None:
|
||||
print(f"could not load image: {path}", file=sys.stderr)
|
||||
return 2, None
|
||||
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||
|
||||
# Upstream prints progress ("CodebookV4 loaded: ...") straight to
|
||||
# stdout, which corrupts --json for any caller that parses us
|
||||
# (image_meta.py json.loads our stdout). Keep stdout ours alone.
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
codebook_v4 = SpectralCodebookV4()
|
||||
codebook_v4.load(str(codebook_path))
|
||||
|
||||
extractor = RobustSynthIDExtractor()
|
||||
result = extractor.detect_from_v4_codebook(rgb, codebook_v4, model=model)
|
||||
except Exception as e:
|
||||
print(f"scorer error: {e}", file=sys.stderr)
|
||||
return 1, None
|
||||
|
||||
payload = {
|
||||
"available": True,
|
||||
"upstream_dir": str(upstream),
|
||||
"codebook": str(codebook_path),
|
||||
"model": model,
|
||||
"profile_key": result.details.get("profile_key"),
|
||||
"exact_match": result.details.get("exact_match"),
|
||||
"is_watermarked": result.is_watermarked,
|
||||
"confidence": result.confidence,
|
||||
"phase_match": result.phase_match,
|
||||
"per_channel_scores": result.details.get("per_channel_scores"),
|
||||
"per_channel_n": result.details.get("per_channel_n"),
|
||||
"multi_scale_consistency": result.multi_scale_consistency,
|
||||
}
|
||||
return 0, payload
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("path", type=Path, help="Image to score (PNG/JPEG/etc.)")
|
||||
@@ -51,83 +139,24 @@ def main() -> int:
|
||||
p.add_argument("--json", action="store_true", help="Emit JSON on stdout")
|
||||
args = p.parse_args()
|
||||
|
||||
if not args.path.is_file():
|
||||
print(f"not a file: {args.path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
raw_upstream = args.upstream_dir or os.environ.get("REVERSE_SYNTHID_DIR")
|
||||
upstream = resolve_upstream(str(raw_upstream) if raw_upstream else None)
|
||||
if upstream is None:
|
||||
print(
|
||||
"SynthID scorer not configured: set REVERSE_SYNTHID_DIR or pass --upstream-dir",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
|
||||
extraction = upstream / "src" / "extraction"
|
||||
if not extraction.is_dir():
|
||||
print(f"upstream extraction dir not found: {extraction}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
codebook = args.codebook or upstream / "artifacts" / "spectral_codebook_v4.npz"
|
||||
codebook = Path(codebook).expanduser().resolve()
|
||||
if not codebook.is_file():
|
||||
print(f"codebook not found: {codebook}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
sys.path.insert(0, str(extraction))
|
||||
try:
|
||||
import cv2
|
||||
from robust_extractor import RobustSynthIDExtractor
|
||||
from synthid_bypass_v4 import SpectralCodebookV4
|
||||
except ImportError as e:
|
||||
print(f"optional scorer dependencies missing: {e}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
try:
|
||||
img = cv2.imread(str(args.path))
|
||||
if img is None:
|
||||
print(f"could not load image: {args.path}", file=sys.stderr)
|
||||
return 2
|
||||
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||
|
||||
# Upstream prints progress ("CodebookV4 loaded: ...") straight to
|
||||
# stdout, which corrupts --json for any caller that parses us
|
||||
# (image_meta.py json.loads our stdout). Keep stdout ours alone.
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
codebook_v4 = SpectralCodebookV4()
|
||||
codebook_v4.load(str(codebook))
|
||||
|
||||
extractor = RobustSynthIDExtractor()
|
||||
result = extractor.detect_from_v4_codebook(rgb, codebook_v4, model=args.model)
|
||||
except Exception as e:
|
||||
print(f"scorer error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
payload = {
|
||||
"available": True,
|
||||
"upstream_dir": str(upstream),
|
||||
"codebook": str(codebook),
|
||||
"model": args.model,
|
||||
"profile_key": result.details.get("profile_key"),
|
||||
"exact_match": result.details.get("exact_match"),
|
||||
"is_watermarked": result.is_watermarked,
|
||||
"confidence": result.confidence,
|
||||
"phase_match": result.phase_match,
|
||||
"per_channel_scores": result.details.get("per_channel_scores"),
|
||||
"per_channel_n": result.details.get("per_channel_n"),
|
||||
"multi_scale_consistency": result.multi_scale_consistency,
|
||||
}
|
||||
code, payload = score_file(
|
||||
args.path,
|
||||
upstream_dir=str(args.upstream_dir) if args.upstream_dir else None,
|
||||
codebook=args.codebook,
|
||||
model=args.model,
|
||||
)
|
||||
if code != 0:
|
||||
return code
|
||||
|
||||
if args.json:
|
||||
json.dump(payload, sys.stdout, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
else:
|
||||
label = "yes" if result.is_watermarked else "no"
|
||||
print(f"SynthID score: confidence {result.confidence:.3f} (watermarked: {label})")
|
||||
print(f" phase_match: {result.phase_match:.3f}")
|
||||
if result.details.get("profile_key"):
|
||||
print(f" profile: {result.details['profile_key']}")
|
||||
label = "yes" if payload["is_watermarked"] else "no"
|
||||
print(f"SynthID score: confidence {payload['confidence']:.3f} (watermarked: {label})")
|
||||
print(f" phase_match: {payload['phase_match']:.3f}")
|
||||
if payload.get("profile_key"):
|
||||
print(f" profile: {payload['profile_key']}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
+119
-5
@@ -9,6 +9,7 @@ Endpoints:
|
||||
GET /capabilities -> which optional tools / pixel backends are present
|
||||
GET /openapi.json -> dynamically generated OpenAPI 3.0.3 spec
|
||||
POST /inspect -> {"file": <base64>, "name": "x.png"} -> findings JSON
|
||||
POST /detect -> {"file": <base64>, "name": "x.txt"} -> watermark detector reports
|
||||
POST /clean -> {"file": <base64>, "name": "x.png", "options": {...}}
|
||||
-> {"cleaned": <base64>, "report": {...}}
|
||||
|
||||
@@ -43,8 +44,9 @@ from common import (
|
||||
)
|
||||
from container_meta import clean_container, inspect_container
|
||||
from format_dispatch import classify_bytes
|
||||
from image_meta import clean_image, inspect_image
|
||||
from image_meta import clean_image, inspect_image, run_synthid_score
|
||||
from score_stylometry import score_text_stylometry
|
||||
from text_detectors import detector_status, run_all_text_detectors, run_text_detectors
|
||||
from text_unicode import clean_text, inspect_text
|
||||
|
||||
VERSION = os.environ.get("WATERMARKS_SERVER_VERSION", "dev")
|
||||
@@ -64,6 +66,8 @@ ALLOWED_CLEAN_OPTIONS = {
|
||||
"also_layer_a_text": bool,
|
||||
"remove_pixel": str,
|
||||
"strip_all_metadata": bool,
|
||||
"detect_before": bool,
|
||||
"detect_after": bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -85,8 +89,10 @@ def capabilities() -> dict[str, Any]:
|
||||
},
|
||||
"scorers": {
|
||||
"synthid": bool(os.environ.get("REVERSE_SYNTHID_DIR")),
|
||||
"synthid_http": bool(os.environ.get("WATERMARKS_SYNTHID_SCORER_URL")),
|
||||
"stylometry": True,
|
||||
},
|
||||
"text_detectors": detector_status(),
|
||||
"harnesses": {
|
||||
"markllm": bool(os.environ.get("MARKLLM_DIR")),
|
||||
},
|
||||
@@ -178,12 +184,17 @@ _OPENAPI_PATHS: dict[str, dict[str, Any]] = {
|
||||
type="object",
|
||||
properties={
|
||||
"synthid": _schema(type="boolean"),
|
||||
"synthid_http": _schema(type="boolean"),
|
||||
"stylometry": _schema(type="boolean"),
|
||||
},
|
||||
),
|
||||
"harnesses": _schema(
|
||||
type="object", properties={"markllm": _schema(type="boolean")}
|
||||
),
|
||||
"text_detectors": _schema(
|
||||
type="object",
|
||||
additionalProperties=_schema(type="boolean"),
|
||||
),
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -202,7 +213,25 @@ _OPENAPI_PATHS: dict[str, dict[str, Any]] = {
|
||||
"summary": "Inspect a file for AI provenance marks (text / image / container auto-routed)",
|
||||
"requestBody": _schema(
|
||||
required=True,
|
||||
content={"application/json": _schema(schema=_file_request())},
|
||||
content={
|
||||
"application/json": _schema(
|
||||
schema=_file_request(
|
||||
{
|
||||
"properties": {
|
||||
"detect": _schema(
|
||||
type="boolean",
|
||||
description=(
|
||||
"Also run configured text watermark detectors "
|
||||
"(opt-in; may call vendor APIs and send text "
|
||||
"to them)"
|
||||
),
|
||||
)
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
),
|
||||
"responses": {
|
||||
"200": _schema(
|
||||
@@ -239,6 +268,25 @@ _OPENAPI_PATHS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
}
|
||||
},
|
||||
"/detect": {
|
||||
"post": {
|
||||
"summary": "Run watermark detectors on a file (text: vendor/statistical; image: SynthID score)",
|
||||
"requestBody": _schema(
|
||||
required=True,
|
||||
content={"application/json": _schema(schema=_file_request())},
|
||||
),
|
||||
"responses": {
|
||||
"200": _schema(
|
||||
type="object",
|
||||
properties={
|
||||
"ok": _schema(type="boolean"),
|
||||
"kind": _schema(type="string", enum=["text", "image", "container"]),
|
||||
"detections": _schema(type="array", items=_schema(type="object")),
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_ERROR_SCHEMA = _schema(
|
||||
@@ -398,7 +446,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not self._authorized():
|
||||
self._respond(HTTPStatus.UNAUTHORIZED, {"ok": False, "error": "unauthorized"})
|
||||
return
|
||||
if path not in ("/inspect", "/clean"):
|
||||
if path not in ("/inspect", "/clean", "/detect"):
|
||||
self._respond(HTTPStatus.NOT_FOUND, {"ok": False, "error": "not found"})
|
||||
return
|
||||
body = self._read_json()
|
||||
@@ -417,7 +465,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
try:
|
||||
if path == "/inspect":
|
||||
self._handle_inspect(data, name)
|
||||
self._handle_inspect(data, name, body)
|
||||
elif path == "/detect":
|
||||
self._handle_detect(data, name)
|
||||
else:
|
||||
self._handle_clean(data, name, body)
|
||||
except ValueError as e:
|
||||
@@ -428,7 +478,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": "internal error"}
|
||||
)
|
||||
|
||||
def _handle_inspect(self, data: bytes, name: str) -> None:
|
||||
def _handle_inspect(self, data: bytes, name: str, body: dict[str, Any]) -> None:
|
||||
kind = classify_bytes(data, Path(name).suffix)
|
||||
if kind == "unknown":
|
||||
self._respond(
|
||||
@@ -443,6 +493,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
},
|
||||
)
|
||||
return
|
||||
run_detect = body.get("detect") is True
|
||||
with tempfile.TemporaryDirectory(prefix="wm-inspect-") as tmp:
|
||||
path = _tmp_path(Path(tmp), name or "input")
|
||||
path.write_bytes(data)
|
||||
@@ -455,19 +506,69 @@ class Handler(BaseHTTPRequestHandler):
|
||||
report = inspect_text(raw_text).to_dict()
|
||||
s_rep = score_text_stylometry(raw_text, path=name or "<text>")
|
||||
report["stylometry"] = s_rep.to_dict()
|
||||
if run_detect:
|
||||
report["text_detectors"] = run_all_text_detectors(raw_text)
|
||||
elif kind == "image":
|
||||
report = inspect_image(path).to_dict()
|
||||
else:
|
||||
report = inspect_container(path).to_dict()
|
||||
detected_wm = any(
|
||||
entry.get("available") and entry.get("is_watermarked")
|
||||
for entry in report.get("text_detectors") or []
|
||||
)
|
||||
suspicious = (
|
||||
bool(report.get("suspicious_total"))
|
||||
or bool(report.get("has_c2pa") or report.get("has_ai_metadata"))
|
||||
or bool(report.get("stylometry", {}).get("score", 0.0) >= 0.65)
|
||||
or detected_wm
|
||||
)
|
||||
self._respond(
|
||||
HTTPStatus.OK, {"ok": True, "kind": kind, "report": report, "suspicious": suspicious}
|
||||
)
|
||||
|
||||
def _handle_detect(self, data: bytes, name: str) -> None:
|
||||
kind = classify_bytes(data, Path(name).suffix)
|
||||
with tempfile.TemporaryDirectory(prefix="wm-detect-") as tmp:
|
||||
path = _tmp_path(Path(tmp), name or "input")
|
||||
path.write_bytes(data)
|
||||
if kind == "text":
|
||||
if looks_binary(data):
|
||||
raise ValueError(
|
||||
"refusing to detect bytes that look like a binary container as text"
|
||||
)
|
||||
raw_text = data.decode("utf-8", errors="surrogateescape")
|
||||
detections: list[dict[str, Any]] = run_all_text_detectors(raw_text)
|
||||
s_rep = score_text_stylometry(raw_text, path=name or "<text>")
|
||||
detections.append({"detector": "stylometry", "available": True, **s_rep.to_dict()})
|
||||
elif kind == "image":
|
||||
score = run_synthid_score(path)
|
||||
if score is None:
|
||||
score = {
|
||||
"detector": "synthid",
|
||||
"available": False,
|
||||
"error": (
|
||||
"no SynthID scorer configured (set "
|
||||
"WATERMARKS_SYNTHID_SCORER_URL or REVERSE_SYNTHID_DIR)"
|
||||
),
|
||||
}
|
||||
else:
|
||||
score.setdefault("detector", "synthid")
|
||||
detections = [score]
|
||||
else:
|
||||
detections = []
|
||||
report = inspect_container(path).to_dict()
|
||||
self._respond(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"ok": True,
|
||||
"kind": kind,
|
||||
"detections": detections,
|
||||
"report": report,
|
||||
},
|
||||
)
|
||||
return
|
||||
self._respond(HTTPStatus.OK, {"ok": True, "kind": kind, "detections": detections})
|
||||
|
||||
def _handle_clean(self, data: bytes, name: str, body: dict[str, Any]) -> None:
|
||||
kind = classify_bytes(data, Path(name).suffix)
|
||||
if kind == "unknown":
|
||||
@@ -498,13 +599,22 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"refusing to clean bytes that look like a binary container as text"
|
||||
)
|
||||
text = data.decode("utf-8", errors="surrogateescape")
|
||||
detect_before = bool(options.get("detect_before"))
|
||||
detect_after = bool(options.get("detect_after"))
|
||||
detector_reports: dict[str, Any] = {}
|
||||
if detect_before:
|
||||
detector_reports["before"] = run_text_detectors(text)
|
||||
cleaned, stats = clean_text(
|
||||
text,
|
||||
nfkc=bool(options.get("nfkc")),
|
||||
aggressive_homoglyphs=bool(options.get("aggressive_homoglyphs")),
|
||||
)
|
||||
if detect_after:
|
||||
detector_reports["after"] = run_text_detectors(cleaned)
|
||||
cleaned_bytes = cleaned.encode("utf-8", errors="surrogateescape")
|
||||
report: dict[str, Any] = {"kind": "text", "stats": stats, "length": len(cleaned)}
|
||||
if detector_reports:
|
||||
report["text_detectors"] = detector_reports
|
||||
elif kind == "image":
|
||||
dest = tmpdir / "out.png"
|
||||
strip_all = not bool(options.get("keep_non_ai_metadata"))
|
||||
@@ -519,6 +629,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
strip_all_metadata=strip_all,
|
||||
remove_pixel=remove_pixel,
|
||||
)
|
||||
if bool(options.get("detect_before")) and result.get("synthid_before") is None:
|
||||
result["synthid_before"] = run_synthid_score(src)
|
||||
if bool(options.get("detect_after")) and result.get("synthid_after") is None:
|
||||
result["synthid_after"] = run_synthid_score(dest)
|
||||
cleaned_bytes = dest.read_bytes()
|
||||
report = {"kind": "image", **result}
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tiny stdlib HTTP sidecar exposing the reverse-SynthID pixel scorer.
|
||||
|
||||
Runs inside the local-only wr-synthid heavy image so the published core
|
||||
image never bundles the non-commercial reverse-SynthID code. The core
|
||||
service calls this sidecar for SynthID image scoring when
|
||||
WATERMARKS_SYNTHID_SCORER_URL is set (see compose.yaml / .env.example).
|
||||
|
||||
Endpoints:
|
||||
GET /health -> {"ok": true, "version": ...}
|
||||
POST /score -> {"file": <base64>} -> score_synthid payload
|
||||
|
||||
Hardening mirrors server.py: optional bearer key, input size caps,
|
||||
unprivileged user, read-only rootfs with a /tmp tmpfs. Intended for the
|
||||
compose network or a trusted network only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from score_synthid import score_file
|
||||
|
||||
VERSION = os.environ.get("WATERMARKS_SYNTHID_SERVER_VERSION", "dev")
|
||||
|
||||
# Mirror common.MAX_INPUT_BYTES (env-overridable) with the base64 envelope
|
||||
# headroom. Read at import; the sidecar image does not copy common.py, so the
|
||||
# default is repeated here.
|
||||
MAX_INPUT_BYTES = int(os.environ.get("WATERMARKS_MAX_INPUT_BYTES", str(256 << 20)))
|
||||
MAX_BODY_BYTES = MAX_INPUT_BYTES + (MAX_INPUT_BYTES >> 1)
|
||||
|
||||
API_KEY = os.environ.get("WATERMARKS_SYNTHID_SCORER_API_KEY", "").strip()
|
||||
MODEL = os.environ.get("WATERMARKS_SYNTHID_MODEL", "").strip() or None
|
||||
|
||||
|
||||
def _json_ok(payload: dict[str, Any]) -> bytes:
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = f"watermarks-remover-synthid/{VERSION}"
|
||||
|
||||
def log_message(self, fmt: str, *args: object) -> None:
|
||||
print(f"{self.address_string()} - {fmt % args}", file=sys.stderr)
|
||||
|
||||
def _authorized(self) -> bool:
|
||||
if not API_KEY:
|
||||
return True
|
||||
return self.headers.get("Authorization", "") == f"Bearer {API_KEY}"
|
||||
|
||||
def _read_json(self) -> dict[str, Any] | None:
|
||||
raw = self.headers.get("Content-Length")
|
||||
if raw is None or not raw.isdigit():
|
||||
return None
|
||||
length = int(raw)
|
||||
if length > MAX_BODY_BYTES:
|
||||
return None
|
||||
try:
|
||||
body = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, OSError):
|
||||
return None
|
||||
return body if isinstance(body, dict) else None
|
||||
|
||||
def _respond(self, status: int, payload: dict[str, Any]) -> None:
|
||||
data = _json_ok(payload)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if not self._authorized():
|
||||
self._respond(HTTPStatus.UNAUTHORIZED, {"ok": False, "error": "unauthorized"})
|
||||
return
|
||||
if urlparse(self.path).path == "/health":
|
||||
self._respond(HTTPStatus.OK, {"ok": True, "version": VERSION})
|
||||
else:
|
||||
self._respond(HTTPStatus.NOT_FOUND, {"ok": False, "error": "not found"})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if not self._authorized():
|
||||
self._respond(HTTPStatus.UNAUTHORIZED, {"ok": False, "error": "unauthorized"})
|
||||
return
|
||||
if urlparse(self.path).path != "/score":
|
||||
self._respond(HTTPStatus.NOT_FOUND, {"ok": False, "error": "not found"})
|
||||
return
|
||||
body = self._read_json()
|
||||
if body is None:
|
||||
raw_len = self.headers.get("Content-Length")
|
||||
oversized = raw_len is not None and raw_len.isdigit() and int(raw_len) > MAX_BODY_BYTES
|
||||
self._respond(
|
||||
HTTPStatus.REQUEST_ENTITY_TOO_LARGE if oversized else HTTPStatus.BAD_REQUEST,
|
||||
{"ok": False, "error": "invalid request body"},
|
||||
)
|
||||
return
|
||||
|
||||
raw = body.get("file")
|
||||
if not isinstance(raw, str):
|
||||
self._respond(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "missing 'file' field"})
|
||||
return
|
||||
try:
|
||||
data = base64.b64decode(raw, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
self._respond(
|
||||
HTTPStatus.BAD_REQUEST, {"ok": False, "error": "'file' is not valid base64"}
|
||||
)
|
||||
return
|
||||
if len(data) > MAX_INPUT_BYTES:
|
||||
self._respond(
|
||||
HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"ok": False, "error": "file too large"}
|
||||
)
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="wm-synthid-") as tmp:
|
||||
path = Path(tmp) / "input.png"
|
||||
try:
|
||||
path.write_bytes(data)
|
||||
except OSError as e:
|
||||
self._respond(HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "error": str(e)})
|
||||
return
|
||||
code, payload = score_file(path, model=MODEL)
|
||||
|
||||
if code == 0 and payload is not None:
|
||||
self._respond(HTTPStatus.OK, payload)
|
||||
elif code == 2:
|
||||
self._respond(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "could not load image"})
|
||||
else:
|
||||
# exit 1 (runtime error) or 3 (unavailable) -> fail-soft payload,
|
||||
# matching the shape image_meta.run_synthid_score expects.
|
||||
self._respond(
|
||||
HTTPStatus.OK,
|
||||
{"available": False, "error": "scorer unavailable (see sidecar stderr)"},
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
global API_KEY # noqa: PLW0603 — CLI overrides env
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--host", default=os.environ.get("WATERMARKS_SYNTHID_SERVER_HOST", "127.0.0.1"))
|
||||
p.add_argument(
|
||||
"--port", type=int, default=int(os.environ.get("WATERMARKS_SYNTHID_SERVER_PORT", "8766"))
|
||||
)
|
||||
p.add_argument("--api-key", default=API_KEY, help="require this bearer token (default: none)")
|
||||
args = p.parse_args()
|
||||
|
||||
if args.host not in ("127.0.0.1", "localhost", "::1"):
|
||||
print(
|
||||
f"warning: binding {args.host} — intended for a trusted network only", file=sys.stderr
|
||||
)
|
||||
API_KEY = args.api_key
|
||||
print(f"synthid scorer sidecar {VERSION} on http://{args.host}:{args.port}", file=sys.stderr)
|
||||
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
server.shutdown()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,487 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Vendor and research text-watermark detectors behind one interface.
|
||||
|
||||
Detects statistical (Layer B) text watermarks using vendor-provided or
|
||||
research detectors. Every detector implements the same small protocol:
|
||||
|
||||
name: str stable identifier (surfaced in /capabilities)
|
||||
available() -> bool configured and usable right now
|
||||
detect(text) -> dict JSON-safe report; never raises
|
||||
|
||||
Reports follow the fail-soft contract: a detector that is unconfigured,
|
||||
times out, or errors returns {"available": False, "error": ...} and can
|
||||
never block cleaning.
|
||||
|
||||
Detectors:
|
||||
|
||||
- gemini-synthid-text — Google's official SynthID-text detector, called
|
||||
through the Gemini API (taskType DETECT_TEXT_WATERMARK). Activated by
|
||||
WATERMARKS_GEMINI_API_KEY. User text is sent to Google only when the
|
||||
operator sets that key.
|
||||
- markllm — optional research harness (KGW / SynthID schemes) via
|
||||
detect_text_watermark.py, activated by MARKLLM_DIR. Same-config-only
|
||||
detection; not a vendor oracle.
|
||||
- claude-text — placeholder for Anthropic's announced text-watermark
|
||||
detection API. Reports unavailable until a public endpoint exists; the
|
||||
interface it must implement is already defined here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
from urllib.parse import urlparse
|
||||
|
||||
GEMINI_DETECT_URL = (
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
|
||||
)
|
||||
DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
|
||||
DEFAULT_GEMINI_TIMEOUT = 30.0
|
||||
DEFAULT_GEMINI_MAX_CHARS = 1_000_000
|
||||
DEFAULT_MARKLLM_SCHEME = "kgw"
|
||||
DEFAULT_MARKLLM_TIMEOUT = 600.0
|
||||
|
||||
|
||||
class DetectorError(RuntimeError):
|
||||
"""A detector call failed (network, HTTP error, timeout)."""
|
||||
|
||||
|
||||
class TextDetector(Protocol):
|
||||
name: str
|
||||
|
||||
def available(self) -> bool: ...
|
||||
|
||||
def detect(self, text: str) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.environ.get(name, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(name, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gemini (Google's official SynthID-text detector)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WATERMARKED_VERDICTS = ("watermarked", "ai-generated", "ai generated", "likely ai")
|
||||
|
||||
|
||||
def _verdict_is_watermarked(verdict: str | None) -> bool | None:
|
||||
"""Map the detector model's free-text verdict to a boolean, or None."""
|
||||
if not verdict:
|
||||
return None
|
||||
low = verdict.strip().lower()
|
||||
if low.startswith(("unlikely", "no", "not")):
|
||||
return False
|
||||
return any(marker in low for marker in _WATERMARKED_VERDICTS)
|
||||
|
||||
|
||||
def _extract_numeric_score(candidate: dict[str, Any], top: dict[str, Any]) -> float | None:
|
||||
"""Pull a numeric watermark score from any of the known response shapes."""
|
||||
for container in (candidate, top):
|
||||
for key in (
|
||||
"syntheticTextScore",
|
||||
"synthetic_text_score",
|
||||
"watermarkScore",
|
||||
"watermark_score",
|
||||
"score",
|
||||
):
|
||||
value = container.get(key)
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
attribution = candidate.get("attributionMetadata") or {}
|
||||
if isinstance(attribution, dict):
|
||||
for key in ("syntheticTextScore", "synthetic_text_score", "score"):
|
||||
value = attribution.get(key)
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
st = attribution.get("syntheticText")
|
||||
if isinstance(st, dict):
|
||||
for key in ("score", "confidence"):
|
||||
value = st.get(key)
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
return None
|
||||
|
||||
|
||||
def parse_gemini_detect_response(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Parse a generateContent response from a DETECT_TEXT_WATERMARK call.
|
||||
|
||||
The endpoint can answer with either a free-text verdict
|
||||
("Likely AI-generated") or a structured score; both shapes are handled
|
||||
defensively so upstream schema changes degrade to an error report
|
||||
instead of a crash.
|
||||
"""
|
||||
candidates = data.get("candidates") or []
|
||||
candidate = candidates[0] if candidates else {}
|
||||
if not isinstance(candidate, dict):
|
||||
candidate = {}
|
||||
|
||||
if not candidate:
|
||||
feedback = data.get("promptFeedback") or {}
|
||||
block = feedback.get("blockReason")
|
||||
if block:
|
||||
raise DetectorError(f"Gemini blocked the request: {block}")
|
||||
raise DetectorError("Gemini returned no candidates")
|
||||
|
||||
verdict: str | None = None
|
||||
content = candidate.get("content") or {}
|
||||
parts = content.get("parts") or []
|
||||
if parts and isinstance(parts[0], dict):
|
||||
verdict = parts[0].get("text")
|
||||
|
||||
score = _extract_numeric_score(candidate, data)
|
||||
is_watermarked = _verdict_is_watermarked(verdict)
|
||||
if is_watermarked is None and score is not None:
|
||||
is_watermarked = score >= 0.5
|
||||
|
||||
if verdict is None and score is None:
|
||||
raise DetectorError(
|
||||
f"unexpected Gemini response (no verdict or score): {json.dumps(data)[:400]}"
|
||||
)
|
||||
|
||||
raw = {
|
||||
key: candidate[key]
|
||||
for key in ("attributionMetadata", "finishReason", "index")
|
||||
if candidate.get(key) is not None
|
||||
}
|
||||
return {
|
||||
"is_watermarked": is_watermarked,
|
||||
"score": score,
|
||||
"verdict": verdict,
|
||||
"raw": raw,
|
||||
}
|
||||
|
||||
|
||||
def _post_json(url: str, body: dict[str, Any], api_key: str, timeout: float) -> dict[str, Any]:
|
||||
"""POST *body* to *url*, retrying once on transient failures."""
|
||||
if urlparse(url).scheme not in ("http", "https"):
|
||||
raise DetectorError(f"refusing non-http(s) Gemini endpoint: {url}")
|
||||
# S310: URL scheme is restricted to http/https just above.
|
||||
req = urllib.request.Request( # noqa: S310
|
||||
url,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json", "x-goog-api-key": api_key},
|
||||
method="POST",
|
||||
)
|
||||
last_err = "Gemini API call failed"
|
||||
for attempt in range(2):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise DetectorError("non-object Gemini response")
|
||||
return payload
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = f"Gemini API HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:300]}"
|
||||
if e.code not in (429, 500, 502, 503, 504):
|
||||
raise DetectorError(last_err) from e
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||
last_err = f"Gemini API unreachable: {e}"
|
||||
if attempt == 0:
|
||||
time.sleep(1.0)
|
||||
raise DetectorError(last_err)
|
||||
|
||||
|
||||
class GeminiSynthIDTextDetector:
|
||||
"""Google's official SynthID-text detector via the Gemini API."""
|
||||
|
||||
name = "gemini-synthid-text"
|
||||
vendor = "google"
|
||||
|
||||
def available(self) -> bool:
|
||||
return bool(os.environ.get("WATERMARKS_GEMINI_API_KEY", "").strip())
|
||||
|
||||
def detect(self, text: str) -> dict[str, Any]:
|
||||
api_key = os.environ.get("WATERMARKS_GEMINI_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
return {
|
||||
"detector": self.name,
|
||||
"vendor": self.vendor,
|
||||
"available": False,
|
||||
"error": "WATERMARKS_GEMINI_API_KEY not set",
|
||||
}
|
||||
|
||||
max_chars = _env_int("WATERMARKS_GEMINI_MAX_CHARS", DEFAULT_GEMINI_MAX_CHARS)
|
||||
if len(text) > max_chars:
|
||||
return {
|
||||
"detector": self.name,
|
||||
"vendor": self.vendor,
|
||||
"available": True,
|
||||
"skipped": True,
|
||||
"reason": f"text longer than {max_chars} chars",
|
||||
"is_watermarked": None,
|
||||
}
|
||||
|
||||
model = (
|
||||
os.environ.get("WATERMARKS_GEMINI_MODEL", DEFAULT_GEMINI_MODEL) or DEFAULT_GEMINI_MODEL
|
||||
)
|
||||
timeout = _env_float("WATERMARKS_GEMINI_TIMEOUT", DEFAULT_GEMINI_TIMEOUT)
|
||||
url = GEMINI_DETECT_URL.format(model=model)
|
||||
body = {
|
||||
"contents": [{"role": "user", "parts": [{"text": text}]}],
|
||||
"generationConfig": {"taskType": "DETECT_TEXT_WATERMARK"},
|
||||
}
|
||||
report: dict[str, Any] = {
|
||||
"detector": self.name,
|
||||
"vendor": self.vendor,
|
||||
"model": model,
|
||||
"available": True,
|
||||
}
|
||||
try:
|
||||
data = _post_json(url, body, api_key, timeout)
|
||||
except DetectorError as e:
|
||||
report["available"] = False
|
||||
report["error"] = str(e)
|
||||
return report
|
||||
try:
|
||||
parsed = parse_gemini_detect_response(data)
|
||||
except DetectorError as e:
|
||||
report["available"] = False
|
||||
report["error"] = str(e)
|
||||
return report
|
||||
report.update(parsed)
|
||||
return report
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MarkLLM (open-source research harness: KGW / SynthID schemes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _venv_python(upstream: Path) -> Path | None:
|
||||
"""Prefer the MarkLLM checkout's venv interpreter, if it exists."""
|
||||
if os.name == "nt":
|
||||
candidate = upstream / ".venv" / "Scripts" / "python.exe"
|
||||
else:
|
||||
candidate = upstream / ".venv" / "bin" / "python"
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
|
||||
def _markllm_preexec() -> Callable[[], None] | None:
|
||||
"""Optional RLIMIT_AS guard for the MarkLLM child; None means "no limit".
|
||||
|
||||
torch/CUDA usually needs large address spaces, so this is opt-in via
|
||||
WATERMARKS_MARKLLM_RLIMIT_AS (byte count, hex/octal allowed). POSIX only;
|
||||
on Windows preexec_fn must stay None.
|
||||
"""
|
||||
raw = os.environ.get("WATERMARKS_MARKLLM_RLIMIT_AS")
|
||||
if not raw or os.name != "posix":
|
||||
return None
|
||||
try:
|
||||
limit = int(raw, 0)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _apply() -> None:
|
||||
import resource
|
||||
|
||||
resource.setrlimit(resource.RLIMIT_AS, (limit, limit))
|
||||
|
||||
return _apply
|
||||
|
||||
|
||||
class MarkLLMTextDetector:
|
||||
"""Same-config-only research detection via detect_text_watermark.py.
|
||||
|
||||
Constructor overrides (scheme, upstream_dir, model, timeout) take
|
||||
precedence over the environment, so callers such as rewrite_text.py can
|
||||
keep CLI flags driving the harness. When the MarkLLM checkout has a
|
||||
venv, its interpreter runs the child process; otherwise the current
|
||||
interpreter is used (the service image bundles the harness deps).
|
||||
"""
|
||||
|
||||
name = "markllm"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scheme: str | None = None,
|
||||
upstream_dir: str | None = None,
|
||||
model: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
self._scheme = scheme
|
||||
self._upstream_dir = upstream_dir
|
||||
self._model = model
|
||||
self._timeout = timeout
|
||||
|
||||
def available(self) -> bool:
|
||||
upstream = self._upstream_dir or os.environ.get("MARKLLM_DIR", "").strip()
|
||||
return bool(upstream)
|
||||
|
||||
def detect(self, text: str) -> dict[str, Any]:
|
||||
upstream = self._upstream_dir or os.environ.get("MARKLLM_DIR", "").strip()
|
||||
scheme = (
|
||||
self._scheme
|
||||
or os.environ.get("WATERMARKS_MARKLLM_SCHEME", "")
|
||||
or DEFAULT_MARKLLM_SCHEME
|
||||
)
|
||||
report: dict[str, Any] = {
|
||||
"detector": self.name,
|
||||
"scheme": scheme,
|
||||
"vendor": "open-llm",
|
||||
"available": False,
|
||||
}
|
||||
if not upstream:
|
||||
report["error"] = "MARKLLM_DIR not set"
|
||||
return report
|
||||
|
||||
script = Path(__file__).resolve().parent / "detect_text_watermark.py"
|
||||
timeout = (
|
||||
self._timeout
|
||||
if self._timeout is not None
|
||||
else _env_float("WATERMARKS_MARKLLM_TIMEOUT", DEFAULT_MARKLLM_TIMEOUT)
|
||||
)
|
||||
venv_python = _venv_python(Path(upstream).expanduser().resolve())
|
||||
python = str(venv_python) if venv_python is not None else sys.executable
|
||||
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".txt", encoding="utf-8", delete=False) as f:
|
||||
f.write(text)
|
||||
tmp = f.name
|
||||
|
||||
cmd = [python, str(script), "detect", tmp, "--scheme", scheme, "--json"]
|
||||
if self._model:
|
||||
cmd += ["--model", self._model]
|
||||
if self._upstream_dir:
|
||||
cmd += ["--upstream-dir", str(Path(upstream).expanduser().resolve())]
|
||||
|
||||
try:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
preexec_fn=_markllm_preexec(),
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
report["error"] = "MarkLLM detection timed out"
|
||||
return report
|
||||
if r.returncode == 3:
|
||||
report["error"] = (r.stderr or "").strip()[:400] or "MarkLLM unavailable"
|
||||
return report
|
||||
if r.returncode != 0:
|
||||
report["error"] = (r.stderr or "").strip()[:400] or f"MarkLLM exit {r.returncode}"
|
||||
return report
|
||||
try:
|
||||
payload = json.loads(r.stdout or "{}")
|
||||
except json.JSONDecodeError as e:
|
||||
report["error"] = f"bad MarkLLM JSON: {e}"
|
||||
return report
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
Path(tmp).unlink()
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
report["error"] = "bad MarkLLM response"
|
||||
return report
|
||||
payload["available"] = True
|
||||
payload["detector"] = self.name
|
||||
payload["note"] = (
|
||||
"MarkLLM is a research harness: detection is only valid against the "
|
||||
"same scheme config and keys used at generation; not a vendor detector."
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Claude (Anthropic) — announced detector API, not yet public
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ClaudeTextDetector:
|
||||
"""Placeholder for Anthropic's announced text-watermark detection API.
|
||||
|
||||
Anthropic has announced a watermark detection API for Claude-generated
|
||||
text; no public endpoint exists yet. When it ships, set
|
||||
WATERMARKS_CLAUDE_API_KEY, flip available() to check it, and fill in
|
||||
detect() against the documented endpoint.
|
||||
"""
|
||||
|
||||
name = "claude-text"
|
||||
vendor = "anthropic"
|
||||
|
||||
def available(self) -> bool:
|
||||
return False
|
||||
|
||||
def detect(self, text: str) -> dict[str, Any]:
|
||||
return {
|
||||
"detector": self.name,
|
||||
"vendor": self.vendor,
|
||||
"available": False,
|
||||
"error": (
|
||||
"Anthropic has announced a text-watermark detection API for "
|
||||
"Claude; no public endpoint is available yet. When it ships, "
|
||||
"set WATERMARKS_CLAUDE_API_KEY and implement ClaudeTextDetector."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def all_detectors(
|
||||
markllm: MarkLLMTextDetector | None = None, *, include_markllm: bool = True
|
||||
) -> list[TextDetector]:
|
||||
detectors: list[TextDetector] = [GeminiSynthIDTextDetector()]
|
||||
if include_markllm:
|
||||
detectors.append(markllm or MarkLLMTextDetector())
|
||||
detectors.append(ClaudeTextDetector())
|
||||
return detectors
|
||||
|
||||
|
||||
def detector_status() -> dict[str, bool]:
|
||||
"""Configured/usable status per detector (for /capabilities)."""
|
||||
return {d.name: d.available() for d in all_detectors()}
|
||||
|
||||
|
||||
def run_all_text_detectors(
|
||||
text: str,
|
||||
*,
|
||||
markllm: MarkLLMTextDetector | None = None,
|
||||
include_markllm: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Run every detector (including unavailable ones, with reasons).
|
||||
|
||||
markllm injects a caller-parameterized MarkLLM detector (e.g. one
|
||||
driven by rewrite_text.py CLI flags); pass include_markllm=False to
|
||||
exclude the MarkLLM harness entirely.
|
||||
"""
|
||||
return [d.detect(text) for d in all_detectors(markllm, include_markllm=include_markllm)]
|
||||
|
||||
|
||||
def run_text_detectors(
|
||||
text: str,
|
||||
*,
|
||||
markllm: MarkLLMTextDetector | None = None,
|
||||
include_markllm: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Run only the detectors that are configured and usable."""
|
||||
return [
|
||||
d.detect(text)
|
||||
for d in all_detectors(markllm, include_markllm=include_markllm)
|
||||
if d.available()
|
||||
]
|
||||
Reference in New Issue
Block a user