subcortex 0.3.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 (64) hide show
  1. subcortex/__init__.py +3 -0
  2. subcortex/__main__.py +3 -0
  3. subcortex/adapters/__init__.py +48 -0
  4. subcortex/adapters/base.py +230 -0
  5. subcortex/adapters/claude_family.py +133 -0
  6. subcortex/adapters/codex.py +87 -0
  7. subcortex/adapters/copilot.py +60 -0
  8. subcortex/adapters/cursor.py +36 -0
  9. subcortex/adapters/docker_agent.py +115 -0
  10. subcortex/adapters/gemini_family.py +60 -0
  11. subcortex/adapters/grok.py +98 -0
  12. subcortex/adapters/kimi_code.py +138 -0
  13. subcortex/adapters/letta_vibe.py +96 -0
  14. subcortex/adapters/openhands.py +153 -0
  15. subcortex/auth.py +59 -0
  16. subcortex/backends/__init__.py +23 -0
  17. subcortex/backends/base.py +22 -0
  18. subcortex/backends/jev.py +460 -0
  19. subcortex/backends/laya.py +149 -0
  20. subcortex/cli.py +809 -0
  21. subcortex/client.py +77 -0
  22. subcortex/config.py +263 -0
  23. subcortex/daemon.py +502 -0
  24. subcortex/evalset.py +241 -0
  25. subcortex/hook.py +254 -0
  26. subcortex/installers/__init__.py +62 -0
  27. subcortex/installers/amp.py +39 -0
  28. subcortex/installers/base.py +874 -0
  29. subcortex/installers/claude_family.py +229 -0
  30. subcortex/installers/codex.py +110 -0
  31. subcortex/installers/copilot.py +65 -0
  32. subcortex/installers/crush.py +36 -0
  33. subcortex/installers/cursor.py +79 -0
  34. subcortex/installers/gemini_family.py +83 -0
  35. subcortex/installers/goose.py +186 -0
  36. subcortex/installers/kimi_code.py +71 -0
  37. subcortex/installers/mcp_only.py +111 -0
  38. subcortex/installers/more_hooks.py +184 -0
  39. subcortex/installers/opencode.py +66 -0
  40. subcortex/installers/openhands.py +84 -0
  41. subcortex/installers/pi_cline.py +53 -0
  42. subcortex/ledger.py +92 -0
  43. subcortex/localhttp.py +59 -0
  44. subcortex/mcp_server.py +187 -0
  45. subcortex/metrics.py +56 -0
  46. subcortex/plugins/amp/subcortex.ts +258 -0
  47. subcortex/plugins/cline/subcortex.ts +340 -0
  48. subcortex/plugins/opencode/subcortex.ts +265 -0
  49. subcortex/plugins/pi/subcortex.ts +292 -0
  50. subcortex/policy.py +341 -0
  51. subcortex/presets.py +163 -0
  52. subcortex/provision.py +188 -0
  53. subcortex/service.py +149 -0
  54. subcortex/state.py +137 -0
  55. subcortex/transcript.py +211 -0
  56. subcortex/tuis.py +51 -0
  57. subcortex/ui.py +319 -0
  58. subcortex/verdicts.py +233 -0
  59. subcortex/wizard.py +474 -0
  60. subcortex-0.3.0.dist-info/METADATA +287 -0
  61. subcortex-0.3.0.dist-info/RECORD +64 -0
  62. subcortex-0.3.0.dist-info/WHEEL +5 -0
  63. subcortex-0.3.0.dist-info/entry_points.txt +3 -0
  64. subcortex-0.3.0.dist-info/top_level.txt +1 -0
