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.
- subcortex/__init__.py +3 -0
- subcortex/__main__.py +3 -0
- subcortex/adapters/__init__.py +48 -0
- subcortex/adapters/base.py +230 -0
- subcortex/adapters/claude_family.py +133 -0
- subcortex/adapters/codex.py +87 -0
- subcortex/adapters/copilot.py +60 -0
- subcortex/adapters/cursor.py +36 -0
- subcortex/adapters/docker_agent.py +115 -0
- subcortex/adapters/gemini_family.py +60 -0
- subcortex/adapters/grok.py +98 -0
- subcortex/adapters/kimi_code.py +138 -0
- subcortex/adapters/letta_vibe.py +96 -0
- subcortex/adapters/openhands.py +153 -0
- subcortex/auth.py +59 -0
- subcortex/backends/__init__.py +23 -0
- subcortex/backends/base.py +22 -0
- subcortex/backends/jev.py +460 -0
- subcortex/backends/laya.py +149 -0
- subcortex/cli.py +809 -0
- subcortex/client.py +77 -0
- subcortex/config.py +263 -0
- subcortex/daemon.py +502 -0
- subcortex/evalset.py +241 -0
- subcortex/hook.py +254 -0
- subcortex/installers/__init__.py +62 -0
- subcortex/installers/amp.py +39 -0
- subcortex/installers/base.py +874 -0
- subcortex/installers/claude_family.py +229 -0
- subcortex/installers/codex.py +110 -0
- subcortex/installers/copilot.py +65 -0
- subcortex/installers/crush.py +36 -0
- subcortex/installers/cursor.py +79 -0
- subcortex/installers/gemini_family.py +83 -0
- subcortex/installers/goose.py +186 -0
- subcortex/installers/kimi_code.py +71 -0
- subcortex/installers/mcp_only.py +111 -0
- subcortex/installers/more_hooks.py +184 -0
- subcortex/installers/opencode.py +66 -0
- subcortex/installers/openhands.py +84 -0
- subcortex/installers/pi_cline.py +53 -0
- subcortex/ledger.py +92 -0
- subcortex/localhttp.py +59 -0
- subcortex/mcp_server.py +187 -0
- subcortex/metrics.py +56 -0
- subcortex/plugins/amp/subcortex.ts +258 -0
- subcortex/plugins/cline/subcortex.ts +340 -0
- subcortex/plugins/opencode/subcortex.ts +265 -0
- subcortex/plugins/pi/subcortex.ts +292 -0
- subcortex/policy.py +341 -0
- subcortex/presets.py +163 -0
- subcortex/provision.py +188 -0
- subcortex/service.py +149 -0
- subcortex/state.py +137 -0
- subcortex/transcript.py +211 -0
- subcortex/tuis.py +51 -0
- subcortex/ui.py +319 -0
- subcortex/verdicts.py +233 -0
- subcortex/wizard.py +474 -0
- subcortex-0.3.0.dist-info/METADATA +287 -0
- subcortex-0.3.0.dist-info/RECORD +64 -0
- subcortex-0.3.0.dist-info/WHEEL +5 -0
- subcortex-0.3.0.dist-info/entry_points.txt +3 -0
- 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
|