openprism 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.
openprism/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """OpenPrism — many diverse model voices, split and recombined into one judged answer.
2
+
3
+ A panel of models (your own provider keys, or any model in opencode) runs in
4
+ parallel; a judge model reconciles them. Two modes:
5
+ - research: Fusion-style synthesis (consensus / contradictions / gaps -> grounded answer)
6
+ - code: best-of-N selection + repair into one final solution
7
+ """
8
+
9
+ __version__ = "0.1.0"
openprism/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,10 @@
1
+ """Model backends — where Prism's panel calls actually go.
2
+
3
+ - DirectBackend: Prism's own OpenAI-compatible providers (keys in .env / providers.json).
4
+ Used by Claude Code and the standalone CLI, which have no provider registry to borrow.
5
+ - OpencodeBackend: piggybacks on a running opencode server — every provider/model the
6
+ user has configured/authed in opencode, with zero hardcoding. Used in opencode.
7
+ """
8
+ from .base import Backend, ModelInfo, get_backend
9
+
10
+ __all__ = ["Backend", "ModelInfo", "get_backend"]
@@ -0,0 +1,74 @@
1
+ """Backend interface + factory.
2
+
3
+ A backend turns a model reference into a completion. Model references are always
4
+ `provider/model`; because some model ids themselves contain slashes
5
+ (e.g. opencode's `requesty/xai/grok-4`), we split on the FIRST slash only.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass
14
+ class ModelInfo:
15
+ ref: str # full reference, e.g. "google/gemini-2.5-flash"
16
+ provider: str # provider id
17
+ model: str # model id (may contain slashes)
18
+ name: str = "" # human display name, if known
19
+ connected: bool = True
20
+
21
+
22
+ def split_ref(ref: str, default_provider: str = "") -> tuple[str, str]:
23
+ """`provider/model` -> (provider, model). Bare ref -> (default_provider, ref)."""
24
+ if "/" in ref:
25
+ provider, model = ref.split("/", 1)
26
+ return provider, model
27
+ return default_provider, ref
28
+
29
+
30
+ class Backend(ABC):
31
+ """Async backend. Implementations must be safe to call concurrently."""
32
+
33
+ name = "base"
34
+
35
+ @abstractmethod
36
+ async def list_models(self) -> list[ModelInfo]:
37
+ ...
38
+
39
+ @abstractmethod
40
+ async def complete(
41
+ self, model_ref: str, prompt: str, system: str | None, max_tokens: int,
42
+ tools: dict | None = None,
43
+ ) -> tuple[str, int]:
44
+ """Return (text, total_tokens). Raise on failure — the panel layer
45
+ catches per-model so one failure never sinks the run. `tools` is a
46
+ name->bool map for backends that support tool use (opencode); backends
47
+ that can't (direct raw completions) ignore it."""
48
+ ...
49
+
50
+ async def default_panel(self, mode: str) -> list[str]:
51
+ """Models to use when the caller gives no explicit panel. Default: the
52
+ configured preset for `mode`. opencode overrides this dynamically."""
53
+ from .. import config
54
+
55
+ return config.resolve_panel(mode if mode in config.PANELS else None)
56
+
57
+ async def aclose(self) -> None:
58
+ pass
59
+
60
+
61
+ def get_backend(name: str | None = None) -> Backend:
62
+ """Factory. `name` overrides config.BACKEND."""
63
+ from .. import config
64
+
65
+ backend = (name or config.BACKEND).lower()
66
+ if backend == "opencode":
67
+ from .opencode import OpencodeBackend
68
+
69
+ return OpencodeBackend()
70
+ if backend == "direct":
71
+ from .direct import DirectBackend
72
+
73
+ return DirectBackend()
74
+ raise config.PrismError(f"Unknown OPENPRISM_BACKEND: {backend!r} (use 'direct' or 'opencode').")
@@ -0,0 +1,77 @@
1
+ """DirectBackend — Prism's own OpenAI-compatible providers.
2
+
3
+ Providers come from config.load_providers() (the Alibaba Coding Plan from .env by
4
+ default, plus anything in providers.json). Used by Claude Code and the CLI, which
5
+ have no host provider registry to borrow. A bare model id (no slash) resolves to
6
+ the default provider, preserving the original single-provider behaviour.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from openai import AsyncOpenAI
11
+
12
+ from .. import config
13
+ from .base import Backend, ModelInfo, split_ref
14
+
15
+
16
+ class DirectBackend(Backend):
17
+ name = "direct"
18
+
19
+ def __init__(self) -> None:
20
+ self.providers = config.load_providers()
21
+ if not self.providers:
22
+ raise config.PrismError(
23
+ "No providers configured. Set ALIBABA_API_KEY in .env (or add "
24
+ "providers.json), or use OPENPRISM_BACKEND=opencode."
25
+ )
26
+ self.default_provider = config.DEFAULT_PROVIDER or next(iter(self.providers))
27
+ if self.default_provider not in self.providers:
28
+ import sys
29
+
30
+ fallback = next(iter(self.providers))
31
+ print(f"openprism: OPENPRISM_DEFAULT_PROVIDER={self.default_provider!r} is not "
32
+ f"configured; using {fallback!r} for bare model ids", file=sys.stderr)
33
+ self.default_provider = fallback
34
+ self._clients: dict[str, AsyncOpenAI] = {}
35
+
36
+ def _client(self, provider_id: str) -> AsyncOpenAI:
37
+ if provider_id not in self.providers:
38
+ raise config.PrismError(
39
+ f"Provider {provider_id!r} not configured. Known: {list(self.providers)}"
40
+ )
41
+ if provider_id not in self._clients:
42
+ p = self.providers[provider_id]
43
+ self._clients[provider_id] = AsyncOpenAI(api_key=p.api_key, base_url=p.base_url)
44
+ return self._clients[provider_id]
45
+
46
+ async def list_models(self) -> list[ModelInfo]:
47
+ out: list[ModelInfo] = []
48
+ for pid, p in self.providers.items():
49
+ for m in p.models:
50
+ ref = m if pid == self.default_provider else f"{pid}/{m}"
51
+ out.append(ModelInfo(ref=ref, provider=pid, model=m))
52
+ return out
53
+
54
+ async def complete(
55
+ self, model_ref: str, prompt: str, system: str | None, max_tokens: int,
56
+ tools: dict | None = None,
57
+ ) -> tuple[str, int]:
58
+ # `tools` is ignored: direct providers are plain completions. Panelist tool
59
+ # use (web/fetch) requires the opencode backend.
60
+ provider_id, model = split_ref(model_ref, self.default_provider)
61
+ client = self._client(provider_id)
62
+ messages = ([{"role": "system", "content": system}] if system else []) + [
63
+ {"role": "user", "content": prompt}
64
+ ]
65
+ resp = await client.chat.completions.create(
66
+ model=model, messages=messages, max_tokens=max_tokens
67
+ )
68
+ if not getattr(resp, "choices", None):
69
+ raise RuntimeError(f"{model}: provider returned no choices")
70
+ text = resp.choices[0].message.content or ""
71
+ usage = getattr(resp, "usage", None)
72
+ tokens = getattr(usage, "total_tokens", 0) if usage else 0
73
+ return text, tokens
74
+
75
+ async def aclose(self) -> None:
76
+ for client in self._clients.values():
77
+ await client.close()
@@ -0,0 +1,255 @@
1
+ """OpencodeBackend — piggyback on opencode's provider layer.
2
+
3
+ Talks to a local opencode HTTP server. Every provider/model the user has
4
+ configured/authed in opencode is available, discovered live from `/provider` —
5
+ NO hardcoded providers or models. opencode does all auth; Prism never sees keys.
6
+
7
+ Server resolution:
8
+ 1. OPENPRISM_OPENCODE_URL if set.
9
+ 2. otherwise http://127.0.0.1:<OPENPRISM_OPENCODE_PORT or 4096>; if nothing is
10
+ listening there and autoserve is on (default), spawn `opencode serve` and
11
+ wait for it — reusing the user's existing auth.json.
12
+
13
+ A completion = POST /session then POST /session/:id/message with tools disabled,
14
+ which returns a clean (non-agentic) model answer. Each panelist gets its own
15
+ session so panelists never see each other's context.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import atexit
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ import tempfile
24
+ import threading
25
+ import time
26
+ from urllib.parse import urlsplit
27
+
28
+ import httpx
29
+
30
+ from .. import config
31
+ from .base import Backend, ModelInfo, split_ref
32
+
33
+
34
+ def _find_opencode() -> str | None:
35
+ """Locate the opencode binary. The MCP server is launched by a host with a
36
+ minimal PATH that often misses bun/npm shims, so check common install dirs."""
37
+ exe = shutil.which("opencode")
38
+ if exe:
39
+ return exe
40
+ candidates = [
41
+ "~/.opencode/bin/opencode", "~/.bun/bin/opencode", "~/.local/bin/opencode",
42
+ "/usr/local/bin/opencode", "/opt/homebrew/bin/opencode",
43
+ ]
44
+ for c in candidates:
45
+ p = os.path.expanduser(c)
46
+ if os.path.exists(p):
47
+ return p
48
+ return None
49
+
50
+
51
+ # A server we spawn ourselves is shared across backend instances (the MCP server
52
+ # builds a fresh backend per tool call) and torn down once at process exit — so we
53
+ # don't spawn/kill a server on every call or leak orphans.
54
+ _SHARED_SERVER: subprocess.Popen | None = None
55
+ _SPAWN_LOCK = threading.Lock()
56
+
57
+
58
+ def _shutdown_shared_server() -> None:
59
+ global _SHARED_SERVER
60
+ if _SHARED_SERVER is not None:
61
+ _SHARED_SERVER.terminate()
62
+ try:
63
+ _SHARED_SERVER.wait(timeout=5)
64
+ except Exception: # noqa: BLE001
65
+ _SHARED_SERVER.kill()
66
+ _SHARED_SERVER = None
67
+
68
+
69
+ atexit.register(_shutdown_shared_server)
70
+
71
+
72
+ class OpencodeBackend(Backend):
73
+ name = "opencode"
74
+
75
+ def __init__(self) -> None:
76
+ self.base_url = config.OPENCODE_URL.rstrip("/")
77
+ scheme = urlsplit(self.base_url).scheme
78
+ if scheme not in ("http", "https"):
79
+ raise config.PrismError(
80
+ f"OPENPRISM_OPENCODE_URL must be http(s), got {self.base_url!r}."
81
+ )
82
+ self._client = httpx.AsyncClient(
83
+ base_url=self.base_url,
84
+ timeout=httpx.Timeout(config.PANEL_TIMEOUT, connect=10.0),
85
+ auth=(("opencode", config.OPENCODE_PASSWORD) if config.OPENCODE_PASSWORD else None),
86
+ )
87
+ self._ensure_server()
88
+
89
+ @property
90
+ def _auth(self):
91
+ return ("opencode", config.OPENCODE_PASSWORD) if config.OPENCODE_PASSWORD else None
92
+
93
+ # --- server lifecycle ---
94
+ def _reachable(self) -> bool:
95
+ try:
96
+ with httpx.Client(base_url=self.base_url, timeout=2.0, auth=self._auth) as c:
97
+ return c.get("/provider").status_code == 200
98
+ except Exception: # noqa: BLE001
99
+ return False
100
+
101
+ def _ensure_server(self) -> None:
102
+ # Use an already-running server (the user's TUI/serve, or one we spawned
103
+ # earlier this process) if reachable at the configured URL.
104
+ if self._reachable():
105
+ return
106
+ if not config.OPENCODE_AUTOSERVE:
107
+ raise config.PrismError(
108
+ f"No opencode server reachable at {self.base_url} and autoserve is off. "
109
+ "Start `opencode serve` or set OPENPRISM_OPENCODE_URL."
110
+ )
111
+ global _SHARED_SERVER
112
+ port = urlsplit(self.base_url).port
113
+ if not port:
114
+ raise config.PrismError(
115
+ f"Can't derive a port from OPENPRISM_OPENCODE_URL={self.base_url!r} to autospawn; "
116
+ "set OPENPRISM_OPENCODE_PORT or point at a running server."
117
+ )
118
+ exe = _find_opencode()
119
+ if not exe:
120
+ raise config.PrismError(
121
+ "`opencode` binary not found (checked PATH, ~/.opencode/bin, ~/.bun/bin, "
122
+ "etc.) — install opencode or set OPENPRISM_OPENCODE_URL to a running server."
123
+ )
124
+ # Serialise the check-then-spawn so concurrent backends don't race to start
125
+ # two servers on the same port.
126
+ with _SPAWN_LOCK:
127
+ if self._reachable(): # another thread may have started it
128
+ return
129
+ # Capture stderr so a startup failure (port clash, bad argv, crash) has
130
+ # a diagnostic instead of vanishing into DEVNULL.
131
+ log = tempfile.NamedTemporaryFile(
132
+ prefix="openprism-opencode-", suffix=".log", mode="w+", delete=False
133
+ )
134
+ # Spawn once per process; persists for reuse, cleaned up at exit.
135
+ _SHARED_SERVER = subprocess.Popen(
136
+ [exe, "serve", "--port", str(port)],
137
+ stdout=subprocess.DEVNULL, stderr=log,
138
+ )
139
+ for _ in range(40): # ~20s
140
+ if self._reachable():
141
+ return
142
+ time.sleep(0.5)
143
+ try:
144
+ log.flush()
145
+ log.seek(0)
146
+ tail = log.read()[-800:].strip()
147
+ except Exception: # noqa: BLE001
148
+ tail = ""
149
+ raise config.PrismError(
150
+ f"Spawned `opencode serve` but it never came up on {self.base_url}."
151
+ + (f"\n--- opencode stderr ---\n{tail}" if tail else "")
152
+ )
153
+
154
+ # --- Backend interface ---
155
+ async def list_models(self) -> list[ModelInfo]:
156
+ r = await self._client.get("/provider")
157
+ r.raise_for_status()
158
+ data = r.json()
159
+ connected = set(data.get("connected", []))
160
+ out: list[ModelInfo] = []
161
+ for p in data.get("all", []):
162
+ pid = p["id"]
163
+ for mid, m in (p.get("models") or {}).items():
164
+ out.append(ModelInfo(
165
+ ref=f"{pid}/{mid}", provider=pid, model=mid,
166
+ name=(m or {}).get("name", ""), connected=pid in connected,
167
+ ))
168
+ return out
169
+
170
+ # Model id/name substrings that mark a non-chat model (skip in auto panels).
171
+ _NON_CHAT = (
172
+ "embed", "embedding", "rerank", "whisper", "tts", "image", "video",
173
+ "ocr", "guard", "moderation",
174
+ )
175
+
176
+ async def default_panel(self, mode: str) -> list[str]:
177
+ """Pick a diverse default: one chat model from each of up to 4 distinct
178
+ model FAMILIES (not just providers) — so the panel is genuinely diverse,
179
+ never two models from the same family. The user normally specifies their own."""
180
+ models = [m for m in await self.list_models() if m.connected]
181
+
182
+ def is_chat(m: ModelInfo) -> bool:
183
+ s = f"{m.model} {m.name}".lower()
184
+ return not any(k in s for k in self._NON_CHAT)
185
+
186
+ picked: list[str] = []
187
+ seen_families: set[str] = set()
188
+ for m in models:
189
+ if not is_chat(m):
190
+ continue
191
+ fam = config.model_family(m.ref)
192
+ if fam in seen_families:
193
+ continue
194
+ seen_families.add(fam)
195
+ picked.append(m.ref)
196
+ if len(picked) >= 4:
197
+ break
198
+ if not picked:
199
+ raise config.PrismError(
200
+ "No connected chat models in opencode to build a default panel — "
201
+ "specify a panel of provider/model refs."
202
+ )
203
+ return picked
204
+
205
+ async def complete(
206
+ self, model_ref: str, prompt: str, system: str | None, max_tokens: int,
207
+ tools: dict | None = None,
208
+ ) -> tuple[str, int]:
209
+ provider_id, model = split_ref(model_ref)
210
+ if not provider_id:
211
+ raise config.PrismError(
212
+ f"opencode model ref must be 'provider/model', got {model_ref!r}."
213
+ )
214
+ sess = await self._client.post("/session", json={"title": "openprism"})
215
+ sess.raise_for_status()
216
+ sid = sess.json().get("id")
217
+ if not sid:
218
+ raise RuntimeError(f"opencode/{model_ref}: session create returned no id ({sess.text[:200]})")
219
+ # opencode enables ALL tools by default; `tools` is an explicit name->bool
220
+ # allow/deny map. None = omit = opencode defaults (every tool). The panel
221
+ # layer passes a restricted map (web + read-only) so panelists can browse
222
+ # but cannot run bash/edit/write. Each panelist gets its own session.
223
+ body = {
224
+ "model": {"providerID": provider_id, "modelID": model},
225
+ "parts": [{"type": "text", "text": prompt}],
226
+ }
227
+ if tools is not None:
228
+ body["tools"] = tools
229
+ if system:
230
+ body["system"] = system
231
+ try:
232
+ r = await self._client.post(f"/session/{sid}/message", json=body)
233
+ r.raise_for_status()
234
+ d = r.json()
235
+ finally:
236
+ # Don't litter the user's opencode history with a session per panelist.
237
+ try:
238
+ await self._client.delete(f"/session/{sid}")
239
+ except Exception: # noqa: BLE001 — best-effort cleanup
240
+ pass
241
+ info = d.get("info", {})
242
+ err = info.get("error")
243
+ if err:
244
+ msg = (err.get("data") or {}).get("message") or err.get("name") or "unknown error"
245
+ raise RuntimeError(f"opencode/{model_ref}: {msg}")
246
+ text = "".join(
247
+ p.get("text", "") for p in d.get("parts", []) if p.get("type") == "text"
248
+ ).strip()
249
+ tokens = (info.get("tokens") or {}).get("total", 0)
250
+ return text, tokens
251
+
252
+ async def aclose(self) -> None:
253
+ # Only close this instance's HTTP client. Any server we spawned is shared
254
+ # and lives until process exit (see _shutdown_shared_server / atexit).
255
+ await self._client.aclose()
openprism/cli.py ADDED
@@ -0,0 +1,106 @@
1
+ """Prism CLI.
2
+
3
+ openprism "question" # research synthesis (default 4-house panel)
4
+ openprism "task" --mode code # best-of-N + repair (coder panel)
5
+ openprism "q" --panel research-lean # use a preset
6
+ openprism "q" --panel qwen3.7-plus,glm-5 # ad-hoc panel
7
+ openprism --bakeoff qwen3.7-plus qwen3-max-2026-01-23 "your test prompt"
8
+ openprism --list # show presets + known models
9
+ """
10
+ import argparse
11
+ import sys
12
+
13
+ from . import config
14
+ from .config import PrismError
15
+ from .pipeline import bakeoff, run
16
+
17
+ # Windows consoles default to cp1252 and choke on model output / glyphs.
18
+ try:
19
+ sys.stdout.reconfigure(encoding="utf-8")
20
+ sys.stderr.reconfigure(encoding="utf-8")
21
+ except (AttributeError, ValueError):
22
+ pass
23
+
24
+
25
+ def _print_result(result) -> None:
26
+ print("\n" + "=" * 70)
27
+ print(f"OPENPRISM | mode={result.mode} | judge={result.judge_backend}")
28
+ print(result.status_line())
29
+ print("Panel:")
30
+ print(result.panel_summary())
31
+ print("=" * 70 + "\n")
32
+ print(result.final)
33
+ print()
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> int:
37
+ argv = sys.argv[1:] if argv is None else argv
38
+ if argv and argv[0] == "doctor":
39
+ from .doctor import run_doctor
40
+
41
+ return run_doctor()
42
+ if argv and argv[0] == "init":
43
+ from .init_cmd import run_init
44
+
45
+ ip = argparse.ArgumentParser(prog="openprism init",
46
+ description="Generate MCP config for a host.")
47
+ ip.add_argument("--host", help="claude-code | opencode | cursor | windsurf | gemini | codex")
48
+ ip.add_argument("--backend", default="opencode", choices=["direct", "opencode"])
49
+ ip.add_argument("--judge-backend", dest="judge_backend", default=None)
50
+ ip.add_argument("--judge-model", dest="judge_model", default=None)
51
+ ip.add_argument("--local", action="store_true",
52
+ help="launch from this checkout instead of uvx")
53
+ ip.add_argument("--pypi", action="store_true",
54
+ help="emit the published-package form (after PyPI publish) instead of uvx-from-git")
55
+ ip.add_argument("--ref", default=None,
56
+ help="pin the uvx-from-git launch to a tag/branch/sha (recommended)")
57
+ ip.add_argument("--write", action="store_true", help="merge into the host's config file")
58
+ ip.add_argument("--print", action="store_true", help="print the config block (default)")
59
+ return run_init(ip.parse_args(argv[1:]))
60
+
61
+ p = argparse.ArgumentParser(prog="openprism", description="Multi-model panel + judge.")
62
+ p.add_argument("question", nargs="*", help="the question / task")
63
+ p.add_argument("--mode", choices=["research", "code"], default="research")
64
+ p.add_argument("--panel", help="preset name or comma-separated model ids")
65
+ p.add_argument("--bakeoff", nargs=2, metavar=("MODEL_A", "MODEL_B"),
66
+ help="compare two models on the question; judge picks a winner")
67
+ p.add_argument("--list", action="store_true", help="list presets and known models")
68
+ args = p.parse_args(argv)
69
+
70
+ if args.list:
71
+ print("Panel presets:")
72
+ for name, models in config.PANELS.items():
73
+ print(f" {name:<16} {', '.join(models)}")
74
+ print("\nKnown models:")
75
+ for m in config.KNOWN_MODELS:
76
+ print(f" {m}")
77
+ return 0
78
+
79
+ question = " ".join(args.question).strip()
80
+ if not question:
81
+ p.error("no question given")
82
+
83
+ try:
84
+ if args.bakeoff:
85
+ a, b = args.bakeoff
86
+ verdict, responses = bakeoff(question, a, b)
87
+ print("\n" + "=" * 70)
88
+ print(f"BAKE-OFF | {a} vs {b}")
89
+ for r in responses:
90
+ tag = f"{r.latency:.1f}s" if r.ok else f"FAILED: {r.error}"
91
+ print(f" {r.model:<22} {tag}")
92
+ print("=" * 70 + "\n")
93
+ print(verdict)
94
+ print()
95
+ return 0
96
+
97
+ result = run(question, mode=args.mode, panel_spec=args.panel)
98
+ _print_result(result)
99
+ return 0
100
+ except PrismError as e:
101
+ print(f"openprism: {e}", file=sys.stderr)
102
+ return 1
103
+
104
+
105
+ if __name__ == "__main__":
106
+ sys.exit(main())