generic-ml-cache-core 0.2.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 (99) hide show
  1. generic_ml_cache_core/__init__.py +64 -0
  2. generic_ml_cache_core/adapter/__init__.py +1 -0
  3. generic_ml_cache_core/adapter/inbound/__init__.py +1 -0
  4. generic_ml_cache_core/adapter/inbound/composition.py +96 -0
  5. generic_ml_cache_core/adapter/out/__init__.py +1 -0
  6. generic_ml_cache_core/adapter/out/api/__init__.py +1 -0
  7. generic_ml_cache_core/adapter/out/api/stub_api_client_adapter.py +30 -0
  8. generic_ml_cache_core/adapter/out/client/__init__.py +28 -0
  9. generic_ml_cache_core/adapter/out/client/claude.py +214 -0
  10. generic_ml_cache_core/adapter/out/client/codex.py +171 -0
  11. generic_ml_cache_core/adapter/out/client/cursor.py +208 -0
  12. generic_ml_cache_core/adapter/out/client/discover.py +121 -0
  13. generic_ml_cache_core/adapter/out/client/isolation.py +396 -0
  14. generic_ml_cache_core/adapter/out/client/local_client_runner.py +54 -0
  15. generic_ml_cache_core/adapter/out/client/passthrough_client_runner.py +47 -0
  16. generic_ml_cache_core/adapter/out/client/prime_directive.py +53 -0
  17. generic_ml_cache_core/adapter/out/client/registry.py +34 -0
  18. generic_ml_cache_core/adapter/out/clock/__init__.py +1 -0
  19. generic_ml_cache_core/adapter/out/clock/system_clock.py +16 -0
  20. generic_ml_cache_core/adapter/out/fingerprint/__init__.py +1 -0
  21. generic_ml_cache_core/adapter/out/fingerprint/filesystem_file_fingerprint.py +30 -0
  22. generic_ml_cache_core/adapter/out/metrics/__init__.py +1 -0
  23. generic_ml_cache_core/adapter/out/metrics/access_registry.py +147 -0
  24. generic_ml_cache_core/adapter/out/metrics/journal_metrics.py +45 -0
  25. generic_ml_cache_core/adapter/out/persistence/__init__.py +1 -0
  26. generic_ml_cache_core/adapter/out/persistence/call_identity_serialization.py +100 -0
  27. generic_ml_cache_core/adapter/out/persistence/in_memory_execution_repository.py +69 -0
  28. generic_ml_cache_core/adapter/out/persistence/sqlite_execution_repository.py +398 -0
  29. generic_ml_cache_core/adapter/out/storage/__init__.py +1 -0
  30. generic_ml_cache_core/adapter/out/storage/filesystem_blob_store.py +47 -0
  31. generic_ml_cache_core/application/__init__.py +1 -0
  32. generic_ml_cache_core/application/domain/__init__.py +1 -0
  33. generic_ml_cache_core/application/domain/model/__init__.py +1 -0
  34. generic_ml_cache_core/application/domain/model/client_status.py +17 -0
  35. generic_ml_cache_core/application/domain/model/execution/__init__.py +1 -0
  36. generic_ml_cache_core/application/domain/model/execution/artifact.py +78 -0
  37. generic_ml_cache_core/application/domain/model/execution/execution_failure.py +32 -0
  38. generic_ml_cache_core/application/domain/model/execution/execution_kind.py +26 -0
  39. generic_ml_cache_core/application/domain/model/execution/execution_state.py +21 -0
  40. generic_ml_cache_core/application/domain/model/execution/ml_execution.py +41 -0
  41. generic_ml_cache_core/application/domain/model/identity/__init__.py +1 -0
  42. generic_ml_cache_core/application/domain/model/identity/api_call_identity.py +36 -0
  43. generic_ml_cache_core/application/domain/model/identity/call_identity.py +25 -0
  44. generic_ml_cache_core/application/domain/model/identity/managed_call_identity.py +54 -0
  45. generic_ml_cache_core/application/domain/model/identity/passthrough_call_identity.py +35 -0
  46. generic_ml_cache_core/application/domain/model/model_info.py +20 -0
  47. generic_ml_cache_core/application/domain/model/model_listing.py +29 -0
  48. generic_ml_cache_core/application/domain/model/parsed_output.py +23 -0
  49. generic_ml_cache_core/application/domain/model/probe/__init__.py +1 -0
  50. generic_ml_cache_core/application/domain/model/probe/probe_report.py +26 -0
  51. generic_ml_cache_core/application/domain/model/probe/probe_status.py +13 -0
  52. generic_ml_cache_core/application/domain/model/run/__init__.py +1 -0
  53. generic_ml_cache_core/application/domain/model/run/cache_mode.py +21 -0
  54. generic_ml_cache_core/application/domain/model/run/client_run_request.py +35 -0
  55. generic_ml_cache_core/application/domain/model/run/client_run_result.py +65 -0
  56. generic_ml_cache_core/application/domain/model/run/message.py +20 -0
  57. generic_ml_cache_core/application/domain/model/usage/__init__.py +1 -0
  58. generic_ml_cache_core/application/domain/model/usage/token_usage.py +53 -0
  59. generic_ml_cache_core/application/domain/model/usage/usage.py +108 -0
  60. generic_ml_cache_core/application/domain/service/__init__.py +1 -0
  61. generic_ml_cache_core/application/domain/service/cacheability.py +19 -0
  62. generic_ml_cache_core/application/domain/service/message_fingerprinting.py +25 -0
  63. generic_ml_cache_core/application/port/__init__.py +1 -0
  64. generic_ml_cache_core/application/port/inbound/__init__.py +1 -0
  65. generic_ml_cache_core/application/port/inbound/probe_command.py +35 -0
  66. generic_ml_cache_core/application/port/inbound/probe_use_case.py +19 -0
  67. generic_ml_cache_core/application/port/inbound/run_api_execution_command.py +40 -0
  68. generic_ml_cache_core/application/port/inbound/run_api_execution_use_case.py +20 -0
  69. generic_ml_cache_core/application/port/inbound/run_managed_local_execution_command.py +48 -0
  70. generic_ml_cache_core/application/port/inbound/run_managed_local_execution_use_case.py +25 -0
  71. generic_ml_cache_core/application/port/inbound/run_passthrough_execution_command.py +35 -0
  72. generic_ml_cache_core/application/port/inbound/run_passthrough_execution_use_case.py +20 -0
  73. generic_ml_cache_core/application/port/out/__init__.py +1 -0
  74. generic_ml_cache_core/application/port/out/api_client_port.py +26 -0
  75. generic_ml_cache_core/application/port/out/base.py +272 -0
  76. generic_ml_cache_core/application/port/out/blob_store_port.py +37 -0
  77. generic_ml_cache_core/application/port/out/client_runner_port.py +26 -0
  78. generic_ml_cache_core/application/port/out/clock_port.py +22 -0
  79. generic_ml_cache_core/application/port/out/execution_repository_port.py +40 -0
  80. generic_ml_cache_core/application/port/out/file_fingerprint_port.py +25 -0
  81. generic_ml_cache_core/application/port/out/metrics_port.py +54 -0
  82. generic_ml_cache_core/application/port/out/passthrough_runner_port.py +25 -0
  83. generic_ml_cache_core/application/usecase/__init__.py +1 -0
  84. generic_ml_cache_core/application/usecase/cached_ml_execution_service.py +198 -0
  85. generic_ml_cache_core/application/usecase/call_identity_building.py +60 -0
  86. generic_ml_cache_core/application/usecase/journal_events.py +19 -0
  87. generic_ml_cache_core/application/usecase/probe_service.py +44 -0
  88. generic_ml_cache_core/application/usecase/run_api_execution_service.py +69 -0
  89. generic_ml_cache_core/application/usecase/run_managed_local_execution_service.py +84 -0
  90. generic_ml_cache_core/application/usecase/run_passthrough_execution_service.py +67 -0
  91. generic_ml_cache_core/common/__init__.py +1 -0
  92. generic_ml_cache_core/common/checksum.py +82 -0
  93. generic_ml_cache_core/common/errors.py +76 -0
  94. generic_ml_cache_core/stream.py +65 -0
  95. generic_ml_cache_core-0.2.0.dist-info/METADATA +104 -0
  96. generic_ml_cache_core-0.2.0.dist-info/RECORD +99 -0
  97. generic_ml_cache_core-0.2.0.dist-info/WHEEL +4 -0
  98. generic_ml_cache_core-0.2.0.dist-info/licenses/LICENSE +201 -0
  99. generic_ml_cache_core-0.2.0.dist-info/licenses/NOTICE +8 -0
