hx-cli 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.
- hx/__init__.py +5 -0
- hx/agents/__init__.py +1 -0
- hx/agents/definitions.py +106 -0
- hx/agents/subagent.py +190 -0
- hx/cli.py +667 -0
- hx/config.py +277 -0
- hx/core/__init__.py +1 -0
- hx/core/compaction.py +245 -0
- hx/core/context.py +271 -0
- hx/core/events.py +183 -0
- hx/core/lateinject.py +121 -0
- hx/core/loop.py +537 -0
- hx/core/messages.py +164 -0
- hx/core/session.py +208 -0
- hx/core/usage.py +129 -0
- hx/frontmatter.py +80 -0
- hx/mcp/__init__.py +1 -0
- hx/mcp/client.py +319 -0
- hx/mcp/manager.py +265 -0
- hx/paths.py +90 -0
- hx/permissions/__init__.py +7 -0
- hx/permissions/engine.py +406 -0
- hx/permissions/parser.py +306 -0
- hx/permissions/sandbox.py +227 -0
- hx/providers/__init__.py +1 -0
- hx/providers/base.py +77 -0
- hx/providers/fake.py +87 -0
- hx/providers/models.py +238 -0
- hx/providers/openrouter.py +468 -0
- hx/skills/__init__.py +1 -0
- hx/skills/loader.py +102 -0
- hx/skills/runtime.py +84 -0
- hx/tools/__init__.py +1 -0
- hx/tools/base.py +97 -0
- hx/tools/bash.py +544 -0
- hx/tools/edit.py +167 -0
- hx/tools/glob.py +75 -0
- hx/tools/grep.py +165 -0
- hx/tools/output.py +133 -0
- hx/tools/read.py +142 -0
- hx/tools/registry.py +149 -0
- hx/tools/task.py +76 -0
- hx/tools/todo.py +149 -0
- hx/tools/write.py +87 -0
- hx/tui/__init__.py +1 -0
- hx/tui/app.py +487 -0
- hx/tui/commands.py +399 -0
- hx/tui/hx.tcss +197 -0
- hx/tui/renderers.py +570 -0
- hx/tui/theme.py +322 -0
- hx/tui/widgets/__init__.py +1 -0
- hx/tui/widgets/configure.py +95 -0
- hx/tui/widgets/diff.py +25 -0
- hx/tui/widgets/input.py +145 -0
- hx/tui/widgets/palette.py +130 -0
- hx/tui/widgets/permission.py +97 -0
- hx/tui/widgets/statusbar.py +212 -0
- hx/tui/widgets/todos.py +116 -0
- hx/tui/widgets/transcript.py +316 -0
- hx/tui/widgets/working.py +78 -0
- hx_cli-0.1.0.dist-info/METADATA +430 -0
- hx_cli-0.1.0.dist-info/RECORD +64 -0
- hx_cli-0.1.0.dist-info/WHEEL +4 -0
- hx_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""OS-level sandboxing for executed commands.
|
|
2
|
+
|
|
3
|
+
macOS uses ``sandbox-exec`` with a generated Seatbelt profile; Linux uses
|
|
4
|
+
``bubblewrap``. Both default to: read the filesystem, write only inside the
|
|
5
|
+
project and the temp dir, no outbound network.
|
|
6
|
+
|
|
7
|
+
If neither backend exists the sandbox degrades to a no-op - and says so in the
|
|
8
|
+
status bar. Silently pretending to be sandboxed would be worse than being
|
|
9
|
+
visibly unsandboxed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import shutil
|
|
17
|
+
import tempfile
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from enum import StrEnum
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SandboxBackend(StrEnum):
|
|
24
|
+
SEATBELT = "seatbelt"
|
|
25
|
+
BUBBLEWRAP = "bubblewrap"
|
|
26
|
+
NONE = "none"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
CREDENTIAL_PATHS = (
|
|
30
|
+
"~/.ssh",
|
|
31
|
+
"~/.aws",
|
|
32
|
+
"~/.gnupg",
|
|
33
|
+
"~/.kube",
|
|
34
|
+
"~/.docker/config.json",
|
|
35
|
+
"~/.config/gh",
|
|
36
|
+
"~/.netrc",
|
|
37
|
+
"~/.npmrc",
|
|
38
|
+
"~/.pypirc",
|
|
39
|
+
"~/.hx/auth.json",
|
|
40
|
+
)
|
|
41
|
+
"""Denied even inside readable roots. A coding agent has no business reading
|
|
42
|
+
these, and one prompt-injected `cat ~/.ssh/id_rsa` is all it takes."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class SandboxPolicy:
|
|
47
|
+
writable_paths: tuple[Path, ...] = ()
|
|
48
|
+
readable_paths: tuple[Path, ...] = ()
|
|
49
|
+
deny_paths: tuple[Path, ...] = field(default_factory=tuple)
|
|
50
|
+
"""Explicitly unreadable even inside readable roots - credential stores,
|
|
51
|
+
``~/.ssh``, ``~/.aws``, and HX's own ``auth.json``."""
|
|
52
|
+
allow_network: bool = False
|
|
53
|
+
allow_subprocess: bool = True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Sandbox:
|
|
57
|
+
"""Wraps a command so it runs under the platform's sandbox."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, policy: SandboxPolicy, backend: SandboxBackend | None = None) -> None:
|
|
60
|
+
self.policy = policy
|
|
61
|
+
self._backend = backend if backend is not None else detect_backend()
|
|
62
|
+
self._profile_path: Path | None = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def backend(self) -> SandboxBackend:
|
|
66
|
+
return self._backend
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def active(self) -> bool:
|
|
70
|
+
"""False when degraded to :attr:`SandboxBackend.NONE`."""
|
|
71
|
+
return self._backend is not SandboxBackend.NONE
|
|
72
|
+
|
|
73
|
+
def wrap(self, argv: list[str]) -> list[str]:
|
|
74
|
+
"""Return the argv to actually exec."""
|
|
75
|
+
if self._backend is SandboxBackend.SEATBELT:
|
|
76
|
+
return ["/usr/bin/sandbox-exec", "-f", str(self._write_profile()), *argv]
|
|
77
|
+
if self._backend is SandboxBackend.BUBBLEWRAP:
|
|
78
|
+
return build_bwrap_argv(self.policy, argv)
|
|
79
|
+
return list(argv)
|
|
80
|
+
|
|
81
|
+
def profile_text(self) -> str:
|
|
82
|
+
"""The generated Seatbelt profile - exposed so tests can assert on it and
|
|
83
|
+
``/permissions`` can show it."""
|
|
84
|
+
return build_seatbelt_profile(self.policy)
|
|
85
|
+
|
|
86
|
+
def _write_profile(self) -> Path:
|
|
87
|
+
if self._profile_path is None or not self._profile_path.exists():
|
|
88
|
+
with tempfile.NamedTemporaryFile(
|
|
89
|
+
"w", suffix=".sb", prefix="hx-sandbox-", delete=False
|
|
90
|
+
) as handle:
|
|
91
|
+
handle.write(self.profile_text())
|
|
92
|
+
self._profile_path = Path(handle.name)
|
|
93
|
+
return self._profile_path
|
|
94
|
+
|
|
95
|
+
def cleanup(self) -> None:
|
|
96
|
+
if self._profile_path is not None:
|
|
97
|
+
self._profile_path.unlink(missing_ok=True)
|
|
98
|
+
self._profile_path = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def detect_backend() -> SandboxBackend:
|
|
102
|
+
"""Probe for ``sandbox-exec`` / ``bwrap`` on PATH."""
|
|
103
|
+
system = platform.system()
|
|
104
|
+
if system == "Darwin" and Path("/usr/bin/sandbox-exec").exists():
|
|
105
|
+
return SandboxBackend.SEATBELT
|
|
106
|
+
if system == "Linux" and shutil.which("bwrap"):
|
|
107
|
+
return SandboxBackend.BUBBLEWRAP
|
|
108
|
+
return SandboxBackend.NONE
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def default_policy(cwd: Path, allow_network: bool = False) -> SandboxPolicy:
|
|
112
|
+
"""Writable: cwd and ``$TMPDIR``. Denied: credential paths. Network off."""
|
|
113
|
+
# gettempdir() resolves through /private on macOS; bind the resolved form,
|
|
114
|
+
# not the whole /var/folders tree, which would hand over every process's temp.
|
|
115
|
+
temp = Path(tempfile.gettempdir()).resolve()
|
|
116
|
+
writable = [cwd.resolve(), temp]
|
|
117
|
+
|
|
118
|
+
# Tool caches that live outside the project but must stay writable, or
|
|
119
|
+
# ordinary commands fail in confusing ways.
|
|
120
|
+
for extra in ("~/.cache", "~/Library/Caches"):
|
|
121
|
+
candidate = Path(extra).expanduser()
|
|
122
|
+
if candidate.is_dir():
|
|
123
|
+
writable.append(candidate.resolve())
|
|
124
|
+
|
|
125
|
+
return SandboxPolicy(
|
|
126
|
+
writable_paths=tuple(dict.fromkeys(writable)),
|
|
127
|
+
readable_paths=(Path("/"),),
|
|
128
|
+
deny_paths=tuple(_real(Path(p).expanduser()) for p in CREDENTIAL_PATHS),
|
|
129
|
+
allow_network=allow_network,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_seatbelt_profile(policy: SandboxPolicy) -> str:
|
|
134
|
+
"""Generate a Seatbelt profile.
|
|
135
|
+
|
|
136
|
+
Starts from ``(deny default)`` and allows explicitly. SBPL evaluates rules
|
|
137
|
+
in order with the last match winning, so the credential denials come after
|
|
138
|
+
the broad read allowance.
|
|
139
|
+
|
|
140
|
+
Paths are emitted as quoted ``subpath`` literals, not regexes, so a
|
|
141
|
+
directory named ``proj (1)`` cannot break out of the profile syntax.
|
|
142
|
+
"""
|
|
143
|
+
lines = [
|
|
144
|
+
"(version 1)",
|
|
145
|
+
"(deny default)",
|
|
146
|
+
"",
|
|
147
|
+
";; Process management",
|
|
148
|
+
"(allow process-fork)",
|
|
149
|
+
"(allow signal (target same-sandbox))",
|
|
150
|
+
"(allow sysctl-read)",
|
|
151
|
+
"(allow mach-lookup)",
|
|
152
|
+
"(allow ipc-posix-shm)",
|
|
153
|
+
]
|
|
154
|
+
lines.append("(allow process-exec*)" if policy.allow_subprocess else "(deny process-exec*)")
|
|
155
|
+
|
|
156
|
+
lines += ["", ";; Reads"]
|
|
157
|
+
for path in policy.readable_paths or (Path("/"),):
|
|
158
|
+
lines.append(f"(allow file-read* (subpath {_sbpl_string(_real(path))}))")
|
|
159
|
+
|
|
160
|
+
lines += ["", ";; Writes"]
|
|
161
|
+
for path in policy.writable_paths:
|
|
162
|
+
lines.append(f"(allow file-write* (subpath {_sbpl_string(_real(path))}))")
|
|
163
|
+
for device in ("/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty", "/dev/urandom"):
|
|
164
|
+
lines.append(f"(allow file-write-data file-read-data (literal {_sbpl_string(device)}))")
|
|
165
|
+
lines.append('(allow file-write* (subpath "/private/var/folders"))')
|
|
166
|
+
|
|
167
|
+
lines += ["", ";; Network"]
|
|
168
|
+
lines.append("(allow network*)" if policy.allow_network else "(deny network*)")
|
|
169
|
+
|
|
170
|
+
if policy.deny_paths:
|
|
171
|
+
lines += ["", ";; Credentials - last match wins, so these override the read allowance"]
|
|
172
|
+
for path in policy.deny_paths:
|
|
173
|
+
lines.append(f"(deny file-read* (subpath {_sbpl_string(_real(path))}))")
|
|
174
|
+
|
|
175
|
+
return "\n".join(lines) + "\n"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _real(path: Path) -> Path:
|
|
179
|
+
"""Resolve symlinks before emitting a path into a profile.
|
|
180
|
+
|
|
181
|
+
Seatbelt matches against the resolved real path, so an unresolved
|
|
182
|
+
``/var/folders/...`` deny silently fails to cover the very files it names -
|
|
183
|
+
on macOS that path really lives under ``/private``.
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
return path.resolve()
|
|
187
|
+
except OSError:
|
|
188
|
+
return path
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _sbpl_string(path: Path | str) -> str:
|
|
192
|
+
"""Quote a path as an SBPL string literal.
|
|
193
|
+
|
|
194
|
+
Only backslashes and double quotes are special inside a quoted literal, so
|
|
195
|
+
escaping those is sufficient - and unlike a regex, parentheses and dots in
|
|
196
|
+
the path carry no meaning.
|
|
197
|
+
"""
|
|
198
|
+
text = str(path)
|
|
199
|
+
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
|
|
200
|
+
return f'"{escaped}"'
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def build_bwrap_argv(policy: SandboxPolicy, argv: list[str]) -> list[str]:
|
|
204
|
+
"""Generate the ``bwrap`` invocation: ``--ro-bind / /``, project bound
|
|
205
|
+
read-write, ``--unshare-net`` unless network is allowed."""
|
|
206
|
+
command = [
|
|
207
|
+
"bwrap",
|
|
208
|
+
"--ro-bind",
|
|
209
|
+
"/",
|
|
210
|
+
"/",
|
|
211
|
+
"--dev",
|
|
212
|
+
"/dev",
|
|
213
|
+
"--proc",
|
|
214
|
+
"/proc",
|
|
215
|
+
"--die-with-parent",
|
|
216
|
+
]
|
|
217
|
+
for path in policy.writable_paths:
|
|
218
|
+
command += ["--bind", str(_real(path)), str(_real(path))]
|
|
219
|
+
for path in policy.deny_paths:
|
|
220
|
+
if path.exists():
|
|
221
|
+
# There is no "deny read" in bwrap; shadow the path with an empty dir.
|
|
222
|
+
command += ["--tmpfs", str(path)]
|
|
223
|
+
if not policy.allow_network:
|
|
224
|
+
command.append("--unshare-net")
|
|
225
|
+
if cwd := os.environ.get("PWD"):
|
|
226
|
+
command += ["--chdir", cwd]
|
|
227
|
+
return [*command, "--", *argv]
|
hx/providers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Model providers. OpenRouter is the only real one for now."""
|
hx/providers/base.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Provider protocol.
|
|
2
|
+
|
|
3
|
+
A provider converts an :class:`~hx.core.context.AssembledContext` into wire
|
|
4
|
+
format, streams the response, and converts it back into core message types.
|
|
5
|
+
All provider-specific quirks - cache markers, reasoning fields, tool-call
|
|
6
|
+
encodings - stay behind this boundary.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
from hx.core.context import AssembledContext
|
|
16
|
+
from hx.core.messages import StopReason
|
|
17
|
+
from hx.core.usage import TurnUsage
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class StreamDelta:
|
|
22
|
+
"""A chunk off the wire."""
|
|
23
|
+
|
|
24
|
+
text: str | None = None
|
|
25
|
+
thinking: str | None = None
|
|
26
|
+
tool_use_id: str | None = None
|
|
27
|
+
tool_name: str | None = None
|
|
28
|
+
tool_input_json: str | None = None
|
|
29
|
+
"""Partial JSON for a tool call; accumulated by the consumer."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class StreamEnd:
|
|
34
|
+
stop_reason: StopReason
|
|
35
|
+
usage: TurnUsage = field(default_factory=TurnUsage)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
StreamItem = StreamDelta | StreamEnd
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class ProviderRequest:
|
|
43
|
+
context: AssembledContext
|
|
44
|
+
model: str
|
|
45
|
+
max_tokens: int
|
|
46
|
+
temperature: float | None = None
|
|
47
|
+
stop_sequences: tuple[str, ...] = ()
|
|
48
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@runtime_checkable
|
|
52
|
+
class Provider(Protocol):
|
|
53
|
+
name: str
|
|
54
|
+
|
|
55
|
+
def astream(self, request: ProviderRequest) -> AsyncIterator[StreamItem]:
|
|
56
|
+
"""Stream a completion. Must raise :class:`ProviderError` on failure.
|
|
57
|
+
|
|
58
|
+
Implementations are async generator functions, so this is declared as a
|
|
59
|
+
plain ``def`` returning an ``AsyncIterator`` rather than ``async def``.
|
|
60
|
+
"""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
async def aclose(self) -> None: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ProviderError(Exception):
|
|
67
|
+
"""Transport or API failure.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
retryable: Whether the loop should retry with backoff (429, 5xx,
|
|
71
|
+
connection resets) rather than surfacing to the user.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, message: str, *, status: int | None = None, retryable: bool = False) -> None:
|
|
75
|
+
super().__init__(message)
|
|
76
|
+
self.status = status
|
|
77
|
+
self.retryable = retryable
|
hx/providers/fake.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Scripted provider for tests.
|
|
2
|
+
|
|
3
|
+
Replays a canned sequence of deltas and tool calls so the entire agent loop -
|
|
4
|
+
tool dispatch, output capping, compaction triggers, late injection,
|
|
5
|
+
cancellation - is deterministic and runs offline.
|
|
6
|
+
|
|
7
|
+
It also records every :class:`~hx.providers.base.ProviderRequest` it receives,
|
|
8
|
+
which is what the prefix-stability tests assert against.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
|
|
16
|
+
from hx.core.messages import StopReason
|
|
17
|
+
from hx.core.usage import TurnUsage
|
|
18
|
+
from hx.providers.base import ProviderError, ProviderRequest, StreamDelta, StreamEnd, StreamItem
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FakeProvider:
|
|
22
|
+
name = "fake"
|
|
23
|
+
|
|
24
|
+
def __init__(self, script: list[list[StreamItem]]) -> None:
|
|
25
|
+
"""Args:
|
|
26
|
+
script: One list of stream items per expected turn, consumed in order.
|
|
27
|
+
"""
|
|
28
|
+
self._script = list(script)
|
|
29
|
+
self._turn = 0
|
|
30
|
+
self._requests: list[ProviderRequest] = []
|
|
31
|
+
self.closed = False
|
|
32
|
+
|
|
33
|
+
async def astream(self, request: ProviderRequest) -> AsyncIterator[StreamItem]:
|
|
34
|
+
self._requests.append(request)
|
|
35
|
+
if self._turn >= len(self._script):
|
|
36
|
+
raise ProviderError(
|
|
37
|
+
f"FakeProvider script exhausted after {self._turn} turns; "
|
|
38
|
+
"the loop ran more turns than the test expected"
|
|
39
|
+
)
|
|
40
|
+
items = self._script[self._turn]
|
|
41
|
+
self._turn += 1
|
|
42
|
+
for item in items:
|
|
43
|
+
yield item
|
|
44
|
+
|
|
45
|
+
async def aclose(self) -> None:
|
|
46
|
+
self.closed = True
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def requests(self) -> list[ProviderRequest]:
|
|
50
|
+
"""Every request seen, in order."""
|
|
51
|
+
return self._requests
|
|
52
|
+
|
|
53
|
+
def prefix_fingerprints(self) -> list[str]:
|
|
54
|
+
"""Prefix hash per recorded request. Identical values across turns means
|
|
55
|
+
the KV cache stayed warm."""
|
|
56
|
+
return [r.context.prefix_fingerprint() for r in self._requests]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def text_turn(text: str, *, usage: TurnUsage | None = None) -> list[StreamItem]:
|
|
60
|
+
"""Script helper: a plain text response ending the turn."""
|
|
61
|
+
return [
|
|
62
|
+
StreamDelta(text=text),
|
|
63
|
+
StreamEnd(stop_reason=StopReason.END_TURN, usage=usage or TurnUsage()),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def tool_turn(
|
|
68
|
+
name: str,
|
|
69
|
+
tool_input: dict[str, object],
|
|
70
|
+
tool_use_id: str = "t1",
|
|
71
|
+
*,
|
|
72
|
+
text: str | None = None,
|
|
73
|
+
usage: TurnUsage | None = None,
|
|
74
|
+
) -> list[StreamItem]:
|
|
75
|
+
"""Script helper: a single tool call."""
|
|
76
|
+
items: list[StreamItem] = []
|
|
77
|
+
if text:
|
|
78
|
+
items.append(StreamDelta(text=text))
|
|
79
|
+
items.append(
|
|
80
|
+
StreamDelta(
|
|
81
|
+
tool_use_id=tool_use_id,
|
|
82
|
+
tool_name=name,
|
|
83
|
+
tool_input_json=json.dumps(tool_input, sort_keys=True),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
items.append(StreamEnd(stop_reason=StopReason.TOOL_USE, usage=usage or TurnUsage()))
|
|
87
|
+
return items
|
hx/providers/models.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Model catalogue: context windows, pricing, and caching behaviour.
|
|
2
|
+
|
|
3
|
+
Populated from OpenRouter's ``/api/v1/models``, cached to
|
|
4
|
+
``~/.hx/models.json`` and refreshed on demand (``/models refresh``) or when the
|
|
5
|
+
cache is older than a day. The registry drives the ``/model`` picker, the
|
|
6
|
+
context gauge, and cost fallback maths.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from typing import Any, ClassVar
|
|
16
|
+
|
|
17
|
+
from hx.paths import models_cache_file
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CacheMode(StrEnum):
|
|
21
|
+
EXPLICIT = "explicit"
|
|
22
|
+
"""Requires ``cache_control`` breakpoints (Anthropic, Gemini)."""
|
|
23
|
+
|
|
24
|
+
IMPLICIT = "implicit"
|
|
25
|
+
"""Caches automatically on a stable prefix (OpenAI, DeepSeek, Grok)."""
|
|
26
|
+
|
|
27
|
+
NONE = "none"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ModelPricing:
|
|
32
|
+
"""USD per token, as OpenRouter reports it."""
|
|
33
|
+
|
|
34
|
+
prompt: float = 0.0
|
|
35
|
+
completion: float = 0.0
|
|
36
|
+
cache_read: float = 0.0
|
|
37
|
+
cache_write: float = 0.0
|
|
38
|
+
reasoning: float = 0.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class ModelInfo:
|
|
43
|
+
id: str
|
|
44
|
+
name: str
|
|
45
|
+
context_window: int
|
|
46
|
+
max_output_tokens: int
|
|
47
|
+
pricing: ModelPricing
|
|
48
|
+
cache_mode: CacheMode = CacheMode.NONE
|
|
49
|
+
supports_tools: bool = True
|
|
50
|
+
supports_reasoning: bool = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ModelRegistry:
|
|
54
|
+
"""Lookup and refresh for the model catalogue."""
|
|
55
|
+
|
|
56
|
+
#: Vendor prefixes whose models need explicit ``cache_control`` breakpoints.
|
|
57
|
+
EXPLICIT_CACHE_VENDORS: ClassVar[frozenset[str]] = frozenset({"anthropic", "google"})
|
|
58
|
+
#: Vendors that cache automatically on a stable prefix.
|
|
59
|
+
IMPLICIT_CACHE_VENDORS: ClassVar[frozenset[str]] = frozenset(
|
|
60
|
+
{"openai", "deepseek", "x-ai", "mistralai", "moonshotai", "qwen"}
|
|
61
|
+
)
|
|
62
|
+
CACHE_TTL_SECONDS: ClassVar[float] = 86_400.0
|
|
63
|
+
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
self._models: dict[str, ModelInfo] = {}
|
|
66
|
+
self._fetched_at: float = 0.0
|
|
67
|
+
|
|
68
|
+
def get(self, model_id: str) -> ModelInfo:
|
|
69
|
+
"""Raises :class:`UnknownModel` if absent even after a cache refresh."""
|
|
70
|
+
if not self._models:
|
|
71
|
+
self.load_cache()
|
|
72
|
+
try:
|
|
73
|
+
return self._models[model_id]
|
|
74
|
+
except KeyError:
|
|
75
|
+
raise UnknownModel(model_id) from None
|
|
76
|
+
|
|
77
|
+
def get_or_default(self, model_id: str) -> ModelInfo:
|
|
78
|
+
"""Never raises.
|
|
79
|
+
|
|
80
|
+
An unknown id yields a conservative placeholder rather than a crash: a
|
|
81
|
+
model missing from a stale catalogue should still be usable, it just
|
|
82
|
+
cannot show accurate cost or a real context gauge.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
return self.get(model_id)
|
|
86
|
+
except UnknownModel:
|
|
87
|
+
return ModelInfo(
|
|
88
|
+
id=model_id,
|
|
89
|
+
name=model_id,
|
|
90
|
+
context_window=128_000,
|
|
91
|
+
max_output_tokens=8192,
|
|
92
|
+
pricing=ModelPricing(),
|
|
93
|
+
cache_mode=infer_cache_mode(model_id),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def all(self) -> list[ModelInfo]:
|
|
97
|
+
if not self._models:
|
|
98
|
+
self.load_cache()
|
|
99
|
+
return sorted(self._models.values(), key=lambda m: m.id)
|
|
100
|
+
|
|
101
|
+
def search(self, query: str) -> list[ModelInfo]:
|
|
102
|
+
"""Fuzzy match for the ``/model`` picker, ranked by match quality."""
|
|
103
|
+
needle = query.strip().lower()
|
|
104
|
+
if not needle:
|
|
105
|
+
return self.all()
|
|
106
|
+
scored: list[tuple[int, str, ModelInfo]] = []
|
|
107
|
+
for model in self.all():
|
|
108
|
+
haystack = f"{model.id} {model.name}".lower()
|
|
109
|
+
if needle in haystack:
|
|
110
|
+
score = 0 if model.id.lower().startswith(needle) else 1
|
|
111
|
+
elif _subsequence(needle, haystack):
|
|
112
|
+
score = 2
|
|
113
|
+
else:
|
|
114
|
+
continue
|
|
115
|
+
scored.append((score, model.id, model))
|
|
116
|
+
return [m for _, _, m in sorted(scored, key=lambda item: (item[0], item[1]))]
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def is_stale(self) -> bool:
|
|
120
|
+
return (time.time() - self._fetched_at) > self.CACHE_TTL_SECONDS
|
|
121
|
+
|
|
122
|
+
async def refresh(self, api_key: str) -> None:
|
|
123
|
+
"""Fetch the live catalogue and rewrite the cache."""
|
|
124
|
+
from hx.providers.openrouter import fetch_models
|
|
125
|
+
|
|
126
|
+
raw = await fetch_models(api_key)
|
|
127
|
+
self._models = {}
|
|
128
|
+
for entry in raw:
|
|
129
|
+
try:
|
|
130
|
+
info = parse_model_entry(entry)
|
|
131
|
+
except (KeyError, TypeError, ValueError):
|
|
132
|
+
# One malformed catalogue entry must not cost us the whole list.
|
|
133
|
+
continue
|
|
134
|
+
self._models[info.id] = info
|
|
135
|
+
self._fetched_at = time.time()
|
|
136
|
+
self.save_cache()
|
|
137
|
+
|
|
138
|
+
def load_cache(self) -> None:
|
|
139
|
+
path = models_cache_file()
|
|
140
|
+
if not path.exists():
|
|
141
|
+
return
|
|
142
|
+
try:
|
|
143
|
+
payload = json.loads(path.read_text())
|
|
144
|
+
except (OSError, json.JSONDecodeError):
|
|
145
|
+
return
|
|
146
|
+
self._fetched_at = payload.get("fetched_at", 0.0)
|
|
147
|
+
self._models = {}
|
|
148
|
+
for entry in payload.get("models", []):
|
|
149
|
+
try:
|
|
150
|
+
info = parse_model_entry(entry)
|
|
151
|
+
except (KeyError, TypeError, ValueError):
|
|
152
|
+
continue
|
|
153
|
+
self._models[info.id] = info
|
|
154
|
+
|
|
155
|
+
def save_cache(self) -> None:
|
|
156
|
+
path = models_cache_file()
|
|
157
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
payload = {
|
|
159
|
+
"fetched_at": self._fetched_at,
|
|
160
|
+
"models": [
|
|
161
|
+
{
|
|
162
|
+
"id": m.id,
|
|
163
|
+
"name": m.name,
|
|
164
|
+
"context_length": m.context_window,
|
|
165
|
+
"top_provider": {"max_completion_tokens": m.max_output_tokens},
|
|
166
|
+
"pricing": {
|
|
167
|
+
"prompt": str(m.pricing.prompt),
|
|
168
|
+
"completion": str(m.pricing.completion),
|
|
169
|
+
"input_cache_read": str(m.pricing.cache_read),
|
|
170
|
+
"input_cache_write": str(m.pricing.cache_write),
|
|
171
|
+
"internal_reasoning": str(m.pricing.reasoning),
|
|
172
|
+
},
|
|
173
|
+
"supported_parameters": (["tools"] if m.supports_tools else [])
|
|
174
|
+
+ (["reasoning"] if m.supports_reasoning else []),
|
|
175
|
+
}
|
|
176
|
+
for m in self.all()
|
|
177
|
+
],
|
|
178
|
+
}
|
|
179
|
+
tmp = path.with_suffix(".json.tmp")
|
|
180
|
+
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True))
|
|
181
|
+
tmp.replace(path)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def parse_model_entry(entry: dict[str, Any]) -> ModelInfo:
|
|
185
|
+
"""Map one OpenRouter catalogue entry to a :class:`ModelInfo`."""
|
|
186
|
+
pricing_raw = entry.get("pricing") or {}
|
|
187
|
+
model_id = str(entry["id"])
|
|
188
|
+
top = entry.get("top_provider") or {}
|
|
189
|
+
context_window = int(entry.get("context_length") or top.get("context_length") or 0)
|
|
190
|
+
params = entry.get("supported_parameters") or []
|
|
191
|
+
return ModelInfo(
|
|
192
|
+
id=model_id,
|
|
193
|
+
name=str(entry.get("name") or model_id),
|
|
194
|
+
context_window=context_window or 128_000,
|
|
195
|
+
max_output_tokens=int(top.get("max_completion_tokens") or 0) or 8192,
|
|
196
|
+
pricing=ModelPricing(
|
|
197
|
+
prompt=_price(pricing_raw.get("prompt")),
|
|
198
|
+
completion=_price(pricing_raw.get("completion")),
|
|
199
|
+
cache_read=_price(pricing_raw.get("input_cache_read")),
|
|
200
|
+
cache_write=_price(pricing_raw.get("input_cache_write")),
|
|
201
|
+
reasoning=_price(pricing_raw.get("internal_reasoning")),
|
|
202
|
+
),
|
|
203
|
+
cache_mode=infer_cache_mode(model_id),
|
|
204
|
+
supports_tools="tools" in params,
|
|
205
|
+
supports_reasoning="reasoning" in params,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _price(value: Any) -> float:
|
|
210
|
+
"""OpenRouter reports prices as strings in USD per token."""
|
|
211
|
+
try:
|
|
212
|
+
return float(value)
|
|
213
|
+
except (TypeError, ValueError):
|
|
214
|
+
return 0.0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _subsequence(needle: str, haystack: str) -> bool:
|
|
218
|
+
it = iter(haystack)
|
|
219
|
+
return all(ch in it for ch in needle)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def infer_cache_mode(model_id: str) -> CacheMode:
|
|
223
|
+
"""Derive caching behaviour from the model's vendor prefix.
|
|
224
|
+
|
|
225
|
+
OpenRouter does not expose this in the catalogue, so it is keyed off the
|
|
226
|
+
``vendor/`` prefix and kept in one place rather than scattered through the
|
|
227
|
+
provider.
|
|
228
|
+
"""
|
|
229
|
+
vendor = model_id.split("/", 1)[0].lower()
|
|
230
|
+
if vendor in ModelRegistry.EXPLICIT_CACHE_VENDORS:
|
|
231
|
+
return CacheMode.EXPLICIT
|
|
232
|
+
if vendor in ModelRegistry.IMPLICIT_CACHE_VENDORS:
|
|
233
|
+
return CacheMode.IMPLICIT
|
|
234
|
+
return CacheMode.NONE
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class UnknownModel(Exception):
|
|
238
|
+
pass
|