rapier-runtime 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rapier/__init__.py +70 -0
- rapier/_json.py +39 -0
- rapier/cli.py +244 -0
- rapier/convergence.py +93 -0
- rapier/envelope.py +66 -0
- rapier/ledger.py +71 -0
- rapier/manifest.py +75 -0
- rapier/mcp/__init__.py +9 -0
- rapier/mcp/server.py +184 -0
- rapier/mcp/tools.py +125 -0
- rapier/models.py +352 -0
- rapier/onboarding.py +128 -0
- rapier/pipeline.py +145 -0
- rapier/presets.py +83 -0
- rapier/secrets.py +72 -0
- rapier/stage.py +98 -0
- rapier/stages/__init__.py +6 -0
- rapier/stages/echo.py +28 -0
- rapier/stages/proposer/__init__.py +7 -0
- rapier/stages/proposer/phase_stages.py +101 -0
- rapier/stages/proposer/phases.py +201 -0
- rapier/stages/resolver/__init__.py +22 -0
- rapier/stages/resolver/_binding.py +33 -0
- rapier/stages/resolver/_extract.py +82 -0
- rapier/stages/resolver/anchored_fix.py +52 -0
- rapier/stages/resolver/author.py +62 -0
- rapier/stages/resolver/citation_gate.py +123 -0
- rapier/stages/resolver/compose.py +457 -0
- rapier/stages/resolver/cross_review.py +45 -0
- rapier/stages/resolver/definitiveness_gate.py +55 -0
- rapier/verify/__init__.py +4 -0
- rapier/verify/_bootstrap.py +66 -0
- rapier/verify/_llm_shim.py +131 -0
- rapier/verify/_vendor/PROVENANCE.md +14 -0
- rapier/verify/_vendor/cite_check.py +1093 -0
- rapier/verify/_vendor/lib_llm.py +288 -0
- rapier/verify/_vendor/spar_cross_review.py +296 -0
- rapier/verify/_vendor/spar_definitiveness_gate.py +770 -0
- rapier/verify/_vendor/spar_verify_gate.py +233 -0
- rapier/verify/_vendor/verify_grounding.py +795 -0
- rapier/verify/service.py +46 -0
- rapier_runtime-0.1.0.dist-info/METADATA +249 -0
- rapier_runtime-0.1.0.dist-info/RECORD +46 -0
- rapier_runtime-0.1.0.dist-info/WHEEL +4 -0
- rapier_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- rapier_runtime-0.1.0.dist-info/licenses/LICENSE +202 -0
rapier/mcp/server.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""The ``rapier mcp`` stdio server — spar / sparring / doctor as MCP tools.
|
|
2
|
+
|
|
3
|
+
Optional: requires the ``mcp`` extra (``pip install "rapier-runtime[mcp]"``). The
|
|
4
|
+
SDK is imported lazily inside :func:`build_server`, so importing this module (and
|
|
5
|
+
the rest of the CLI) never needs it. Keys are supplied by the MCP client in the
|
|
6
|
+
server's ``env`` block and read from the environment like everywhere else — the
|
|
7
|
+
engine still reads no secret from a file.
|
|
8
|
+
|
|
9
|
+
Tools return a **structured** result (recommendation + trust rider markdown, the
|
|
10
|
+
definitiveness verdict, the live grounding summary, cross-vendor status, and any
|
|
11
|
+
forwarded standing objections). A ceremony is many model calls and can run for
|
|
12
|
+
minutes, so the tools **stream per-stage progress** to the client: the engine's
|
|
13
|
+
run-log lines are bridged from the worker thread to MCP ``info`` + ``report_progress``
|
|
14
|
+
notifications, so the client shows life instead of a hang.
|
|
15
|
+
|
|
16
|
+
NOTE: this module deliberately does *not* use ``from __future__ import
|
|
17
|
+
annotations`` — FastMCP evaluates each tool's signature, and the lazily-imported
|
|
18
|
+
``Context`` annotation must be a real class object at definition time, not a
|
|
19
|
+
string it cannot resolve.
|
|
20
|
+
"""
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from typing import Any, Callable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _run_with_progress(
|
|
27
|
+
fn: Callable[..., dict],
|
|
28
|
+
ctx: Any,
|
|
29
|
+
total: int,
|
|
30
|
+
timeout_s: float | None = None,
|
|
31
|
+
ledger_root: str | None = None,
|
|
32
|
+
**kwargs: Any,
|
|
33
|
+
) -> dict:
|
|
34
|
+
"""Run a blocking tool ``fn(log=…, cancel=…, ledger_root=…, **kwargs)`` in a
|
|
35
|
+
worker thread while streaming its log lines to the MCP client as info +
|
|
36
|
+
progress notifications, with cooperative cancellation and an optional timeout.
|
|
37
|
+
|
|
38
|
+
``ctx`` may be ``None`` (a unit test with no session), in which case the run
|
|
39
|
+
proceeds silently. On client cancellation or timeout the cancel flag is set,
|
|
40
|
+
so the (abandoned) worker stops at the next stage boundary rather than being
|
|
41
|
+
killed mid-call. Progress ticks once per pipeline stage (``stage:`` log lines).
|
|
42
|
+
"""
|
|
43
|
+
import queue as _queue
|
|
44
|
+
import threading
|
|
45
|
+
|
|
46
|
+
import anyio
|
|
47
|
+
|
|
48
|
+
q: _queue.Queue = _queue.Queue()
|
|
49
|
+
sentinel = object()
|
|
50
|
+
box: dict[str, dict] = {}
|
|
51
|
+
cancel_event = threading.Event()
|
|
52
|
+
|
|
53
|
+
def _log(msg: str) -> None:
|
|
54
|
+
q.put(str(msg))
|
|
55
|
+
|
|
56
|
+
def _work() -> None:
|
|
57
|
+
box["result"] = fn(
|
|
58
|
+
log=_log, cancel=cancel_event.is_set, ledger_root=ledger_root, **kwargs
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def _drain() -> None:
|
|
62
|
+
done = 0
|
|
63
|
+
while True:
|
|
64
|
+
try:
|
|
65
|
+
msg = q.get_nowait()
|
|
66
|
+
except _queue.Empty:
|
|
67
|
+
await anyio.sleep(0.02)
|
|
68
|
+
continue
|
|
69
|
+
if msg is sentinel:
|
|
70
|
+
return
|
|
71
|
+
if ctx is not None:
|
|
72
|
+
await ctx.info(msg)
|
|
73
|
+
if msg.startswith("stage:"):
|
|
74
|
+
done += 1
|
|
75
|
+
try:
|
|
76
|
+
await ctx.report_progress(done, total)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass # progress is best-effort; never fail a run over it
|
|
79
|
+
|
|
80
|
+
timed_out = False
|
|
81
|
+
try:
|
|
82
|
+
async with anyio.create_task_group() as tg:
|
|
83
|
+
tg.start_soon(_drain)
|
|
84
|
+
if timeout_s:
|
|
85
|
+
try:
|
|
86
|
+
with anyio.fail_after(timeout_s):
|
|
87
|
+
await anyio.to_thread.run_sync(_work, abandon_on_cancel=True)
|
|
88
|
+
except TimeoutError:
|
|
89
|
+
timed_out = True
|
|
90
|
+
else:
|
|
91
|
+
await anyio.to_thread.run_sync(_work, abandon_on_cancel=True)
|
|
92
|
+
q.put(sentinel)
|
|
93
|
+
finally:
|
|
94
|
+
cancel_event.set() # stop the (possibly abandoned) worker at the next boundary
|
|
95
|
+
|
|
96
|
+
if timed_out:
|
|
97
|
+
return {"ok": False, "error": f"timed out after {timeout_s}s; partial run abandoned"}
|
|
98
|
+
return box.get("result", {"ok": False, "error": "run produced no result"})
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_server():
|
|
102
|
+
"""Construct the FastMCP server with Rapier's tools.
|
|
103
|
+
|
|
104
|
+
Raises ``ImportError`` if the ``mcp`` extra is not installed (handled by
|
|
105
|
+
:func:`serve`).
|
|
106
|
+
"""
|
|
107
|
+
from mcp.server.fastmcp import Context, FastMCP # optional dependency
|
|
108
|
+
|
|
109
|
+
from ..presets import load_preset
|
|
110
|
+
from . import tools
|
|
111
|
+
|
|
112
|
+
server = FastMCP("rapier")
|
|
113
|
+
# Opt-in run persistence: when set, ceremonies are written here and become
|
|
114
|
+
# retrievable via list_runs / get_run. Off by default (no surprise disk writes).
|
|
115
|
+
ledger_root = os.environ.get("RAPIER_MCP_LEDGER") or None
|
|
116
|
+
|
|
117
|
+
def _stage_total(name: str, settle: int, verify: str) -> int:
|
|
118
|
+
try:
|
|
119
|
+
return len(load_preset(name, settle=settle, verify=verify).stages)
|
|
120
|
+
except Exception:
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
@server.tool()
|
|
124
|
+
async def spar(
|
|
125
|
+
request: str, settle: int = 0, verify: str = "gate",
|
|
126
|
+
timeout_s: float = 0, ctx: Context = None,
|
|
127
|
+
) -> dict:
|
|
128
|
+
"""Run the SPARRING Resolver on a decision: one grounded, cross-vendor
|
|
129
|
+
challenge plus a definitiveness gate. Returns a recommendation, a trust
|
|
130
|
+
rider, and the grounding verdict. ``settle`` adds review-and-revise rounds
|
|
131
|
+
(default 0); ``verify`` is off|gate|round for the external-canon gate;
|
|
132
|
+
``timeout_s`` > 0 caps the run (partial run abandoned on timeout)."""
|
|
133
|
+
return await _run_with_progress(
|
|
134
|
+
tools.run_spar, ctx, _stage_total("spar", settle, verify),
|
|
135
|
+
timeout_s=timeout_s or None, ledger_root=ledger_root,
|
|
136
|
+
request=request, settle=settle, verify=verify,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
@server.tool()
|
|
140
|
+
async def sparring(
|
|
141
|
+
request: str, settle: int = 0, verify: str = "gate",
|
|
142
|
+
report_all: bool = False, timeout_s: float = 0, ctx: Context = None,
|
|
143
|
+
) -> dict:
|
|
144
|
+
"""Run the full SPARRING ceremony (Proposer, then Resolver) on a decision.
|
|
145
|
+
``report_all`` also returns the Proposer handoff (the committed option and
|
|
146
|
+
its standing objections); ``timeout_s`` > 0 caps the run."""
|
|
147
|
+
return await _run_with_progress(
|
|
148
|
+
tools.run_sparring, ctx, _stage_total("sparring", settle, verify),
|
|
149
|
+
timeout_s=timeout_s or None, ledger_root=ledger_root,
|
|
150
|
+
request=request, settle=settle, verify=verify, report_all=report_all,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@server.tool()
|
|
154
|
+
def rapier_doctor() -> dict:
|
|
155
|
+
"""Report which AI vendor keys this server has (env-var names only, never
|
|
156
|
+
values) and whether cross-vendor review is available."""
|
|
157
|
+
return tools.doctor()
|
|
158
|
+
|
|
159
|
+
@server.tool()
|
|
160
|
+
def list_runs() -> dict:
|
|
161
|
+
"""List persisted run ids (requires the server's RAPIER_MCP_LEDGER)."""
|
|
162
|
+
return tools.list_runs(ledger_root)
|
|
163
|
+
|
|
164
|
+
@server.tool()
|
|
165
|
+
def get_run(run_id: str) -> dict:
|
|
166
|
+
"""Fetch a persisted run's report + verdict by id (requires RAPIER_MCP_LEDGER)."""
|
|
167
|
+
return tools.get_run(ledger_root, run_id)
|
|
168
|
+
|
|
169
|
+
return server
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def serve() -> int:
|
|
173
|
+
"""Run the stdio MCP server. Returns non-zero with a hint if the SDK is absent."""
|
|
174
|
+
try:
|
|
175
|
+
server = build_server()
|
|
176
|
+
except ImportError:
|
|
177
|
+
print(
|
|
178
|
+
'The MCP server needs the optional "mcp" extra:\n'
|
|
179
|
+
' pip install "rapier-runtime[mcp]"',
|
|
180
|
+
file=sys.stderr,
|
|
181
|
+
)
|
|
182
|
+
return 1
|
|
183
|
+
server.run() # stdio transport by default
|
|
184
|
+
return 0
|
rapier/mcp/tools.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""MCP tool logic — pure functions the MCP server wraps.
|
|
2
|
+
|
|
3
|
+
No dependency on the ``mcp`` SDK, so this is testable without the extra installed
|
|
4
|
+
and reusable outside MCP. Each function runs a preset through the engine and
|
|
5
|
+
returns a structured result: a human-readable ``report_md`` plus machine fields
|
|
6
|
+
(verdict, grounding, cross-vendor, standing objections). Honest first: if no
|
|
7
|
+
vendor key is configured, it returns ``{"ok": False, "error": …}`` rather than an
|
|
8
|
+
empty run.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
from ..onboarding import configured_vendors, doctor_report, preflight_error
|
|
17
|
+
from ..presets import load_preset
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _result(env, report_all: bool) -> dict[str, Any]:
|
|
21
|
+
report = env.meta.get("report_md") or env.recommendation or ""
|
|
22
|
+
proposer_md = env.meta.get("proposer_report_md")
|
|
23
|
+
if report_all and proposer_md:
|
|
24
|
+
report = f"{proposer_md}\n\n---\n\n{report}"
|
|
25
|
+
gate = env.meta.get("citation_gate") or {}
|
|
26
|
+
review = env.meta.get("review") or {}
|
|
27
|
+
cut = (env.meta.get("proposer") or {}).get("cut") or {}
|
|
28
|
+
cancelled = any(t.kind == "control" and "cancelled" in t.summary for t in env.trace)
|
|
29
|
+
return {
|
|
30
|
+
"ok": True,
|
|
31
|
+
"cancelled": cancelled,
|
|
32
|
+
"run_id": env.meta.get("run_id"),
|
|
33
|
+
"report_md": report,
|
|
34
|
+
"verdict": env.verdict,
|
|
35
|
+
"grounding": (
|
|
36
|
+
{
|
|
37
|
+
"gate": gate.get("gate"),
|
|
38
|
+
"grounding_rate": gate.get("grounding_rate"),
|
|
39
|
+
"counts": gate.get("counts"),
|
|
40
|
+
}
|
|
41
|
+
if gate
|
|
42
|
+
else None
|
|
43
|
+
),
|
|
44
|
+
"cross_vendor": review.get("cross_vendor"),
|
|
45
|
+
"author_vendor": env.meta.get("author_vendor"),
|
|
46
|
+
"reviewer_vendor": review.get("reviewer_vendor"),
|
|
47
|
+
"standing_objections": cut.get("standing_objections") or [],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _run(
|
|
52
|
+
name: str,
|
|
53
|
+
request: str,
|
|
54
|
+
settle: int,
|
|
55
|
+
verify: str,
|
|
56
|
+
report_all: bool,
|
|
57
|
+
log: Callable[[str], None] | None,
|
|
58
|
+
cancel: Callable[[], bool] | None = None,
|
|
59
|
+
ledger_root: str | None = None,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
err = preflight_error()
|
|
62
|
+
if err:
|
|
63
|
+
return {"ok": False, "error": err}
|
|
64
|
+
preset = load_preset(name, settle=settle, verify=verify)
|
|
65
|
+
env = preset.build().run(
|
|
66
|
+
request, ledger_root=ledger_root, log=log or (lambda _m: None), cancel=cancel
|
|
67
|
+
)
|
|
68
|
+
return _result(env, report_all)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_spar(
|
|
72
|
+
request: str, settle: int = 0, verify: str = "gate",
|
|
73
|
+
log: Callable[[str], None] | None = None,
|
|
74
|
+
cancel: Callable[[], bool] | None = None,
|
|
75
|
+
ledger_root: str | None = None,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
return _run("spar", request, settle, verify, False, log, cancel, ledger_root)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run_sparring(
|
|
81
|
+
request: str, settle: int = 0, verify: str = "gate", report_all: bool = False,
|
|
82
|
+
log: Callable[[str], None] | None = None,
|
|
83
|
+
cancel: Callable[[], bool] | None = None,
|
|
84
|
+
ledger_root: str | None = None,
|
|
85
|
+
) -> dict[str, Any]:
|
|
86
|
+
return _run("sparring", request, settle, verify, report_all, log, cancel, ledger_root)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def doctor() -> dict[str, Any]:
|
|
90
|
+
return {"report": doctor_report(), "configured_vendors": configured_vendors()}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _safe_run_id(run_id: str) -> bool:
|
|
94
|
+
"""Reject path-traversal / separators — a run id is a single dir name."""
|
|
95
|
+
return bool(run_id) and os.sep not in run_id and "/" not in run_id and ".." not in run_id
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def list_runs(ledger_root: str | None) -> dict[str, Any]:
|
|
99
|
+
"""List persisted run ids under the server's ledger dir (newest last)."""
|
|
100
|
+
if not ledger_root or not os.path.isdir(ledger_root):
|
|
101
|
+
return {"ok": False, "error": "run persistence is not enabled (set RAPIER_MCP_LEDGER)"}
|
|
102
|
+
runs = sorted(
|
|
103
|
+
d for d in os.listdir(ledger_root) if os.path.isdir(os.path.join(ledger_root, d))
|
|
104
|
+
)
|
|
105
|
+
return {"ok": True, "runs": runs}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_run(ledger_root: str | None, run_id: str) -> dict[str, Any]:
|
|
109
|
+
"""Return a persisted run's report + verdict by id (from its envelope.json)."""
|
|
110
|
+
if not ledger_root or not os.path.isdir(ledger_root):
|
|
111
|
+
return {"ok": False, "error": "run persistence is not enabled (set RAPIER_MCP_LEDGER)"}
|
|
112
|
+
if not _safe_run_id(run_id):
|
|
113
|
+
return {"ok": False, "error": "invalid run id"}
|
|
114
|
+
path = os.path.join(ledger_root, run_id, "envelope.json")
|
|
115
|
+
if not os.path.isfile(path):
|
|
116
|
+
return {"ok": False, "error": f"run '{run_id}' not found"}
|
|
117
|
+
with open(path, encoding="utf-8") as fh:
|
|
118
|
+
env = json.load(fh)
|
|
119
|
+
meta = env.get("meta") or {}
|
|
120
|
+
return {
|
|
121
|
+
"ok": True,
|
|
122
|
+
"run_id": run_id,
|
|
123
|
+
"report_md": meta.get("report_md") or env.get("recommendation") or "",
|
|
124
|
+
"verdict": env.get("verdict"),
|
|
125
|
+
}
|
rapier/models.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""The model layer — the ONLY place a vendor or model name lives.
|
|
2
|
+
|
|
3
|
+
A ``ModelSpec`` (vendor + model + prompt) comes straight from the manifest.
|
|
4
|
+
``build_client`` turns it into a ``ModelClient``. Cross-vendor independence is
|
|
5
|
+
therefore a config property, not a code change: point two roles at two vendors.
|
|
6
|
+
|
|
7
|
+
Real provider SDKs are imported lazily and only when a real call is made, so
|
|
8
|
+
the package imports and the M0 echo pipeline run with no SDKs and no keys.
|
|
9
|
+
Secrets are read exclusively through :mod:`rapier.secrets` (env-only, redacted).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .secrets import register_secret_value, require_secret
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ModelSpec:
|
|
22
|
+
vendor: str # mock | anthropic | openai
|
|
23
|
+
model: str
|
|
24
|
+
prompt_template: str | None = None
|
|
25
|
+
max_tokens: int = 4096 # generative floor; 1024 truncates real recommendations
|
|
26
|
+
temperature: float = 1.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ModelResponse:
|
|
31
|
+
text: str
|
|
32
|
+
vendor: str
|
|
33
|
+
model: str
|
|
34
|
+
raw: dict[str, Any] | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- one HTTP path + one transcript hook for EVERY vendor -------------------
|
|
38
|
+
# Every model call — native Anthropic/OpenAI, Gemini/Grok, any compatible
|
|
39
|
+
# endpoint — goes through _post_with_retry and _record(), so retry/backoff and
|
|
40
|
+
# transcript capture are identical regardless of the LLM pairing.
|
|
41
|
+
|
|
42
|
+
_transcript_sink = None # set per run; called with one dict per model call
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def set_transcript_sink(fn) -> None:
|
|
46
|
+
global _transcript_sink
|
|
47
|
+
_transcript_sink = fn
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _record(vendor: str, model: str, system: str, prompt: str, response: str) -> None:
|
|
51
|
+
if _transcript_sink is not None:
|
|
52
|
+
try:
|
|
53
|
+
_transcript_sink(
|
|
54
|
+
{"vendor": vendor, "model": model, "system": system, "prompt": prompt, "response": response}
|
|
55
|
+
)
|
|
56
|
+
except Exception: # transcript capture must never break a run
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _post_with_retry(url: str, headers: dict, payload: dict, timeout: int = 120) -> dict:
|
|
61
|
+
"""POST JSON with exponential backoff on 429/5xx (respects Retry-After)."""
|
|
62
|
+
import time
|
|
63
|
+
|
|
64
|
+
import requests
|
|
65
|
+
|
|
66
|
+
delay, last = 2.0, None
|
|
67
|
+
for _ in range(6):
|
|
68
|
+
try:
|
|
69
|
+
resp = requests.post(url, headers=headers, json=payload, timeout=timeout)
|
|
70
|
+
except requests.RequestException as exc:
|
|
71
|
+
last = str(exc)
|
|
72
|
+
time.sleep(delay)
|
|
73
|
+
delay = min(delay * 2, 30)
|
|
74
|
+
continue
|
|
75
|
+
if resp.status_code == 429 or resp.status_code >= 500:
|
|
76
|
+
retry_after = resp.headers.get("Retry-After")
|
|
77
|
+
wait = float(retry_after) if (retry_after or "").replace(".", "", 1).isdigit() else delay
|
|
78
|
+
last = f"HTTP {resp.status_code}"
|
|
79
|
+
time.sleep(min(wait, 30))
|
|
80
|
+
delay = min(delay * 2, 30)
|
|
81
|
+
continue
|
|
82
|
+
resp.raise_for_status()
|
|
83
|
+
return resp.json()
|
|
84
|
+
raise RuntimeError(f"request to {url} failed after retries: {last}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ModelClient(ABC):
|
|
88
|
+
def __init__(self, spec: ModelSpec):
|
|
89
|
+
self.spec = spec
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def complete(self, system: str, prompt: str) -> ModelResponse:
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class MockModelClient(ModelClient):
|
|
97
|
+
"""Deterministic, no network, no key. Used by the echo pipeline and tests."""
|
|
98
|
+
|
|
99
|
+
def complete(self, system: str, prompt: str) -> ModelResponse:
|
|
100
|
+
text = f"[mock:{self.spec.model}] {prompt.strip()}"
|
|
101
|
+
_record("mock", self.spec.model, system, prompt, text)
|
|
102
|
+
return ModelResponse(text=text, vendor="mock", model=self.spec.model)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AnthropicModelClient(ModelClient):
|
|
106
|
+
def complete(self, system: str, prompt: str) -> ModelResponse:
|
|
107
|
+
key = require_secret("ANTHROPIC_API_KEY")
|
|
108
|
+
register_secret_value(key)
|
|
109
|
+
data = _post_with_retry(
|
|
110
|
+
"https://api.anthropic.com/v1/messages",
|
|
111
|
+
{"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"},
|
|
112
|
+
{
|
|
113
|
+
"model": self.spec.model,
|
|
114
|
+
"max_tokens": self.spec.max_tokens,
|
|
115
|
+
"system": system,
|
|
116
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
|
|
120
|
+
_record("anthropic", self.spec.model, system, prompt, text)
|
|
121
|
+
return ModelResponse(text=text, vendor="anthropic", model=self.spec.model)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class OpenAIModelClient(ModelClient):
|
|
125
|
+
def complete(self, system: str, prompt: str) -> ModelResponse:
|
|
126
|
+
key = require_secret("OPENAI_API_KEY")
|
|
127
|
+
register_secret_value(key)
|
|
128
|
+
data = _post_with_retry(
|
|
129
|
+
"https://api.openai.com/v1/chat/completions",
|
|
130
|
+
{"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
131
|
+
{
|
|
132
|
+
"model": self.spec.model,
|
|
133
|
+
"messages": [
|
|
134
|
+
{"role": "system", "content": system},
|
|
135
|
+
{"role": "user", "content": prompt},
|
|
136
|
+
],
|
|
137
|
+
"max_completion_tokens": self.spec.max_tokens,
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
text = (data.get("choices") or [{}])[0].get("message", {}).get("content") or ""
|
|
141
|
+
_record("openai", self.spec.model, system, prompt, text)
|
|
142
|
+
return ModelResponse(text=text, vendor="openai", model=self.spec.model)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class OpenAICompatibleModelClient(ModelClient):
|
|
146
|
+
"""Any OpenAI-wire-format endpoint — Grok (xAI), DeepSeek, Mistral, Groq,
|
|
147
|
+
Together, OpenRouter, local Ollama/vLLM. One client, parameterized by
|
|
148
|
+
``base_url`` + the env var holding its key. Uses ``requests`` directly (no
|
|
149
|
+
SDK), so adding a vendor is a config entry, not new code.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(self, spec: ModelSpec, base_url: str, key_env: str):
|
|
153
|
+
super().__init__(spec)
|
|
154
|
+
self.base_url = base_url.rstrip("/")
|
|
155
|
+
self.key_env = key_env
|
|
156
|
+
|
|
157
|
+
def complete(self, system: str, prompt: str) -> ModelResponse:
|
|
158
|
+
key = require_secret(self.key_env)
|
|
159
|
+
register_secret_value(key)
|
|
160
|
+
data = _post_with_retry(
|
|
161
|
+
f"{self.base_url}/chat/completions",
|
|
162
|
+
{"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
163
|
+
{
|
|
164
|
+
"model": self.spec.model,
|
|
165
|
+
"messages": [
|
|
166
|
+
{"role": "system", "content": system},
|
|
167
|
+
{"role": "user", "content": prompt},
|
|
168
|
+
],
|
|
169
|
+
"max_tokens": self.spec.max_tokens,
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
text = (data.get("choices") or [{}])[0].get("message", {}).get("content") or ""
|
|
173
|
+
_record(self.spec.vendor, self.spec.model, system, prompt, text)
|
|
174
|
+
return ModelResponse(text=text, vendor=self.spec.vendor, model=self.spec.model)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# vendor -> (base_url, key_env, default_model). Defaults are overridable in the
|
|
178
|
+
# manifest; only xai's default is live-validated (2026-07-06 — Grok's API is
|
|
179
|
+
# OpenAI-compatible). Others are best-effort until validated with a real key.
|
|
180
|
+
_OPENAI_COMPATIBLE: dict[str, tuple[str, str, str]] = {
|
|
181
|
+
"gemini": ("https://generativelanguage.googleapis.com/v1beta/openai", "GEMINI_API_KEY", "gemini-2.5-flash"), # frontier; also has a native API
|
|
182
|
+
"xai": ("https://api.x.ai/v1", "XAI_API_KEY", "grok-4.3"),
|
|
183
|
+
"deepseek": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY", "deepseek-chat"),
|
|
184
|
+
"mistral": ("https://api.mistral.ai/v1", "MISTRAL_API_KEY", "mistral-large-latest"),
|
|
185
|
+
"groq": ("https://api.groq.com/openai/v1", "GROQ_API_KEY", "llama-3.3-70b-versatile"),
|
|
186
|
+
"together": ("https://api.together.xyz/v1", "TOGETHER_API_KEY", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
|
|
187
|
+
"openrouter": ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY", "openai/gpt-4o"),
|
|
188
|
+
"ollama": ("http://localhost:11434/v1", "OLLAMA_API_KEY", "llama3.1"), # local, zero-egress
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# vendor -> env var holding its key (for auto-detect). Native + compatible.
|
|
192
|
+
_VENDOR_KEY_ENV: dict[str, str] = {
|
|
193
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
194
|
+
"openai": "OPENAI_API_KEY",
|
|
195
|
+
**{v: cfg[1] for v, cfg in _OPENAI_COMPATIBLE.items()},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def build_client(spec: ModelSpec) -> ModelClient:
|
|
200
|
+
vendor = spec.vendor
|
|
201
|
+
if vendor == "mock":
|
|
202
|
+
return MockModelClient(spec)
|
|
203
|
+
if vendor == "anthropic":
|
|
204
|
+
return AnthropicModelClient(spec)
|
|
205
|
+
if vendor == "openai":
|
|
206
|
+
return OpenAIModelClient(spec)
|
|
207
|
+
if vendor in _OPENAI_COMPATIBLE:
|
|
208
|
+
base_url, key_env, default_model = _OPENAI_COMPATIBLE[vendor]
|
|
209
|
+
if not spec.model:
|
|
210
|
+
spec.model = default_model
|
|
211
|
+
return OpenAICompatibleModelClient(spec, base_url, key_env)
|
|
212
|
+
known = ["mock", "anthropic", "openai", *sorted(_OPENAI_COMPATIBLE)]
|
|
213
|
+
raise ValueError(f"unknown vendor '{vendor}'; known vendors: {known}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def available_vendors() -> list[str]:
|
|
217
|
+
"""Which vendors have a key present in the environment (auto-detect).
|
|
218
|
+
|
|
219
|
+
``mock`` is always available; ``ollama`` is local and only appears if its
|
|
220
|
+
(optional) key env is set. Keys are read env-only (threat model S1).
|
|
221
|
+
"""
|
|
222
|
+
from .secrets import get_secret
|
|
223
|
+
|
|
224
|
+
avail = ["mock"]
|
|
225
|
+
for vendor, key_env in _VENDOR_KEY_ENV.items():
|
|
226
|
+
if get_secret(key_env):
|
|
227
|
+
avail.append(vendor)
|
|
228
|
+
return avail
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# vendor -> a sensible default model (used when a role has no explicit model).
|
|
232
|
+
_DEFAULT_MODEL: dict[str, str] = {
|
|
233
|
+
"anthropic": "claude-opus-4-8",
|
|
234
|
+
"openai": "gpt-5.2",
|
|
235
|
+
**{v: cfg[2] for v, cfg in _OPENAI_COMPATIBLE.items()},
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
# preference order when auto-assigning frontier vendors to roles.
|
|
239
|
+
_FRONTIER_ORDER = ["anthropic", "openai", "gemini", "xai"]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def default_model(vendor: str) -> str:
|
|
243
|
+
return _DEFAULT_MODEL.get(vendor, "")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def vendor_key_envs() -> dict[str, str]:
|
|
247
|
+
"""Vendor -> the env var holding its key (public, for onboarding / doctor)."""
|
|
248
|
+
return dict(_VENDOR_KEY_ENV)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def frontier_vendors() -> list[str]:
|
|
252
|
+
"""The frontier vendors, in preference order (for onboarding hints)."""
|
|
253
|
+
return list(_FRONTIER_ORDER)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def resolve_pair(
|
|
257
|
+
available: list[str],
|
|
258
|
+
primary_pref: str | None = None,
|
|
259
|
+
secondary_pref: str | None = None,
|
|
260
|
+
) -> tuple[str | None, str | None]:
|
|
261
|
+
"""Choose ``(primary, secondary)`` vendors from the available set.
|
|
262
|
+
|
|
263
|
+
``primary`` backs the author/primary slot; ``secondary`` is a **distinct**
|
|
264
|
+
vendor for the reviewer/cross-vendor slot, or ``None`` when only one vendor
|
|
265
|
+
is available (honest single-vendor degradation). Preferences win when
|
|
266
|
+
available; otherwise frontier vendors are assigned in order.
|
|
267
|
+
"""
|
|
268
|
+
avail = [v for v in available if v != "mock"]
|
|
269
|
+
if not avail:
|
|
270
|
+
return None, None
|
|
271
|
+
|
|
272
|
+
def pick(pref: str | None, exclude: str | None = None) -> str | None:
|
|
273
|
+
if pref and pref in avail and pref != exclude:
|
|
274
|
+
return pref
|
|
275
|
+
for v in _FRONTIER_ORDER:
|
|
276
|
+
if v in avail and v != exclude:
|
|
277
|
+
return v
|
|
278
|
+
for v in avail:
|
|
279
|
+
if v != exclude:
|
|
280
|
+
return v
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
primary = pick(primary_pref)
|
|
284
|
+
secondary = pick(secondary_pref, exclude=primary)
|
|
285
|
+
return primary, secondary
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# vendor -> hosting jurisdiction (for egress/data-residency policy; threat model S5).
|
|
289
|
+
_VENDOR_JURISDICTION: dict[str, str] = {
|
|
290
|
+
"anthropic": "us", "openai": "us", "gemini": "us", "xai": "us",
|
|
291
|
+
"groq": "us", "together": "us", "openrouter": "us",
|
|
292
|
+
"mistral": "eu", "deepseek": "cn", "qwen": "cn", "ollama": "local",
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class PolicyError(RuntimeError):
|
|
297
|
+
"""Raised when the policy cannot be satisfied (e.g. independence=required,
|
|
298
|
+
but only one vendor is available)."""
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass
|
|
302
|
+
class Policy:
|
|
303
|
+
"""Declarative vendor policy (V3). Governs role->vendor assignment.
|
|
304
|
+
|
|
305
|
+
- ``vendors``: preference order (falls back to the frontier order).
|
|
306
|
+
- ``independence``: ``required`` (error if no distinct 2nd vendor) |
|
|
307
|
+
``preferred`` (use a distinct 2nd if available, else single-vendor) |
|
|
308
|
+
``off`` (single-vendor; don't seek a 2nd).
|
|
309
|
+
- ``avoid_jurisdictions``: drop vendors hosted in these (e.g. ``["cn"]``).
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
vendors: list[str] | None = None
|
|
313
|
+
independence: str = "preferred"
|
|
314
|
+
avoid_jurisdictions: list[str] = field(default_factory=list)
|
|
315
|
+
|
|
316
|
+
def _available(self, available: list[str]) -> list[str]:
|
|
317
|
+
avail = [v for v in available if v != "mock"]
|
|
318
|
+
if self.avoid_jurisdictions:
|
|
319
|
+
avail = [v for v in avail if _VENDOR_JURISDICTION.get(v) not in self.avoid_jurisdictions]
|
|
320
|
+
return avail
|
|
321
|
+
|
|
322
|
+
def resolve(
|
|
323
|
+
self, available: list[str], primary_pref: str | None = None, secondary_pref: str | None = None
|
|
324
|
+
) -> tuple[str | None, str | None]:
|
|
325
|
+
avail = self._available(available)
|
|
326
|
+
if not avail:
|
|
327
|
+
if self.independence == "required":
|
|
328
|
+
raise PolicyError("independence=required but no vendors are available")
|
|
329
|
+
return None, None
|
|
330
|
+
order = self.vendors or _FRONTIER_ORDER
|
|
331
|
+
|
|
332
|
+
def pick(pref: str | None, exclude: str | None = None) -> str | None:
|
|
333
|
+
if pref and pref in avail and pref != exclude:
|
|
334
|
+
return pref
|
|
335
|
+
for v in order:
|
|
336
|
+
if v in avail and v != exclude:
|
|
337
|
+
return v
|
|
338
|
+
for v in avail:
|
|
339
|
+
if v != exclude:
|
|
340
|
+
return v
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
primary = pick(primary_pref)
|
|
344
|
+
if self.independence == "off":
|
|
345
|
+
return primary, None
|
|
346
|
+
secondary = pick(secondary_pref, exclude=primary)
|
|
347
|
+
if self.independence == "required" and secondary is None:
|
|
348
|
+
raise PolicyError(
|
|
349
|
+
f"independence=required but only one vendor available ({primary}); "
|
|
350
|
+
"add a second vendor's key or set independence: preferred/off"
|
|
351
|
+
)
|
|
352
|
+
return primary, secondary
|