subcortex/auth.py ADDED
@@ -0,0 +1,59 @@
1
+ """The daemon's per-user token.
2
+
3
+ The daemon listens on 127.0.0.1, which every local user can reach. Requests
4
+ must carry the token stored in a 0600 file inside the user's private data dir,
5
+ so only this user's own processes can use the daemon (read snapshots, plant
6
+ context, spend Jev credits). Web pages are refused before that, by Host,
7
+ Origin and Content-Type checks in the daemon.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import secrets
14
+ from pathlib import Path
15
+
16
+ from .config import data_dir
17
+
18
+ HEADER = "X-Subcortex-Token"
19
+
20
+
21
+ def token_path() -> Path:
22
+ return data_dir() / "token"
23
+
24
+
25
+ def read_token() -> str:
26
+ try:
27
+ token = token_path().read_text(encoding="ascii").strip()
28
+ except (OSError, UnicodeDecodeError):
29
+ return ""
30
+ # It goes into a header: anything but hex is a damaged file, not a token.
31
+ return token if token and all(c in "0123456789abcdef" for c in token) else ""
32
+
33
+
34
+ def ensure_token() -> str:
35
+ """The token, created (0600, atomically, first writer wins) if missing."""
36
+ existing = read_token()
37
+ if len(existing) >= 32:
38
+ return existing
39
+ path = token_path()
40
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
41
+ tmp = path.with_name(f".token-{os.getpid()}-{secrets.token_hex(4)}")
42
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
43
+ with os.fdopen(fd, "w", encoding="ascii") as fh:
44
+ fh.write(secrets.token_hex(32))
45
+ try:
46
+ if path.exists():
47
+ os.replace(tmp, path) # present but unusable (empty, truncated)
48
+ else:
49
+ os.link(tmp, path) # fails if another daemon created it meanwhile: use theirs
50
+ except FileExistsError:
51
+ pass
52
+ except OSError:
53
+ os.replace(tmp, path)
54
+ finally:
55
+ try:
56
+ os.unlink(tmp)
57
+ except OSError:
58
+ pass
59
+ return read_token()
@@ -0,0 +1,23 @@
1
+ """Backend factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+ from .base import DecisionBackend
8
+
9
+ BACKENDS = ("laya", "jev")
10
+
11
+
12
+ def get_backend(config: Dict[str, Any], override: Optional[str] = None) -> DecisionBackend:
13
+ """Instantiate the configured backend (models/connections load lazily)."""
14
+ name = str(override or config.get("backend") or "laya").strip().lower()
15
+ if name == "laya":
16
+ from .laya import LayaBackend
17
+
18
+ return LayaBackend(config)
19
+ if name == "jev":
20
+ from .jev import JevBackend
21
+
22
+ return JevBackend(config)
23
+ raise ValueError(f"Unknown backend {name!r}; valid: {', '.join(BACKENDS)}")
@@ -0,0 +1,22 @@
1
+ """Backend protocol shared by all subcortex decision backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Protocol, Tuple
6
+
7
+
8
+ class DecisionBackend(Protocol):
9
+ """A typed-decision backend.
10
+
11
+ ``predict`` runs one typed-decision call and returns the raw result dict
12
+ (with an ``"answers"`` key). ``available`` reports whether the backend can
13
+ be used right now, with a human-readable reason/fix when not.
14
+ """
15
+
16
+ name: str
17
+
18
+ def predict(self, state: Any, questions: Dict[str, Any]) -> Dict[str, Any]:
19
+ ...
20
+
21
+ def available(self) -> Tuple[bool, str]:
22
+ ...
@@ -0,0 +1,460 @@
1
+ """Jev backend: hosted typed-decision API client (TypeSafe System One, v1).
2
+
3
+ Pure stdlib (``http.client`` with kept-alive connections), zero dependencies. Wire shape, as specified by the
4
+ TypeSafe OpenAPI schema and ``typesafe-sdk``: ``POST {base}/v1/systemone`` with
5
+ ``{"model", "state", "questions"}`` returns ``{"model", "answers", "usage"}``;
6
+ every answer carries its ``type``, and ``usage.input_tokens`` is what is billed.
7
+
8
+ Endpoints that speak it: TypeSafe (``https://api.typesafe.ai``, key in
9
+ ``TYPESAFE_API_KEY``, model ``jev-latest``), OpenRouter (``https://openrouter.ai/api``,
10
+ ``OPENROUTER_API_KEY``) and the Vercel AI Gateway
11
+ (``https://ai-gateway.vercel.sh/typesafe``, model ``typesafe-ai/jev``). A base
12
+ URL is used as pasted from their docs; older ``.../v1`` + ``/systemone`` configs
13
+ keep working.
14
+
15
+ Fail-closed URL policy: https anywhere, cleartext http only for
16
+ loopback/private LAN addresses, no userinfo, no redirects (a redirect would
17
+ carry the bearer key somewhere unvetted), response capped at 1 MB, endpoint
18
+ path restricted to ``[A-Za-z0-9/._-~]+``. No retries: a decision that isn't
19
+ back within the hook budget is worthless, so it fails open instead.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import http.client
25
+ import ipaddress
26
+ import json
27
+ import math
28
+ import re
29
+ import ssl
30
+ import threading
31
+ import time
32
+ from typing import Any, Callable, Dict, Optional, Tuple
33
+ from urllib.parse import urlsplit, urlunsplit
34
+
35
+ from .. import __version__
36
+ from ..config import read_secret
37
+ from ..metrics import METRICS
38
+
39
+ Transport = Callable[[str, bytes, Dict[str, str], float], Tuple[int, str]]
40
+
41
+ DEFAULT_BASE_URL = "https://api.typesafe.ai"
42
+ DEFAULT_ENDPOINT_PATH = "/v1/systemone"
43
+ DEFAULT_API_KEY_ENV = "TYPESAFE_API_KEY"
44
+ DEFAULT_MODEL = "jev-latest"
45
+ # Typical decisions take ~100 ms; hooks give up after hooks.http_timeout_s (3 s).
46
+ DEFAULT_TIMEOUT = 2.5
47
+ # USD per input token (output tokens are free), per docs.typesafe.ai/models.
48
+ PRICE_PER_INPUT_TOKEN = 0.042e-6
49
+
50
+ # Answers are small probability maps (~hundreds of bytes/question). Anything
51
+ # past this is a broken/compromised endpoint — fail closed.
52
+ MAX_RESPONSE_BYTES = 1_000_000
53
+
54
+
55
+ class BackendUnavailableError(RuntimeError):
56
+ """The Jev API is unreachable, misconfigured, or answered garbage."""
57
+
58
+
59
+ # -- URL policy ----------------------------------------------------------------
60
+
61
+
62
+ def _is_loopback_host(host: str) -> bool:
63
+ # localhost is the one permitted DNS alias; canonical v4/v6 loopbacks match
64
+ # via is_loopback — but NOT v4-mapped ::ffff:127.x.
65
+ if host == "localhost":
66
+ return True
67
+ try:
68
+ ip = ipaddress.ip_address(host)
69
+ except ValueError:
70
+ return False
71
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
72
+ return False
73
+ return ip.is_loopback
74
+
75
+
76
+ # Cleartext http is allowed exactly for loopback + these non-routed ranges:
77
+ # RFC1918 LANs, Tailscale CGNAT (100.64/10 — NOT covered by is_private), ULA +
78
+ # link-local IPv6. Checked against explicit networks, never is_private — that
79
+ # flag also matches 169.254/16 link-local, i.e. the cloud metadata endpoint.
80
+ _CLEAR_NETWORKS_V4 = tuple(
81
+ ipaddress.ip_network(c)
82
+ for c in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "127.0.0.0/8")
83
+ )
84
+ _CLEAR_NETWORKS_V6 = tuple(
85
+ ipaddress.ip_network(c) for c in ("::1/128", "fc00::/7", "fe80::/10", "fec0::/10")
86
+ )
87
+
88
+
89
+ def _allows_cleartext(host: str) -> bool:
90
+ if _is_loopback_host(host):
91
+ return True
92
+ try:
93
+ ip = ipaddress.ip_address(host)
94
+ except ValueError:
95
+ return False # DNS names must use https — no resolution at request time
96
+ nets = _CLEAR_NETWORKS_V6 if ip.version == 6 else _CLEAR_NETWORKS_V4
97
+ return any(ip in net for net in nets)
98
+
99
+
100
+ def _check_url(url: str) -> Tuple[str, str]:
101
+ """Fail-closed URL policy: https anywhere, http LAN-only, no userinfo."""
102
+ try:
103
+ parts = urlsplit(url)
104
+ except ValueError:
105
+ raise BackendUnavailableError("invalid jev url") from None
106
+ scheme, host = parts.scheme.lower(), (parts.hostname or "").lower()
107
+ if scheme not in {"http", "https"}:
108
+ raise BackendUnavailableError(f"refusing non-http jev url: {scheme or '(none)'}")
109
+ # "@" anywhere in netloc means userinfo was present — even an empty
110
+ # username risks parser/request-library disagreement downstream.
111
+ if "@" in parts.netloc:
112
+ raise BackendUnavailableError("refusing jev url with embedded credentials")
113
+ if scheme == "http" and not _allows_cleartext(host):
114
+ raise BackendUnavailableError(
115
+ f"refusing cleartext jev url for non-LAN host: {host or '(none)'}"
116
+ )
117
+ return scheme, host
118
+
119
+
120
+ # Conservative endpoint-path charset: percent-escapes like %2f/%3f and unicode
121
+ # look-alikes must not reach the URL — a proxy/router could reinterpret them as
122
+ # delimiters. Real decisions paths (/systemone, /api/alpha/decisions) are all
123
+ # in this set.
124
+ _ENDPOINT_PATH_CHARS = re.compile(r"[A-Za-z0-9/._\-~]+")
125
+
126
+
127
+ def _normalize_endpoint_path(endpoint_path: str) -> str:
128
+ """Validate a configured endpoint path (fail closed to default).
129
+
130
+ The path is operator config, not remote input — but it flows into the
131
+ request URL, so keep it to a plain absolute path of unreserved chars. No
132
+ scheme, host, query, fragment, credentials, percent-escapes, or non-ASCII
133
+ bytes. Anything else degrades to the default path.
134
+ """
135
+ candidate = (endpoint_path or "").strip()
136
+ if not candidate.startswith("/") or " " in candidate:
137
+ return DEFAULT_ENDPOINT_PATH
138
+ try:
139
+ parts = urlsplit(candidate)
140
+ except ValueError:
141
+ return DEFAULT_ENDPOINT_PATH
142
+ if parts.scheme or parts.netloc or parts.query or parts.fragment or "@" in candidate:
143
+ return DEFAULT_ENDPOINT_PATH
144
+ # Match against the raw candidate, not the parsed path: urlsplit strips
145
+ # ASCII newlines/tabs before parsing, so validating parts.path would let
146
+ # "/x\ny" through as "/xy" — a control byte that must fail closed.
147
+ if "%" in candidate or _ENDPOINT_PATH_CHARS.fullmatch(candidate) is None:
148
+ return DEFAULT_ENDPOINT_PATH
149
+ return candidate.rstrip("/") or DEFAULT_ENDPOINT_PATH
150
+
151
+
152
+ def _join_url(base_url: str, endpoint_path: str = DEFAULT_ENDPOINT_PATH) -> str:
153
+ """base_url + endpoint path without mangling query/fragment.
154
+
155
+ Official docs give a bare base (``https://api.typesafe.ai``,
156
+ ``https://openrouter.ai/api``) and the path ``/v1/systemone``; subcortex
157
+ <= 0.2 saved ``https://api.typesafe.ai/v1`` + ``/systemone``. Both, and any
158
+ mix of the two, resolve to one ``/v1/systemone``.
159
+ """
160
+ try:
161
+ parts = urlsplit(base_url)
162
+ except ValueError:
163
+ raise BackendUnavailableError("invalid jev base url") from None
164
+ if parts.fragment:
165
+ raise BackendUnavailableError("refusing jev base url with fragment")
166
+ base = parts.path.rstrip("/")
167
+ endpoint = _normalize_endpoint_path(endpoint_path)
168
+ if base.endswith("/v1") and endpoint.startswith("/v1/"):
169
+ endpoint = endpoint[3:]
170
+ elif endpoint == "/systemone" and not base.endswith("/v1"):
171
+ endpoint = "/v1/systemone"
172
+ return urlunsplit((parts.scheme, parts.netloc, base + endpoint, parts.query, ""))
173
+
174
+
175
+ def _read_capped(resp: Any) -> str:
176
+ raw: bytes = resp.read(MAX_RESPONSE_BYTES + 1)
177
+ if len(raw) > MAX_RESPONSE_BYTES:
178
+ raise BackendUnavailableError(f"jev response over {MAX_RESPONSE_BYTES} byte cap")
179
+ return raw.decode("utf-8", "replace")
180
+
181
+
182
+ # -- keep-alive transport ----------------------------------------------------------
183
+ #
184
+ # A fresh HTTPS connection costs three round trips (TCP, TLS, request); a reused
185
+ # one costs one. Measured to api.typesafe.ai at ~225 ms RTT: ~650 ms per decision
186
+ # fresh, ~250 ms reused. So the daemon keeps a few idle connections per origin.
187
+ # http.client never follows redirects (a 3xx fails closed in _parse_response,
188
+ # so the bearer key is never re-sent elsewhere).
189
+
190
+ _IDLE_MAX_AGE_S = 30.0 # servers drop idle keep-alive connections; retire ours first
191
+ _IDLE_PER_ORIGIN = 4
192
+ _idle: Dict[Tuple[str, str, int], list] = {}
193
+ _idle_lock = threading.Lock()
194
+
195
+
196
+ def _proxy_for(scheme: str, host: str) -> Optional[Tuple[str, int]]:
197
+ """The HTTPS proxy to tunnel through (HTTPS_PROXY / system settings, honoring
198
+ NO_PROXY), so corporate networks can still reach the hosted API."""
199
+ import urllib.request
200
+
201
+ if _is_loopback_host(host) or urllib.request.proxy_bypass(host):
202
+ return None
203
+ proxy = urllib.request.getproxies().get(scheme)
204
+ if not proxy:
205
+ return None
206
+ parts = urlsplit(proxy if "://" in proxy else f"http://{proxy}")
207
+ if not parts.hostname:
208
+ return None
209
+ return parts.hostname, parts.port or (443 if parts.scheme == "https" else 80)
210
+
211
+
212
+ def _connect(scheme: str, host: str, port: int, timeout_s: float) -> Any:
213
+ proxy = _proxy_for(scheme, host)
214
+ if scheme == "https":
215
+ context = ssl.create_default_context()
216
+ if proxy:
217
+ conn = http.client.HTTPSConnection(proxy[0], proxy[1], timeout=timeout_s, context=context)
218
+ conn.set_tunnel(host, port)
219
+ return conn
220
+ return http.client.HTTPSConnection(host, port, timeout=timeout_s, context=context)
221
+ return http.client.HTTPConnection(host, port, timeout=timeout_s) # LAN only (_check_url)
222
+
223
+
224
+ def _take(origin: Tuple[str, str, int]) -> Any:
225
+ now = time.monotonic()
226
+ with _idle_lock:
227
+ pool = _idle.get(origin) or []
228
+ while pool:
229
+ conn, since = pool.pop()
230
+ if now - since < _IDLE_MAX_AGE_S:
231
+ return conn
232
+ conn.close()
233
+ return None
234
+
235
+
236
+ def _give_back(origin: Tuple[str, str, int], conn: Any) -> None:
237
+ with _idle_lock:
238
+ pool = _idle.setdefault(origin, [])
239
+ if len(pool) < _IDLE_PER_ORIGIN:
240
+ pool.append((conn, time.monotonic()))
241
+ return
242
+ conn.close()
243
+
244
+
245
+ def _default_transport(
246
+ url: str, body: bytes, headers: Dict[str, str], timeout_s: float
247
+ ) -> Tuple[int, str]:
248
+ scheme, host = _check_url(url)
249
+ parts = urlsplit(url)
250
+ port = parts.port or (443 if scheme == "https" else 80)
251
+ target = parts.path + (f"?{parts.query}" if parts.query else "")
252
+ origin = (scheme, host, port)
253
+ reused = _take(origin)
254
+ for conn in ([reused] if reused else []) + [None]:
255
+ fresh = conn is None
256
+ if fresh:
257
+ conn = _connect(scheme, host, port, timeout_s)
258
+ else:
259
+ conn.timeout = timeout_s
260
+ if conn.sock is not None:
261
+ conn.sock.settimeout(timeout_s)
262
+ try:
263
+ conn.request("POST", target, body=body, headers=headers)
264
+ resp = conn.getresponse()
265
+ text = _read_capped(resp)
266
+ except (http.client.RemoteDisconnected, ConnectionResetError, BrokenPipeError,
267
+ http.client.CannotSendRequest, http.client.BadStatusLine):
268
+ conn.close()
269
+ if fresh:
270
+ raise
271
+ continue # the server had closed the idle connection; nothing was processed
272
+ except BaseException:
273
+ conn.close()
274
+ raise
275
+ if resp.will_close:
276
+ conn.close()
277
+ else:
278
+ _give_back(origin, conn)
279
+ return int(resp.status), text
280
+ raise BackendUnavailableError("jev transport failed")
281
+
282
+
283
+ # Module-level so tests can stub the network with mock.patch.
284
+ _transport: Transport = _default_transport
285
+
286
+
287
+ # -- request / response shaping -------------------------------------------------
288
+
289
+
290
+ def _build_body(model: str, state: Any, questions: Dict[str, Any]) -> bytes:
291
+ try:
292
+ # allow_nan=False: NaN/Infinity are not valid JSON — a state carrying
293
+ # them is corrupt input, not a request (strict endpoints 400 it).
294
+ return json.dumps(
295
+ {"model": model, "state": state, "questions": questions}, allow_nan=False
296
+ ).encode("utf-8")
297
+ except (ValueError, TypeError) as exc:
298
+ raise BackendUnavailableError(
299
+ f"jev request is not JSON-serializable: {type(exc).__name__}"
300
+ ) from exc
301
+
302
+
303
+ # Status -> what the user should do. Upstream error bodies are
304
+ # attacker-influenced: report a category, never the body.
305
+ _STATUS_HINTS = {
306
+ 401: "API key rejected (401): check the key",
307
+ 403: "API key missing or not allowed (403): check the key",
308
+ 404: "endpoint not found (404): check jev.base_url",
309
+ 408: "request timed out upstream (408)",
310
+ 413: "request too large (413)",
311
+ 422: "request rejected as invalid (422)",
312
+ 429: "rate limited (429)",
313
+ 529: "Jev is overloaded (529)",
314
+ }
315
+
316
+
317
+ def _status_error(status: int) -> BackendUnavailableError:
318
+ hint = _STATUS_HINTS.get(status) or (
319
+ f"Jev server error ({status})" if status >= 500 else f"http {status}")
320
+ return BackendUnavailableError(f"jev request failed: {hint}")
321
+
322
+
323
+ def _parse_response(status: int, text: str) -> Dict[str, Any]:
324
+ if not 200 <= status < 300:
325
+ raise _status_error(status)
326
+ try:
327
+ parsed = json.loads(text)
328
+ except ValueError:
329
+ raise BackendUnavailableError("jev returned malformed JSON") from None
330
+ if not isinstance(parsed, dict) or not isinstance(parsed.get("answers"), dict):
331
+ raise BackendUnavailableError("jev response is missing answers")
332
+ return parsed
333
+
334
+
335
+ def _probability(value: Any) -> bool:
336
+ return (not isinstance(value, bool) and isinstance(value, (int, float))
337
+ and math.isfinite(value) and 0.0 <= float(value) <= 1.0)
338
+
339
+
340
+ def _validate_answers(answers: Dict[str, Any], questions: Dict[str, Any]) -> None:
341
+ """Every question answered, with its own type and well-formed values.
342
+
343
+ Answers to questions we didn't ask are ignored (the SDK does the same, for
344
+ forward compatibility); a missing or mistyped answer fails the whole call.
345
+ """
346
+ for name, question in questions.items():
347
+ answer = answers.get(name)
348
+ qtype = question.get("type") if isinstance(question, dict) else None
349
+ if not isinstance(answer, dict):
350
+ raise BackendUnavailableError(f"jev did not answer {name}")
351
+ # Laya-compatible servers omit the discriminator; when present it must match.
352
+ if "type" in answer and answer["type"] != qtype:
353
+ raise BackendUnavailableError(f"jev answered {name} with the wrong type")
354
+ if qtype == "noul":
355
+ if not _probability(answer.get("noul")):
356
+ raise BackendUnavailableError(f"invalid jev answer for {name}: not a probability")
357
+ elif qtype == "choice":
358
+ criteria = question.get("criteria") or {}
359
+ if answer.get("choice") not in criteria:
360
+ raise BackendUnavailableError(f"invalid jev answer for {name}: unknown choice")
361
+ elif qtype == "score":
362
+ score = answer.get("score")
363
+ if isinstance(score, bool) or not isinstance(score, (int, float)) or not math.isfinite(score):
364
+ raise BackendUnavailableError(f"invalid jev answer for {name}: not a score")
365
+
366
+
367
+ def _record_usage(result: Dict[str, Any]) -> None:
368
+ """Billable tokens and cost, for ``subcortex stats`` (OpenRouter reports cost itself)."""
369
+ usage = result.get("usage")
370
+ if not isinstance(usage, dict):
371
+ return
372
+ tokens = usage.get("input_tokens")
373
+ if isinstance(tokens, int) and not isinstance(tokens, bool) and tokens >= 0:
374
+ METRICS.add("jev_input_tokens", tokens)
375
+ cost = usage.get("cost")
376
+ if isinstance(cost, bool) or not isinstance(cost, (int, float)) or not math.isfinite(cost) or cost < 0:
377
+ cost = tokens * PRICE_PER_INPUT_TOKEN
378
+ METRICS.add("jev_cost_usd", float(cost))
379
+ from .. import ledger
380
+
381
+ ledger.record("jev", tokens=tokens, usd=round(float(cost), 9))
382
+ if isinstance(result.get("model"), str):
383
+ METRICS.note("jev_model", result["model"][:64])
384
+
385
+
386
+ # -- backend --------------------------------------------------------------------
387
+
388
+
389
+ class JevBackend:
390
+ name = "jev"
391
+
392
+ def __init__(self, config: Dict[str, Any]) -> None:
393
+ jev = config.get("jev") or {}
394
+ self.base_url = str(jev.get("base_url") or DEFAULT_BASE_URL)
395
+ self.endpoint_path = str(jev.get("endpoint_path") or DEFAULT_ENDPOINT_PATH)
396
+ self.api_key_env = str(jev.get("api_key_env") or DEFAULT_API_KEY_ENV).strip()
397
+ self.model = str(jev.get("model") or DEFAULT_MODEL).strip()
398
+ try:
399
+ timeout = float(jev.get("timeout", DEFAULT_TIMEOUT))
400
+ except (TypeError, ValueError):
401
+ timeout = DEFAULT_TIMEOUT
402
+ self.timeout = timeout if math.isfinite(timeout) and timeout > 0 else DEFAULT_TIMEOUT
403
+
404
+ # -- availability -----------------------------------------------------------
405
+
406
+ def available(self) -> Tuple[bool, str]:
407
+ try:
408
+ url = _join_url(self.base_url, self.endpoint_path)
409
+ _check_url(url)
410
+ except BackendUnavailableError as exc:
411
+ return False, f"jev misconfigured: {exc}"
412
+ if not read_secret(self.api_key_env):
413
+ return False, (
414
+ f"jev API key not configured ({self.api_key_env} is not set). "
415
+ f"Fix: export {self.api_key_env}=... or run: subcortex setup"
416
+ )
417
+ return True, f"jev configured ({url}, model {self.model!r})"
418
+
419
+ # -- inference ----------------------------------------------------------------
420
+
421
+ def _api_key(self) -> str:
422
+ key = (read_secret(self.api_key_env) or "").strip()
423
+ if not key:
424
+ raise BackendUnavailableError(
425
+ f"jev API key not configured ({self.api_key_env} is not set). "
426
+ f"Fix: export {self.api_key_env}=... or run: subcortex setup"
427
+ )
428
+ # A pasted key with a stray newline or space would fail inside http.client
429
+ # with an opaque error (or split the header): refuse it clearly.
430
+ if not key.isascii() or not key.isprintable() or any(c.isspace() for c in key):
431
+ raise BackendUnavailableError(
432
+ f"jev API key in {self.api_key_env} contains spaces or control characters. "
433
+ "Fix: paste it again with: subcortex setup"
434
+ )
435
+ return key
436
+
437
+ def predict(self, state: Any, questions: Dict[str, Any]) -> Dict[str, Any]:
438
+ """One typed-decision call. No retries — fail closed on any error."""
439
+ url = _join_url(self.base_url, self.endpoint_path)
440
+ _check_url(url) # fail closed before the key is attached to anything
441
+ body = _build_body(self.model, state, questions)
442
+ headers = {
443
+ "Authorization": f"Bearer {self._api_key()}",
444
+ "Content-Type": "application/json",
445
+ "Accept": "application/json",
446
+ "User-Agent": f"subcortex/{__version__}",
447
+ }
448
+ try:
449
+ status, text = _transport(url, body, headers, self.timeout)
450
+ except BackendUnavailableError:
451
+ raise
452
+ except Exception as exc:
453
+ # Transport exceptions can echo the request URL; never surface it raw.
454
+ raise BackendUnavailableError(
455
+ f"jev transport failed: {type(exc).__name__}"
456
+ ) from exc
457
+ result = _parse_response(status, text)
458
+ _validate_answers(result["answers"], questions)
459
+ _record_usage(result)
460
+ return result