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.
- generic_ml_cache_core/__init__.py +64 -0
- generic_ml_cache_core/adapter/__init__.py +1 -0
- generic_ml_cache_core/adapter/inbound/__init__.py +1 -0
- generic_ml_cache_core/adapter/inbound/composition.py +96 -0
- generic_ml_cache_core/adapter/out/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/api/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/api/stub_api_client_adapter.py +30 -0
- generic_ml_cache_core/adapter/out/client/__init__.py +28 -0
- generic_ml_cache_core/adapter/out/client/claude.py +214 -0
- generic_ml_cache_core/adapter/out/client/codex.py +171 -0
- generic_ml_cache_core/adapter/out/client/cursor.py +208 -0
- generic_ml_cache_core/adapter/out/client/discover.py +121 -0
- generic_ml_cache_core/adapter/out/client/isolation.py +396 -0
- generic_ml_cache_core/adapter/out/client/local_client_runner.py +54 -0
- generic_ml_cache_core/adapter/out/client/passthrough_client_runner.py +47 -0
- generic_ml_cache_core/adapter/out/client/prime_directive.py +53 -0
- generic_ml_cache_core/adapter/out/client/registry.py +34 -0
- generic_ml_cache_core/adapter/out/clock/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/clock/system_clock.py +16 -0
- generic_ml_cache_core/adapter/out/fingerprint/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/fingerprint/filesystem_file_fingerprint.py +30 -0
- generic_ml_cache_core/adapter/out/metrics/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/metrics/access_registry.py +147 -0
- generic_ml_cache_core/adapter/out/metrics/journal_metrics.py +45 -0
- generic_ml_cache_core/adapter/out/persistence/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/persistence/call_identity_serialization.py +100 -0
- generic_ml_cache_core/adapter/out/persistence/in_memory_execution_repository.py +69 -0
- generic_ml_cache_core/adapter/out/persistence/sqlite_execution_repository.py +398 -0
- generic_ml_cache_core/adapter/out/storage/__init__.py +1 -0
- generic_ml_cache_core/adapter/out/storage/filesystem_blob_store.py +47 -0
- generic_ml_cache_core/application/__init__.py +1 -0
- generic_ml_cache_core/application/domain/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/client_status.py +17 -0
- generic_ml_cache_core/application/domain/model/execution/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/execution/artifact.py +78 -0
- generic_ml_cache_core/application/domain/model/execution/execution_failure.py +32 -0
- generic_ml_cache_core/application/domain/model/execution/execution_kind.py +26 -0
- generic_ml_cache_core/application/domain/model/execution/execution_state.py +21 -0
- generic_ml_cache_core/application/domain/model/execution/ml_execution.py +41 -0
- generic_ml_cache_core/application/domain/model/identity/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/identity/api_call_identity.py +36 -0
- generic_ml_cache_core/application/domain/model/identity/call_identity.py +25 -0
- generic_ml_cache_core/application/domain/model/identity/managed_call_identity.py +54 -0
- generic_ml_cache_core/application/domain/model/identity/passthrough_call_identity.py +35 -0
- generic_ml_cache_core/application/domain/model/model_info.py +20 -0
- generic_ml_cache_core/application/domain/model/model_listing.py +29 -0
- generic_ml_cache_core/application/domain/model/parsed_output.py +23 -0
- generic_ml_cache_core/application/domain/model/probe/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/probe/probe_report.py +26 -0
- generic_ml_cache_core/application/domain/model/probe/probe_status.py +13 -0
- generic_ml_cache_core/application/domain/model/run/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/run/cache_mode.py +21 -0
- generic_ml_cache_core/application/domain/model/run/client_run_request.py +35 -0
- generic_ml_cache_core/application/domain/model/run/client_run_result.py +65 -0
- generic_ml_cache_core/application/domain/model/run/message.py +20 -0
- generic_ml_cache_core/application/domain/model/usage/__init__.py +1 -0
- generic_ml_cache_core/application/domain/model/usage/token_usage.py +53 -0
- generic_ml_cache_core/application/domain/model/usage/usage.py +108 -0
- generic_ml_cache_core/application/domain/service/__init__.py +1 -0
- generic_ml_cache_core/application/domain/service/cacheability.py +19 -0
- generic_ml_cache_core/application/domain/service/message_fingerprinting.py +25 -0
- generic_ml_cache_core/application/port/__init__.py +1 -0
- generic_ml_cache_core/application/port/inbound/__init__.py +1 -0
- generic_ml_cache_core/application/port/inbound/probe_command.py +35 -0
- generic_ml_cache_core/application/port/inbound/probe_use_case.py +19 -0
- generic_ml_cache_core/application/port/inbound/run_api_execution_command.py +40 -0
- generic_ml_cache_core/application/port/inbound/run_api_execution_use_case.py +20 -0
- generic_ml_cache_core/application/port/inbound/run_managed_local_execution_command.py +48 -0
- generic_ml_cache_core/application/port/inbound/run_managed_local_execution_use_case.py +25 -0
- generic_ml_cache_core/application/port/inbound/run_passthrough_execution_command.py +35 -0
- generic_ml_cache_core/application/port/inbound/run_passthrough_execution_use_case.py +20 -0
- generic_ml_cache_core/application/port/out/__init__.py +1 -0
- generic_ml_cache_core/application/port/out/api_client_port.py +26 -0
- generic_ml_cache_core/application/port/out/base.py +272 -0
- generic_ml_cache_core/application/port/out/blob_store_port.py +37 -0
- generic_ml_cache_core/application/port/out/client_runner_port.py +26 -0
- generic_ml_cache_core/application/port/out/clock_port.py +22 -0
- generic_ml_cache_core/application/port/out/execution_repository_port.py +40 -0
- generic_ml_cache_core/application/port/out/file_fingerprint_port.py +25 -0
- generic_ml_cache_core/application/port/out/metrics_port.py +54 -0
- generic_ml_cache_core/application/port/out/passthrough_runner_port.py +25 -0
- generic_ml_cache_core/application/usecase/__init__.py +1 -0
- generic_ml_cache_core/application/usecase/cached_ml_execution_service.py +198 -0
- generic_ml_cache_core/application/usecase/call_identity_building.py +60 -0
- generic_ml_cache_core/application/usecase/journal_events.py +19 -0
- generic_ml_cache_core/application/usecase/probe_service.py +44 -0
- generic_ml_cache_core/application/usecase/run_api_execution_service.py +69 -0
- generic_ml_cache_core/application/usecase/run_managed_local_execution_service.py +84 -0
- generic_ml_cache_core/application/usecase/run_passthrough_execution_service.py +67 -0
- generic_ml_cache_core/common/__init__.py +1 -0
- generic_ml_cache_core/common/checksum.py +82 -0
- generic_ml_cache_core/common/errors.py +76 -0
- generic_ml_cache_core/stream.py +65 -0
- generic_ml_cache_core-0.2.0.dist-info/METADATA +104 -0
- generic_ml_cache_core-0.2.0.dist-info/RECORD +99 -0
- generic_ml_cache_core-0.2.0.dist-info/WHEEL +4 -0
- generic_ml_cache_core-0.2.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
]
|