ragfaith-proxy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: ragfaith-proxy
3
+ Version: 0.1.0
4
+ Summary: Universal OpenAI-compatible sidecar proxy with a live ragfaith faithfulness cascade
5
+ Requires-Python: >=3.11
6
+ Provides-Extra: spacy
7
+ Requires-Dist: spacy>=3.7; extra == "spacy"
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8; extra == "dev"
10
+ Requires-Dist: ruff>=0.6; extra == "dev"
@@ -0,0 +1,166 @@
1
+ # ragfaith-proxy
2
+
3
+ Universal OpenAI-compatible sidecar proxy with a **live ragfaith faithfulness
4
+ cascade**. Any host tool without a plugin system — chat GUIs, agent harnesses,
5
+ scripts — just points its OpenAI base URL at this proxy. Streaming replies pass
6
+ through byte-for-byte (**first token never delayed**) while claims in the reply
7
+ are judged against the sources actually pulled in the conversation
8
+ (`role: "tool"` results). Flagged claims are corrected with a nudge, in-band
9
+ or on the next turn.
10
+
11
+ Pure Python stdlib. No third-party runtime dependencies.
12
+
13
+ ## Install & run
14
+
15
+ ```sh
16
+ pip install .
17
+ ragfaith-proxy # listens on 127.0.0.1:8787
18
+ ```
19
+
20
+ Tests (no network): `pip install .[dev] && pytest -q`
21
+
22
+ ## Authentication: client-auth-only
23
+
24
+ The proxy holds **no API key of its own**. Every client request must carry its
25
+ own `Authorization: Bearer <provider key>` header, which the proxy forwards to
26
+ the upstream verbatim. The client's token also authenticates the judge calls
27
+ that request's cascade triggers. Requests without an `Authorization` header
28
+ get 401. Consequence: the proxy can be hosted openly — anyone who connects
29
+ needs and spends only their own provider key.
30
+
31
+ ## FlowDown (reference example)
32
+
33
+ FlowDown is an Apple AI chat client that accepts a custom OpenAI-compatible
34
+ endpoint. To use it with the cascade:
35
+
36
+ 1. Start the proxy: `ragfaith-proxy` (default `http://127.0.0.1:8787`).
37
+ 2. In FlowDown, add a custom OpenAI-compatible provider with:
38
+ - Base URL: `http://127.0.0.1:8787/v1`
39
+ - API key: your real provider key (e.g. a synthetic key) — it is forwarded
40
+ upstream and reused for that session's judge calls.
41
+ 3. Chat as usual. When a reply misstates the retrieved sources, the next turn
42
+ automatically carries a corrective nudge — or, in chain mode, the correction
43
+ streams right after the flagged reply.
44
+
45
+ Any other client works the same way: set its base URL to
46
+ `http://127.0.0.1:8787/v1`.
47
+
48
+ ## How it works
49
+
50
+ ```
51
+ client -> proxy -> upstream LLM
52
+ |-> stream back immediately, buffer a copy
53
+ |-> on finish: decompose claims (spaCy/regex sentences)
54
+ |-> judge claims vs pulled tool-result premises
55
+ |-> flagged? nudge
56
+ ```
57
+
58
+ - **Model detection**: the request's `model` field is the active model.
59
+ - **Judge selection** (never self-judge): the active model is compared against
60
+ the configured GLM judge model (provider prefixes and `:` variants ignored).
61
+ A match is judged by the configured DeepSeek model; anything else is judged
62
+ by the configured GLM model. Both models are configurable for any
63
+ OpenAI-compatible endpoint via `RFE_JUDGE_GLM_MODEL` /
64
+ `RFE_JUDGE_DEEPSEEK_MODEL` — no `hf:`-style id shape required.
65
+ - **Premises**: content the conversation actually pulled — `role: "tool"`
66
+ messages and `tool_result` content blocks, capped to the most recent
67
+ 24k chars (`RFE_PREMISE_CAP`). A small per-conversation store covers clients
68
+ that do not resend full history (LRU-capped at 1000 conversations);
69
+ conversations are keyed by `X-Conversation-Id`, else a hash of the first
70
+ user message (full message list if none), else Host header.
71
+
72
+ ## Nudge modes (`RFE_NUDGE_MODE`)
73
+
74
+ - **`chain` (default)**: after a streaming reply with flagged claims finishes,
75
+ the proxy withholds the stream close, issues ONE internal continuation call
76
+ (original messages + assistant reply + aggregated nudge), streams it down the
77
+ same SSE stream, then terminates with a single `[DONE]`. Non-streaming
78
+ requests can't be chained, so they fall back to `next` behavior (logged).
79
+ **Spend caveat**: every flagged reply costs one extra upstream completion —
80
+ chain mode roughly doubles the upstream cost of flagged replies.
81
+ - **`next`**: the nudge is stashed per conversation and injected as a user
82
+ message right after the flagged assistant message in the next request's
83
+ history.
84
+
85
+ All-faithful replies are fully silent: no extra call, no injection.
86
+
87
+ Default nudge template:
88
+
89
+ ```
90
+ ragfaith judge (<model>): N claim(s) in your previous reply were flagged
91
+ <verdict>: <claims>. Re-check against the sources actually pulled in this
92
+ conversation and reconcile; do not invent corrections.
93
+ ```
94
+
95
+ ## Environment variables
96
+
97
+ | Variable | Default | Purpose |
98
+ |---|---|---|
99
+ | `RFE_PROXY_HOST` | `127.0.0.1` | Listen address |
100
+ | `RFE_PROXY_PORT` | `8787` | Listen port |
101
+ | `RFE_UPSTREAM_BASE` | `https://api.synthetic.new/v1` | Upstream OpenAI-compatible base |
102
+ | `RFE_JUDGE_PROVIDER` | `synthetic` | Preset: `synthetic` or `openrouter` (free-form with generic vars) |
103
+ | `RFE_JUDGE_BASE` | preset | Generic judge base URL; overrides preset |
104
+ | `RFE_JUDGE_GLM_MODEL` | preset | Generic primary judge model; overrides preset |
105
+ | `RFE_JUDGE_DEEPSEEK_MODEL` | preset | Generic GLM-active judge model; overrides preset |
106
+ | `RFE_SYNTHETIC_BASE` | upstream base | Judge base URL (synthetic preset) |
107
+ | `RFE_SYNTHETIC_GLM_MODEL` | `hf:zai-org/GLM-5.3-Flash` | Primary judge model (synthetic preset) |
108
+ | `RFE_SYNTHETIC_DEEPSEEK_MODEL` | `hf:deepseek-ai/DeepSeek-V4.1-Flash` | Judge when active model is GLM (synthetic preset) |
109
+ | `RFE_OPENROUTER_BASE` | `https://openrouter.ai/api/v1` | Judge base URL (openrouter preset) |
110
+ | `RFE_OPENROUTER_GLM_MODEL` | `z-ai/glm-5.3-flash` | OpenRouter primary judge |
111
+ | `RFE_OPENROUTER_DEEPSEEK_MODEL` | `deepseek/deepseek-v4.1-flash` | OpenRouter GLM-active judge |
112
+ | `RFE_NUDGE_MODE` | `chain` | `chain` or `next` |
113
+ | `RFE_PREMISE_CAP` | `24000` | Max chars of premises per verdict |
114
+ | `RFE_HOST_DECORATORS` | — | JSON map of per-host overrides |
115
+ | `RFE_HOST_CONFIG` | — | Path to same JSON map in a file |
116
+ | `RFE_CACHE_DIR` | — | Append-only JSONL verdict cache dir |
117
+ | `RFE_JUDGE_LOG` | stderr | File for session token log lines |
118
+ | `RFE_MAX_BODY` | `10485760` | Max request body bytes (larger gets 413) |
119
+
120
+ Precedence: generic `RFE_JUDGE_*` vars → preset vars (`RFE_SYNTHETIC_*` /
121
+ `RFE_OPENROUTER_*`, selected by `RFE_JUDGE_PROVIDER`) → built-in defaults.
122
+ Example — judge with any OpenAI-compatible endpoint:
123
+
124
+ ```sh
125
+ export RFE_JUDGE_PROVIDER=my-endpoint # preset name is free-form
126
+ export RFE_JUDGE_BASE=http://llm.internal/v1
127
+ export RFE_JUDGE_GLM_MODEL=openai/gpt-oss-120b
128
+ export RFE_JUDGE_DEEPSEEK_MODEL=mistral/magistral-small
129
+ ```
130
+
131
+ Per-host decorators are matched by the `X-RFE-Host` header (exact key), else
132
+ by User-Agent substring, else `default`:
133
+
134
+ ```json
135
+ {"flowdown": {"template": "...", "nudge_mode": "next"}, "default": {}}
136
+ ```
137
+
138
+ ## Logging & cache
139
+
140
+ Token log lines go to stderr (or `RFE_JUDGE_LOG` file), one JSON per judge
141
+ call — tokens only, no cost math:
142
+
143
+ ```json
144
+ {"ts": 1757680000.0, "kind": "judge", "model": "...", "prompt_tokens": 0, "completion_tokens": 0, "conversation": "..."}
145
+ ```
146
+
147
+ With `RFE_CACHE_DIR` set, verdicts persist to
148
+ `<dir>/proxy-cache-<judgeModelSanitized>.jsonl` (`{"key","verdict"}` rows,
149
+ sha256 keys over model/context/claim). Unset: in-memory dict only.
150
+
151
+ ## spaCy (optional)
152
+
153
+ Claim decomposition uses `split_claims`, a vendored copy of
154
+ `rag_faithfulness_eval.decompose` (copied, not imported). With
155
+ `pip install ragfaith-proxy[spacy]` you get the benchmarked spaCy sentencizer
156
+ path. Without it the proxy falls back to a regex sentence splitter and logs a
157
+ prominent warning — claim boundaries may differ from benchmark results in
158
+ edge cases.
159
+
160
+ ## Failure behavior
161
+
162
+ The proxy never blocks or corrupts the stream: any upstream/judge/cascade
163
+ failure is logged and the response passes through untouched. Client
164
+ disconnects mid-stream abort cleanly. Responses are requested
165
+ `Accept-Encoding: identity` from upstream so the buffered copy stays
166
+ parseable.
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ragfaith-proxy"
7
+ version = "0.1.0"
8
+ description = "Universal OpenAI-compatible sidecar proxy with a live ragfaith faithfulness cascade"
9
+ requires-python = ">=3.11"
10
+ dependencies = []
11
+
12
+ [project.optional-dependencies]
13
+ spacy = ["spacy>=3.7"]
14
+ dev = ["pytest>=8", "ruff>=0.6"]
15
+
16
+ [project.scripts]
17
+ ragfaith-proxy = "ragfaith_proxy.proxy:main"
18
+
19
+ [tool.setuptools.packages.find]
20
+ include = ["ragfaith_proxy*"]
@@ -0,0 +1,3 @@
1
+ """ragfaith-proxy: universal OpenAI-compatible sidecar with a live faithfulness cascade."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,56 @@
1
+ """Claim decomposition, copied from rag_faithfulness_eval/decompose.py.
2
+
3
+ spaCy sentencizer is the benchmarked path (pip install ragfaith-proxy[spacy]).
4
+ Without spaCy we degrade to a regex sentence splitter and warn loudly; claim
5
+ offsets stay verbatim char-offsets into the text either way.
6
+ """
7
+
8
+ import logging
9
+ import re
10
+ from types import SimpleNamespace
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _nlp = None
15
+
16
+ _SENT_RE = re.compile(r"[^.!?\n]+(?:[.!?]+|\n+|$)")
17
+
18
+
19
+ def _get_nlp(): # lazy: spacy is an optional extra, the proxy must not need it
20
+ global _nlp
21
+ if _nlp is not None:
22
+ return _nlp
23
+ try:
24
+ import spacy
25
+
26
+ nlp = spacy.blank("en")
27
+ nlp.add_pipe("sentencizer")
28
+ _nlp = nlp
29
+ except ImportError:
30
+ # spaCy parity is the benchmarked path; regex is the no-dep fallback
31
+ logger.warning(
32
+ "spaCy not installed: using regex sentence splitter (degraded claim "
33
+ "boundaries). Install ragfaith-proxy[spacy] for benchmark parity."
34
+ )
35
+
36
+ def regex_nlp(text: str):
37
+ sents = []
38
+ for m in _SENT_RE.finditer(text):
39
+ seg = m.group()
40
+ stripped = seg.strip()
41
+ if not stripped:
42
+ continue
43
+ start = m.start() + len(seg) - len(seg.lstrip())
44
+ sents.append(
45
+ SimpleNamespace(start_char=start, end_char=start + len(stripped), text=stripped)
46
+ )
47
+ return SimpleNamespace(sents=sents)
48
+
49
+ _nlp = regex_nlp
50
+ return _nlp
51
+
52
+
53
+ def split_claims(text: str) -> list[tuple[int, int, str]]:
54
+ """Return [(start, end, sentence)] with char offsets into text."""
55
+ doc = _get_nlp()(text)
56
+ return [(s.start_char, s.end_char, s.text) for s in doc.sents if s.text.strip()]
@@ -0,0 +1,160 @@
1
+ """LLM judge for ragfaith-proxy (OpenAI-compatible chat API, stdlib only).
2
+
3
+ Adapted from rag_faithfulness_eval/llm_judge.py: provider-pluggable base URL
4
+ and key supplied by the caller (env-only, no .env loading), token-only log
5
+ lines instead of USD accounting.
6
+ """
7
+
8
+ import hashlib
9
+ import json
10
+ import logging
11
+ import re
12
+ import sys
13
+ import threading
14
+ import time
15
+ import urllib.error
16
+ import urllib.request
17
+ from collections import OrderedDict
18
+ from pathlib import Path
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ VERDICTS = ("faithful", "unfaithful", "unverifiable")
23
+ VCACHE_MAX = 10000
24
+ RETRIES = 3 # streaming handler must not stall minutes on an upstream outage
25
+
26
+ SYSTEM_PROMPT = (
27
+ "You are a RAG faithfulness judge. / Du bist ein RAG-Treuerichter.\n"
28
+ "Decide if the CLAIM is fully supported by the CONTEXT alone (never use "
29
+ "outside knowledge). Answer with ONLY one JSON object, no other text:\n"
30
+ '{"verdict": "faithful"} - every fact in the claim is supported by the context\n'
31
+ '{"verdict": "unfaithful"} - at least one fact contradicts or is unsupported '
32
+ "by the context (wrong entity, number, date, or fabricated detail)\n"
33
+ '{"verdict": "unverifiable"} - the context does not address the claim at all'
34
+ )
35
+
36
+
37
+ def _verdict_key(model: str, context: str, claim: str) -> str:
38
+ return hashlib.sha256(f"{model}\x00{context}\x00{claim}".encode()).hexdigest()
39
+
40
+
41
+ def _parse_verdict(text: str) -> str:
42
+ m = re.search(r'"verdict"\s*:\s*"(\w+)"', text)
43
+ verdict = m.group(1) if m else ""
44
+ if verdict not in VERDICTS:
45
+ raise ValueError(f"unparseable verdict: {text[:200]!r}")
46
+ return verdict
47
+
48
+
49
+ class Judge:
50
+ """Chat-model judge against any OpenAI-compatible base URL."""
51
+
52
+ def __init__(
53
+ self,
54
+ model: str,
55
+ base_url: str,
56
+ api_key: str,
57
+ *,
58
+ max_tokens: int = 256,
59
+ cache_path: Path | None = None,
60
+ log=None,
61
+ vcache_max: int = VCACHE_MAX,
62
+ ):
63
+ self.model = model
64
+ self.base_url = base_url.rstrip("/")
65
+ self.api_key = api_key
66
+ self.max_tokens = max_tokens
67
+ self.cache_path = cache_path
68
+ self._log = log or (lambda r: print(json.dumps(r), file=sys.stderr, flush=True))
69
+ self._lock = threading.Lock()
70
+ self._vcache_max = vcache_max
71
+ self._vcache: OrderedDict[str, str] = OrderedDict()
72
+ if cache_path and cache_path.exists():
73
+ for line in cache_path.read_text().splitlines():
74
+ if line.strip():
75
+ row = json.loads(line)
76
+ self._vcache[row["key"]] = row["verdict"]
77
+
78
+ def _call(self, user_msg: str, retries: int = RETRIES, max_tokens: int | None = None) -> dict:
79
+ body = json.dumps(
80
+ {
81
+ "model": self.model,
82
+ "temperature": 0,
83
+ "max_tokens": max_tokens or self.max_tokens,
84
+ "reasoning": {"exclude": True}, # hide reasoning; still billed ~100-250 tok
85
+ "messages": [
86
+ {"role": "system", "content": SYSTEM_PROMPT},
87
+ {"role": "user", "content": user_msg},
88
+ ],
89
+ }
90
+ ).encode()
91
+ url = f"{self.base_url}/chat/completions"
92
+ for attempt in range(retries):
93
+ req = urllib.request.Request(
94
+ url,
95
+ data=body,
96
+ headers={
97
+ "Authorization": f"Bearer {self.api_key}",
98
+ "Content-Type": "application/json",
99
+ },
100
+ )
101
+ try:
102
+ with urllib.request.urlopen(req, timeout=120) as resp:
103
+ return json.loads(resp.read())
104
+ except urllib.error.HTTPError as e:
105
+ if e.code in (429, 500, 502, 503) and attempt < retries - 1:
106
+ time.sleep(2**attempt)
107
+ continue
108
+ raise
109
+ except (urllib.error.URLError, TimeoutError) as e:
110
+ # network drop: bounded retry (<=15 s total sleep at 3 attempts)
111
+ if attempt < retries - 1:
112
+ logger.warning("network error (%s); retry %d/%d", e, attempt + 1, retries)
113
+ time.sleep(min(60, 5 * 2**attempt))
114
+ continue
115
+ raise
116
+ raise RuntimeError("unreachable: retry loop exhausted")
117
+
118
+ def _account(self, resp: dict, conversation: str) -> None:
119
+ usage = resp.get("usage") or {}
120
+ self._log(
121
+ {
122
+ "kind": "judge",
123
+ "model": self.model,
124
+ "prompt_tokens": usage.get("prompt_tokens", 0),
125
+ "completion_tokens": usage.get("completion_tokens", 0),
126
+ "conversation": conversation,
127
+ }
128
+ )
129
+
130
+ def verdict(self, context: str, claim: str, conversation: str = "") -> str:
131
+ key = _verdict_key(self.model, context, claim)
132
+ with self._lock:
133
+ if key in self._vcache:
134
+ self._vcache.move_to_end(key)
135
+ return self._vcache[key]
136
+ msg = f"CONTEXT:\n{context}\n\nCLAIM:\n{claim}"
137
+ for max_tokens in (self.max_tokens, self.max_tokens * 2):
138
+ resp = self._call(msg, max_tokens=max_tokens)
139
+ self._account(resp, conversation)
140
+ content = resp["choices"][0]["message"].get("content")
141
+ try:
142
+ verdict = _parse_verdict(content or "")
143
+ self._store(key, verdict)
144
+ return verdict
145
+ except ValueError:
146
+ continue
147
+ verdict = "unverifiable" # parse failure after 256 -> 2x tokens: conservative
148
+ self._store(key, verdict)
149
+ return verdict
150
+
151
+ def _store(self, key: str, verdict: str) -> None:
152
+ with self._lock:
153
+ self._vcache[key] = verdict
154
+ self._vcache.move_to_end(key)
155
+ while len(self._vcache) > self._vcache_max:
156
+ self._vcache.popitem(last=False)
157
+ if self.cache_path:
158
+ self.cache_path.parent.mkdir(parents=True, exist_ok=True)
159
+ with self.cache_path.open("a") as f:
160
+ f.write(json.dumps({"key": key, "verdict": verdict}) + "\n")