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 +9 -0
- openprism/__main__.py +6 -0
- openprism/backends/__init__.py +10 -0
- openprism/backends/base.py +74 -0
- openprism/backends/direct.py +77 -0
- openprism/backends/opencode.py +255 -0
- openprism/cli.py +106 -0
- openprism/config.py +224 -0
- openprism/doctor.py +88 -0
- openprism/init_cmd.py +154 -0
- openprism/judge.py +81 -0
- openprism/mcp_server.py +121 -0
- openprism/panel.py +48 -0
- openprism/pipeline.py +133 -0
- openprism/prompts.py +104 -0
- openprism/py.typed +0 -0
- openprism-0.1.0.dist-info/METADATA +240 -0
- openprism-0.1.0.dist-info/RECORD +22 -0
- openprism-0.1.0.dist-info/WHEEL +5 -0
- openprism-0.1.0.dist-info/entry_points.txt +3 -0
- openprism-0.1.0.dist-info/licenses/LICENSE +21 -0
- openprism-0.1.0.dist-info/top_level.txt +1 -0
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,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())
|