@@ -0,0 +1,208 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Adapter for the Cursor agent CLI.
4
+
5
+ Cursor bakes reasoning effort into the model id, so ``effort`` is appended to
6
+ the model string. Best-effort for v0.0.1; correct here as needed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import List, Optional
13
+
14
+ from generic_ml_cache_core.application.domain.model.parsed_output import ParsedOutput
15
+ from generic_ml_cache_core.application.domain.model.usage.usage import Usage, int_or_none
16
+ from generic_ml_cache_core.application.port.out.base import (
17
+ ClientAdapter,
18
+ ModelInfo,
19
+ ensure_trailing_newline,
20
+ final_result_object,
21
+ )
22
+
23
+
24
+ class CursorAdapter(ClientAdapter):
25
+ name = "cursor"
26
+ default_executable = "cursor-agent"
27
+
28
+ def build_argv(
29
+ self,
30
+ executable,
31
+ run_dir,
32
+ model,
33
+ effort,
34
+ context,
35
+ prompt,
36
+ system_prompt,
37
+ client_args=(),
38
+ grants=(),
39
+ ) -> List[str]:
40
+ # cursor-agent takes the prompt ONLY as a positional argument -- its CLI has
41
+ # no stdin/file path for the prompt (verified against `cursor-agent --help`:
42
+ # `[prompt...]`, no `-`, no --prompt-file), unlike claude and codex. Feeding
43
+ # the prompt on stdin makes it hang waiting for a positional it never gets.
44
+ # So the prompt stays in argv, which means a cursor prompt is bounded by the
45
+ # OS argument-size limit (~128 KiB/arg on Linux, ~32 KB whole command line on
46
+ # Windows); claude and codex have no such ceiling because they read the
47
+ # prompt from stdin.
48
+ #
49
+ # Current cursor-agent has NO system-prompt flag (removed) and headless
50
+ # --print ignores workspace rule files (.cursor/rules, .cursorrules,
51
+ # AGENTS.md) -- both verified against the live CLI -- so the prime directive
52
+ # (system prompt) and context are folded into the prompt argument itself.
53
+ # None of this enters the Request, so input_data and the cache key are
54
+ # unchanged: cursor keys identically to claude/codex.
55
+ segments = [system_prompt] if system_prompt else []
56
+ if context:
57
+ segments.append(context)
58
+ segments.append(prompt)
59
+ full_prompt = "\n\n".join(segments)
60
+ # Cursor encodes effort in the model id. Pass a full id from --list-models
61
+ # with no effort (preferred), or a base id plus an effort to append. Do not
62
+ # pass both, or the effort is duplicated.
63
+ model_id = f"{model}-{effort}" if effort else model
64
+ # Capability doors (read/write/shell/web-search) now live in
65
+ # $CURSOR_CONFIG_DIR/cli-config.json written by grant_setup. --trust stays
66
+ # here: it is workspace-trust transport (accept the ephemeral run folder),
67
+ # not a capability. The net grant's external-egress flag (--force) is added
68
+ # by grant_argv, because Cursor's sandbox network is not file-addressable
69
+ # headless -- see grant_argv.
70
+ return [
71
+ executable,
72
+ *self.write_access_argv(run_dir),
73
+ "--model",
74
+ model_id,
75
+ "--print",
76
+ # Streaming output (NDJSON) so a live consumer can watch progress; the
77
+ # recorded answer + usage come from the final `result` event, which is
78
+ # identical to the old single-object json (proven against the live CLI),
79
+ # so the stored output is unchanged. The prompt stays the trailing positional.
80
+ "--output-format",
81
+ "stream-json",
82
+ # Passthrough args before the prompt: cursor-agent's prompt is a
83
+ # trailing (variadic) positional, so anything after it is read as prompt
84
+ # text, not a flag. Spliced here verbatim, uninterpreted.
85
+ *client_args,
86
+ full_prompt,
87
+ ]
88
+
89
+ def parse_output(self, stdout: str) -> ParsedOutput:
90
+ """Cursor's ``--output-format json`` is a single object: ``result`` is the
91
+ answer text and ``usage`` (camelCase keys) holds the token counts. Cursor
92
+ reports input/output and both cache directions, but no reasoning split and
93
+ no cost.
94
+ """
95
+ try:
96
+ doc = final_result_object(stdout)
97
+ if not isinstance(doc, dict):
98
+ raise ValueError("no result object")
99
+ except (json.JSONDecodeError, ValueError):
100
+ return ParsedOutput(text=stdout, usage=None)
101
+
102
+ text = doc.get("result")
103
+ if not isinstance(text, str):
104
+ return ParsedOutput(text=stdout, usage=None)
105
+
106
+ block = doc.get("usage") if isinstance(doc.get("usage"), dict) else None
107
+ usage = None
108
+ if block is not None:
109
+ usage = Usage(
110
+ input_tokens=int_or_none(block.get("inputTokens")),
111
+ output_tokens=int_or_none(block.get("outputTokens")),
112
+ cache_read_tokens=int_or_none(block.get("cacheReadTokens")),
113
+ cache_write_tokens=int_or_none(block.get("cacheWriteTokens")),
114
+ reasoning_tokens=None,
115
+ cost_usd=None,
116
+ raw=dict(block),
117
+ )
118
+ return ParsedOutput(text=ensure_trailing_newline(text), usage=usage)
119
+
120
+ def write_access_argv(self, run_dir):
121
+ # cursor-agent refuses an untrusted workspace ("Workspace Trust Required")
122
+ # in the isolated run folder. --trust accepts it; in --print mode the agent
123
+ # already has its write tool, so trust alone is sufficient to write (the
124
+ # separate --force is not needed). Reads outside the folder are unaffected.
125
+ # Verified against cursor-agent --print on the live CLI.
126
+ return ["--trust"]
127
+
128
+ def grant_setup(self, run_dir, config_home, grants):
129
+ # Uniform door: write $CURSOR_CONFIG_DIR/cli-config.json so the FILE enables
130
+ # capabilities. The project-level permission file was stripped by a security
131
+ # fix (GHSA-v64q-396f-7m79), so we redirect the config home instead. Write
132
+ # is always on (the record-path guarantee). Cursor has no file-level read
133
+ # *deny* headless -- a documented limit, not a door we close. Cursor folds
134
+ # web search into fetch, so web-search maps to WebFetch. net needs the shell
135
+ # (to reach the network) plus fetch; its external egress is opened by
136
+ # grant_argv. The cache enables (docs/reference/grants.md).
137
+ allow = ["Write(**)"]
138
+ if "read" in grants:
139
+ allow.append("Read(**)")
140
+ if "shell" in grants or "net" in grants:
141
+ allow.append("Shell(**)")
142
+ if "net" in grants or "web-search" in grants:
143
+ allow.append("WebFetch(**)")
144
+ # de-dup, preserve order
145
+ seen, ordered = set(), []
146
+ for tok in allow:
147
+ if tok not in seen:
148
+ seen.add(tok)
149
+ ordered.append(tok)
150
+ config_home.mkdir(parents=True, exist_ok=True)
151
+ config = {"version": 1, "permissions": {"allow": ordered}}
152
+ (config_home / "cli-config.json").write_text(json.dumps(config), encoding="utf-8")
153
+ return {"CURSOR_CONFIG_DIR": str(config_home)}
154
+
155
+ def grant_argv(self, grants):
156
+ # Cursor's sandbox blocks external network egress and its sandbox.json
157
+ # networkPolicy is IGNORED under headless --print (upstream bug), so the
158
+ # file cannot open the network. The verified headless egress lever is
159
+ # --force ("Force allow commands unless explicitly denied"; --yolo is its
160
+ # alias). So net = the file's Shell/WebFetch allow PLUS this forced flag.
161
+ # Transport forced by the client, not a capability door (docs/reference/grants.md).
162
+ return ["--force"] if "net" in grants else []
163
+
164
+ def models_argv(self, executable: str) -> Optional[List[str]]:
165
+ return [executable, "--list-models"]
166
+
167
+ def parse_model_list(self, stdout: str) -> List[ModelInfo]:
168
+ """Parse ``cursor-agent --list-models`` output.
169
+
170
+ Each model is one ``<id> - <Label>`` line. A header line and a trailing
171
+ ``Tip:`` line are ignored; a ``(default)``/``(current)`` marker on the
172
+ label is lifted into a flag. The id is taken verbatim -- it is exactly
173
+ what a caller passes to ``--model``.
174
+ """
175
+ models: List[ModelInfo] = []
176
+ for raw in stdout.splitlines():
177
+ line = raw.strip()
178
+ if not line or " - " not in line:
179
+ continue
180
+ if line.lower().startswith("available models") or line.startswith("Tip:"):
181
+ continue
182
+ ident, _, label = line.partition(" - ")
183
+ ident, label = ident.strip(), label.strip()
184
+ if not ident:
185
+ continue
186
+ default = current = False
187
+ if label.endswith("(default)"):
188
+ default, label = True, label[: -len("(default)")].strip()
189
+ elif label.endswith("(current)"):
190
+ current, label = True, label[: -len("(current)")].strip()
191
+ models.append(ModelInfo(id=ident, name=label, default=default, current=current))
192
+ return models
193
+
194
+ def stream_event(self, raw_line):
195
+ try:
196
+ d = json.loads(raw_line)
197
+ except (json.JSONDecodeError, ValueError):
198
+ return None
199
+ if not isinstance(d, dict):
200
+ return None
201
+ t = d.get("type")
202
+ if t == "system" and d.get("subtype") == "init":
203
+ return {"kind": "start"}
204
+ if t == "assistant":
205
+ return {"kind": "message"}
206
+ if t == "result":
207
+ return {"kind": "result"}
208
+ return None
@@ -0,0 +1,121 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Client discovery: report which configured clients are present and runnable.
4
+
5
+ This is **read-only and advisory**. Discovery never chooses a client, never
6
+ restricts which model may run, and never gates a call -- it only reports what it
7
+ found on this machine. The run is always the validator.
8
+
9
+ It is the detection half of "detection, not selection": the cache can tell a
10
+ caller *what is here*; deciding *what to use* stays with the caller.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import subprocess
16
+ from typing import Dict, List, Optional, Tuple
17
+
18
+ from generic_ml_cache_core.adapter.out.client.registry import get_adapter, registered_names
19
+ from generic_ml_cache_core.application.domain.model.client_status import (
20
+ ClientStatus as ClientStatus,
21
+ )
22
+ from generic_ml_cache_core.application.domain.model.model_listing import (
23
+ ModelListing as ModelListing,
24
+ )
25
+ from generic_ml_cache_core.common.errors import ClientNotFound
26
+
27
+
28
+ def _probe_version(argv: List[str], timeout: float) -> Tuple[Optional[str], Optional[str]]:
29
+ try:
30
+ proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
31
+ except Exception as exc: # noqa: BLE001 - any launch failure just means "unknown"
32
+ return None, f"version check failed: {exc}"
33
+ out = (proc.stdout or "").strip() or (proc.stderr or "").strip()
34
+ first = out.splitlines()[0].strip() if out else ""
35
+ return (first or None), (None if first else "no version output")
36
+
37
+
38
+ def probe(name: str, executable: Optional[str] = None, timeout: float = 10.0) -> ClientStatus:
39
+ """Probe one registered client: is its executable present, and what version?
40
+
41
+ Never raises for an absent client -- absence is reported in the result.
42
+ """
43
+ adapter = get_adapter(name)
44
+ try:
45
+ exe = adapter.resolve_executable(executable)
46
+ except ClientNotFound as exc:
47
+ return ClientStatus(name=name, present=False, detail=str(exc))
48
+ version, detail = _probe_version(adapter.version_argv(exe), timeout)
49
+ return ClientStatus(name=name, present=True, executable=exe, version=version, detail=detail)
50
+
51
+
52
+ def probe_all(
53
+ timeout: float = 10.0, executables: Optional[Dict[str, str]] = None
54
+ ) -> List[ClientStatus]:
55
+ """Probe every registered client, in name order.
56
+
57
+ ``executables`` optionally maps a client name to the executable to probe
58
+ (e.g. from the ``[executables]`` config); a client absent from the mapping
59
+ falls back to its adapter's own ``PATH`` lookup.
60
+ """
61
+ exe = executables or {}
62
+ return [probe(name, executable=exe.get(name), timeout=timeout) for name in registered_names()]
63
+
64
+
65
+ def list_models(name: str, executable: Optional[str] = None, timeout: float = 30.0) -> ModelListing:
66
+ """List one client's models by relaying its own listing command.
67
+
68
+ Never raises for an absent client or a client that cannot enumerate; both
69
+ are reported in the result. A relayed list reflects what the *authenticated*
70
+ client can reach, which is why it is preferred over any static catalog.
71
+ """
72
+ adapter = get_adapter(name)
73
+ try:
74
+ exe = adapter.resolve_executable(executable)
75
+ except ClientNotFound as exc:
76
+ return ModelListing(name=name, present=False, supported=False, reason=str(exc))
77
+
78
+ argv = adapter.models_argv(exe)
79
+ if argv is None:
80
+ return ModelListing(
81
+ name=name,
82
+ present=True,
83
+ supported=False,
84
+ reason="this client has no model-listing command",
85
+ )
86
+
87
+ try:
88
+ proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
89
+ except Exception as exc: # noqa: BLE001 - any launch failure is just "couldn't list"
90
+ return ModelListing(
91
+ name=name, present=True, supported=True, reason=f"model listing failed: {exc}"
92
+ )
93
+
94
+ if proc.returncode != 0:
95
+ err = (proc.stderr or proc.stdout or "").strip().splitlines()
96
+ detail = err[0].strip() if err else f"exit {proc.returncode}"
97
+ return ModelListing(
98
+ name=name,
99
+ present=True,
100
+ supported=True,
101
+ reason=f"client exited {proc.returncode}: {detail}",
102
+ )
103
+
104
+ return ModelListing(
105
+ name=name, present=True, supported=True, models=adapter.parse_model_list(proc.stdout)
106
+ )
107
+
108
+
109
+ def list_models_all(
110
+ timeout: float = 30.0, executables: Optional[Dict[str, str]] = None
111
+ ) -> List[ModelListing]:
112
+ """List models for every registered client, in name order.
113
+
114
+ ``executables`` optionally maps a client name to the executable to use
115
+ (e.g. from the ``[executables]`` config); a client absent from the mapping
116
+ falls back to its adapter's own ``PATH`` lookup.
117
+ """
118
+ exe = executables or {}
119
+ return [
120
+ list_models(name, executable=exe.get(name), timeout=timeout) for name in registered_names()
121
+ ]