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.
Files changed (64) hide show
  1. hx/__init__.py +5 -0
  2. hx/agents/__init__.py +1 -0
  3. hx/agents/definitions.py +106 -0
  4. hx/agents/subagent.py +190 -0
  5. hx/cli.py +667 -0
  6. hx/config.py +277 -0
  7. hx/core/__init__.py +1 -0
  8. hx/core/compaction.py +245 -0
  9. hx/core/context.py +271 -0
  10. hx/core/events.py +183 -0
  11. hx/core/lateinject.py +121 -0
  12. hx/core/loop.py +537 -0
  13. hx/core/messages.py +164 -0
  14. hx/core/session.py +208 -0
  15. hx/core/usage.py +129 -0
  16. hx/frontmatter.py +80 -0
  17. hx/mcp/__init__.py +1 -0
  18. hx/mcp/client.py +319 -0
  19. hx/mcp/manager.py +265 -0
  20. hx/paths.py +90 -0
  21. hx/permissions/__init__.py +7 -0
  22. hx/permissions/engine.py +406 -0
  23. hx/permissions/parser.py +306 -0
  24. hx/permissions/sandbox.py +227 -0
  25. hx/providers/__init__.py +1 -0
  26. hx/providers/base.py +77 -0
  27. hx/providers/fake.py +87 -0
  28. hx/providers/models.py +238 -0
  29. hx/providers/openrouter.py +468 -0
  30. hx/skills/__init__.py +1 -0
  31. hx/skills/loader.py +102 -0
  32. hx/skills/runtime.py +84 -0
  33. hx/tools/__init__.py +1 -0
  34. hx/tools/base.py +97 -0
  35. hx/tools/bash.py +544 -0
  36. hx/tools/edit.py +167 -0
  37. hx/tools/glob.py +75 -0
  38. hx/tools/grep.py +165 -0
  39. hx/tools/output.py +133 -0
  40. hx/tools/read.py +142 -0
  41. hx/tools/registry.py +149 -0
  42. hx/tools/task.py +76 -0
  43. hx/tools/todo.py +149 -0
  44. hx/tools/write.py +87 -0
  45. hx/tui/__init__.py +1 -0
  46. hx/tui/app.py +487 -0
  47. hx/tui/commands.py +399 -0
  48. hx/tui/hx.tcss +197 -0
  49. hx/tui/renderers.py +570 -0
  50. hx/tui/theme.py +322 -0
  51. hx/tui/widgets/__init__.py +1 -0
  52. hx/tui/widgets/configure.py +95 -0
  53. hx/tui/widgets/diff.py +25 -0
  54. hx/tui/widgets/input.py +145 -0
  55. hx/tui/widgets/palette.py +130 -0
  56. hx/tui/widgets/permission.py +97 -0
  57. hx/tui/widgets/statusbar.py +212 -0
  58. hx/tui/widgets/todos.py +116 -0
  59. hx/tui/widgets/transcript.py +316 -0
  60. hx/tui/widgets/working.py +78 -0
  61. hx_cli-0.1.0.dist-info/METADATA +430 -0
  62. hx_cli-0.1.0.dist-info/RECORD +64 -0
  63. hx_cli-0.1.0.dist-info/WHEEL +4 -0
  64. 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]
@@ -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