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.
Files changed (46) hide show
  1. rapier/__init__.py +70 -0
  2. rapier/_json.py +39 -0
  3. rapier/cli.py +244 -0
  4. rapier/convergence.py +93 -0
  5. rapier/envelope.py +66 -0
  6. rapier/ledger.py +71 -0
  7. rapier/manifest.py +75 -0
  8. rapier/mcp/__init__.py +9 -0
  9. rapier/mcp/server.py +184 -0
  10. rapier/mcp/tools.py +125 -0
  11. rapier/models.py +352 -0
  12. rapier/onboarding.py +128 -0
  13. rapier/pipeline.py +145 -0
  14. rapier/presets.py +83 -0
  15. rapier/secrets.py +72 -0
  16. rapier/stage.py +98 -0
  17. rapier/stages/__init__.py +6 -0
  18. rapier/stages/echo.py +28 -0
  19. rapier/stages/proposer/__init__.py +7 -0
  20. rapier/stages/proposer/phase_stages.py +101 -0
  21. rapier/stages/proposer/phases.py +201 -0
  22. rapier/stages/resolver/__init__.py +22 -0
  23. rapier/stages/resolver/_binding.py +33 -0
  24. rapier/stages/resolver/_extract.py +82 -0
  25. rapier/stages/resolver/anchored_fix.py +52 -0
  26. rapier/stages/resolver/author.py +62 -0
  27. rapier/stages/resolver/citation_gate.py +123 -0
  28. rapier/stages/resolver/compose.py +457 -0
  29. rapier/stages/resolver/cross_review.py +45 -0
  30. rapier/stages/resolver/definitiveness_gate.py +55 -0
  31. rapier/verify/__init__.py +4 -0
  32. rapier/verify/_bootstrap.py +66 -0
  33. rapier/verify/_llm_shim.py +131 -0
  34. rapier/verify/_vendor/PROVENANCE.md +14 -0
  35. rapier/verify/_vendor/cite_check.py +1093 -0
  36. rapier/verify/_vendor/lib_llm.py +288 -0
  37. rapier/verify/_vendor/spar_cross_review.py +296 -0
  38. rapier/verify/_vendor/spar_definitiveness_gate.py +770 -0
  39. rapier/verify/_vendor/spar_verify_gate.py +233 -0
  40. rapier/verify/_vendor/verify_grounding.py +795 -0
  41. rapier/verify/service.py +46 -0
  42. rapier_runtime-0.1.0.dist-info/METADATA +249 -0
  43. rapier_runtime-0.1.0.dist-info/RECORD +46 -0
  44. rapier_runtime-0.1.0.dist-info/WHEEL +4 -0
  45. rapier_runtime-0.1.0.dist-info/entry_points.txt +2 -0
  46. 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