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,35 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""ProbeCommand."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from generic_ml_cache_core.application.domain.service.cacheability import is_call_uncacheable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ProbeCommand:
|
|
15
|
+
"""The input to the probe use case: the key-determining inputs only.
|
|
16
|
+
|
|
17
|
+
A probe is a read-only forecast, so it carries no run policy (no cache mode,
|
|
18
|
+
no persist/record flags, no system prompt). It exposes the same keyed fields a
|
|
19
|
+
run does, so both derive the same key from the shared builder.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
client: str
|
|
23
|
+
model: str
|
|
24
|
+
effort: str
|
|
25
|
+
context: str
|
|
26
|
+
prompt: str
|
|
27
|
+
input_file_paths: List[str] = field(default_factory=list)
|
|
28
|
+
allow_paths: List[str] = field(default_factory=list)
|
|
29
|
+
scan_trust: bool = False
|
|
30
|
+
client_args: List[str] = field(default_factory=list)
|
|
31
|
+
grants: List[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def is_uncacheable(self) -> bool:
|
|
35
|
+
return is_call_uncacheable(self.allow_paths, self.scan_trust)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""ProbeUseCase (inbound port)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
from generic_ml_cache_core.application.domain.model.probe.probe_report import ProbeReport
|
|
10
|
+
from generic_ml_cache_core.application.port.inbound.probe_command import ProbeCommand
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProbeUseCase(ABC):
|
|
14
|
+
"""Inbound port for the read-only cache probe — a forecast of what a run would
|
|
15
|
+
do, with no side effects (no client launch, no store, no journal event)."""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def execute(self, command: ProbeCommand) -> ProbeReport:
|
|
19
|
+
"""Forecast the verdict for ``command`` without running or recording."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""RunApiExecutionCommand."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from generic_ml_cache_core.application.domain.model.run.cache_mode import CacheMode
|
|
11
|
+
from generic_ml_cache_core.application.domain.model.run.message import Message
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class RunApiExecutionCommand:
|
|
16
|
+
"""The input to the API use case.
|
|
17
|
+
|
|
18
|
+
The caller builds the full message list (there is no local client to read
|
|
19
|
+
files or scan folders), so there are no input-file, allow-path, grant, or
|
|
20
|
+
scan-trust fields. An API call is always cacheable.
|
|
21
|
+
|
|
22
|
+
Note (future): ``persist_output = False`` will be incompatible with async
|
|
23
|
+
execution — an async call must store its output so the caller can retrieve it
|
|
24
|
+
by id later. Async is not built yet, so nothing enforces it here.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
provider: str
|
|
28
|
+
model: str
|
|
29
|
+
messages: List[Message] = field(default_factory=list)
|
|
30
|
+
cache_mode: CacheMode = CacheMode.CACHE
|
|
31
|
+
persist_output: bool = True
|
|
32
|
+
record_on_error: bool = False
|
|
33
|
+
|
|
34
|
+
def should_persist(self, succeeded: bool) -> bool:
|
|
35
|
+
"""Whether this command's policy stores an output for a run that ended
|
|
36
|
+
with ``succeeded``: never without ``persist_output``; a failure only with
|
|
37
|
+
``record_on_error``."""
|
|
38
|
+
if not self.persist_output:
|
|
39
|
+
return False
|
|
40
|
+
return succeeded or self.record_on_error
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""RunApiExecutionUseCase (inbound port)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
from generic_ml_cache_core.application.domain.model.execution.ml_execution import MlExecution
|
|
10
|
+
from generic_ml_cache_core.application.port.inbound.run_api_execution_command import (
|
|
11
|
+
RunApiExecutionCommand,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RunApiExecutionUseCase(ABC):
|
|
16
|
+
"""Inbound port for record-or-replay of a direct ML provider API call."""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def execute(self, command: RunApiExecutionCommand) -> MlExecution:
|
|
20
|
+
"""Resolve the API command and return the resulting execution."""
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""RunManagedLocalExecutionCommand."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from generic_ml_cache_core.application.domain.model.run.cache_mode import CacheMode
|
|
11
|
+
from generic_ml_cache_core.application.domain.service.cacheability import is_call_uncacheable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class RunManagedLocalExecutionCommand:
|
|
16
|
+
"""The input to the managed-local use case: raw user intent only.
|
|
17
|
+
|
|
18
|
+
It carries file *paths* and raw text, never fingerprints — the use case
|
|
19
|
+
reads the files and computes the fingerprints. The use case builds the
|
|
20
|
+
CallIdentity from this command; the command never builds it itself.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
client: str
|
|
24
|
+
model: str
|
|
25
|
+
effort: str
|
|
26
|
+
context: str
|
|
27
|
+
prompt: str
|
|
28
|
+
user_system_prompt: Optional[str] = None
|
|
29
|
+
input_file_paths: List[str] = field(default_factory=list)
|
|
30
|
+
allow_paths: List[str] = field(default_factory=list)
|
|
31
|
+
scan_trust: bool = False
|
|
32
|
+
client_args: List[str] = field(default_factory=list)
|
|
33
|
+
grants: List[str] = field(default_factory=list)
|
|
34
|
+
cache_mode: CacheMode = CacheMode.CACHE
|
|
35
|
+
persist_output: bool = True
|
|
36
|
+
record_on_error: bool = False
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_uncacheable(self) -> bool:
|
|
40
|
+
return is_call_uncacheable(self.allow_paths, self.scan_trust)
|
|
41
|
+
|
|
42
|
+
def should_persist(self, succeeded: bool) -> bool:
|
|
43
|
+
"""Whether this command's policy stores an output for a run that ended
|
|
44
|
+
with ``succeeded``: never without ``persist_output``; a failure only with
|
|
45
|
+
``record_on_error``."""
|
|
46
|
+
if not self.persist_output:
|
|
47
|
+
return False
|
|
48
|
+
return succeeded or self.record_on_error
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""RunManagedLocalExecutionUseCase (inbound port)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
from generic_ml_cache_core.application.domain.model.execution.ml_execution import MlExecution
|
|
10
|
+
from generic_ml_cache_core.application.port.inbound.run_managed_local_execution_command import (
|
|
11
|
+
RunManagedLocalExecutionCommand,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RunManagedLocalExecutionUseCase(ABC):
|
|
16
|
+
"""Inbound port for record-or-replay of a fully managed local client call.
|
|
17
|
+
|
|
18
|
+
The driving adapter (CLI, daemon, library consumer) depends on this contract
|
|
19
|
+
and never on the implementation. The composition root wires the concrete
|
|
20
|
+
service in.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def execute(self, command: RunManagedLocalExecutionCommand) -> MlExecution:
|
|
25
|
+
"""Resolve the command and return the resulting execution."""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""RunPassthroughExecutionCommand."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from generic_ml_cache_core.application.domain.model.run.cache_mode import CacheMode
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class RunPassthroughExecutionCommand:
|
|
15
|
+
"""The input to the passthrough use case.
|
|
16
|
+
|
|
17
|
+
Everything after the client name is opaque: ``native_args`` is forwarded to
|
|
18
|
+
the client verbatim and enters the key as-is (by fingerprint). A passthrough
|
|
19
|
+
is always cacheable (it declares no scan folders), so there is no allow-path
|
|
20
|
+
or scan-trust here.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
client: str
|
|
24
|
+
native_args: List[str] = field(default_factory=list)
|
|
25
|
+
cache_mode: CacheMode = CacheMode.CACHE
|
|
26
|
+
persist_output: bool = True
|
|
27
|
+
record_on_error: bool = False
|
|
28
|
+
|
|
29
|
+
def should_persist(self, succeeded: bool) -> bool:
|
|
30
|
+
"""Whether this command's policy stores an output for a run that ended
|
|
31
|
+
with ``succeeded``: never without ``persist_output``; a failure only with
|
|
32
|
+
``record_on_error``."""
|
|
33
|
+
if not self.persist_output:
|
|
34
|
+
return False
|
|
35
|
+
return succeeded or self.record_on_error
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""RunPassthroughExecutionUseCase (inbound port)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
from generic_ml_cache_core.application.domain.model.execution.ml_execution import MlExecution
|
|
10
|
+
from generic_ml_cache_core.application.port.inbound.run_passthrough_execution_command import (
|
|
11
|
+
RunPassthroughExecutionCommand,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RunPassthroughExecutionUseCase(ABC):
|
|
16
|
+
"""Inbound port for record-or-replay of a passthrough (alias) client call."""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def execute(self, command: RunPassthroughExecutionCommand) -> MlExecution:
|
|
20
|
+
"""Resolve the passthrough command and return the resulting execution."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Hexagonal layer package."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""ApiClientPort."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from generic_ml_cache_core.application.domain.model.run.client_run_result import ClientRunResult
|
|
11
|
+
from generic_ml_cache_core.application.domain.model.run.message import Message
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiClientPort(ABC):
|
|
15
|
+
"""Outbound port for calling an ML provider API directly.
|
|
16
|
+
|
|
17
|
+
Distinct from the local runner ports: there is no subprocess, no filesystem,
|
|
18
|
+
and no grants — the caller has already built the full message list. The
|
|
19
|
+
adapter returns a raw ClientRunResult with the response text as stdout, an
|
|
20
|
+
empty file list, and the provider-reported token usage when available.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def run(self, provider: str, model: str, messages: List[Message]) -> ClientRunResult:
|
|
25
|
+
"""Call ``provider``'s ``model`` with ``messages`` and return the raw
|
|
26
|
+
result. Raises on an unrecoverable transport failure."""
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Client adapters: how to launch one agentic CLI and read its output.
|
|
4
|
+
|
|
5
|
+
An adapter is the *only* place that knows a specific CLI's flags. Everything
|
|
6
|
+
else in the cache works in terms of the neutral quartet
|
|
7
|
+
``(model, effort, prompt, context)`` plus a system prompt.
|
|
8
|
+
|
|
9
|
+
The v0.0.1 adapters below encode each CLI's own launch conventions (Claude
|
|
10
|
+
``--effort``; Codex ``model_reasoning_effort``; Cursor bakes effort into the
|
|
11
|
+
model id). Flags can drift, so every adapter:
|
|
12
|
+
|
|
13
|
+
* accepts an explicit ``executable`` override (the *executable seam*), and
|
|
14
|
+
* keeps launch wiring small and obvious so it is cheap to correct.
|
|
15
|
+
|
|
16
|
+
Adapters never decide caching policy and never read the caller's ambient files.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import shutil
|
|
22
|
+
from abc import ABC, abstractmethod
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import ClassVar, List, Optional, Sequence
|
|
25
|
+
|
|
26
|
+
from generic_ml_cache_core.application.domain.model.model_info import ModelInfo as ModelInfo
|
|
27
|
+
from generic_ml_cache_core.application.domain.model.parsed_output import ParsedOutput
|
|
28
|
+
from generic_ml_cache_core.common.errors import ClientNotFound
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ClientAdapter(ABC):
|
|
32
|
+
"""Translate the neutral request into a concrete subprocess invocation."""
|
|
33
|
+
|
|
34
|
+
#: short client name used in stored records and on the CLI (e.g. "claude")
|
|
35
|
+
name: ClassVar[str]
|
|
36
|
+
#: default executable looked up on PATH when no override is given
|
|
37
|
+
default_executable: ClassVar[str]
|
|
38
|
+
|
|
39
|
+
def resolve_executable(self, override: Optional[str]) -> str:
|
|
40
|
+
"""Return an absolute path to the executable, honoring the seam."""
|
|
41
|
+
candidate = override or self.default_executable
|
|
42
|
+
# An explicit path (contains a separator) is used verbatim if it exists.
|
|
43
|
+
if any(sep in candidate for sep in ("/", "\\")):
|
|
44
|
+
p = Path(candidate)
|
|
45
|
+
if p.exists():
|
|
46
|
+
return str(p)
|
|
47
|
+
raise ClientNotFound(f"executable not found at {candidate!r}")
|
|
48
|
+
found = shutil.which(candidate)
|
|
49
|
+
if not found:
|
|
50
|
+
raise ClientNotFound(
|
|
51
|
+
f"could not find {candidate!r} on PATH; pass --executable to override"
|
|
52
|
+
)
|
|
53
|
+
return found
|
|
54
|
+
|
|
55
|
+
def version_argv(self, executable: str) -> List[str]:
|
|
56
|
+
"""Argv that prints the client's version. Default: ``<exe> --version``.
|
|
57
|
+
|
|
58
|
+
Override only if a client uses a different flag. Advisory: used by the
|
|
59
|
+
``doctor`` command for discovery; it never affects caching.
|
|
60
|
+
"""
|
|
61
|
+
return [executable, "--version"]
|
|
62
|
+
|
|
63
|
+
def models_argv(self, executable: str) -> Optional[List[str]]:
|
|
64
|
+
"""Argv that makes the client list the models it can use, or ``None``.
|
|
65
|
+
|
|
66
|
+
Return ``None`` when the client has no scriptable way to enumerate its
|
|
67
|
+
models -- discovery then reports "not supported" for this client rather
|
|
68
|
+
than inventing or substituting a list. When non-``None``, the output is
|
|
69
|
+
relayed through :meth:`parse_model_list`. Because the client is the one
|
|
70
|
+
already authenticated, a relayed list reflects what *that account* can
|
|
71
|
+
actually reach. Advisory: never selects, restricts, or gates a run.
|
|
72
|
+
"""
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def parse_model_list(self, stdout: str) -> List[ModelInfo]:
|
|
76
|
+
"""Structure the client's raw model-list output into ``ModelInfo``.
|
|
77
|
+
|
|
78
|
+
Only called when :meth:`models_argv` returns a command; override the two
|
|
79
|
+
together. Keep parsing to plain structuring of what the client printed.
|
|
80
|
+
"""
|
|
81
|
+
raise NotImplementedError
|
|
82
|
+
|
|
83
|
+
def prepare(self, run_dir: Path, context: str, prompt: str, system_prompt: str) -> None:
|
|
84
|
+
"""Write any input files the client needs into its isolated folder.
|
|
85
|
+
|
|
86
|
+
Called *before* the pre-run snapshot, so anything written here is part of
|
|
87
|
+
the baseline and is therefore not mistaken for client output. Default:
|
|
88
|
+
no-op (the client receives everything via argv/stdin).
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def build_argv(
|
|
93
|
+
self,
|
|
94
|
+
executable: str,
|
|
95
|
+
run_dir: Path,
|
|
96
|
+
model: str,
|
|
97
|
+
effort: str,
|
|
98
|
+
context: str,
|
|
99
|
+
prompt: str,
|
|
100
|
+
system_prompt: str,
|
|
101
|
+
client_args: List[str],
|
|
102
|
+
grants: Sequence[str] = (),
|
|
103
|
+
) -> List[str]:
|
|
104
|
+
"""Return the full argv to launch the client in ``run_dir``.
|
|
105
|
+
|
|
106
|
+
``client_args`` are passthrough arguments the caller wants appended to the
|
|
107
|
+
launch verbatim -- the cache never interprets them. The adapter places
|
|
108
|
+
them as late as its CLI allows while they are still read as flags: at the
|
|
109
|
+
very end for clients whose prompt arrives on stdin, but **before the
|
|
110
|
+
trailing prompt positional** for a client that takes the prompt in argv
|
|
111
|
+
(otherwise they would be swallowed as prompt text rather than applied).
|
|
112
|
+
|
|
113
|
+
``grants`` are declared capabilities to *open* for this run (e.g. ``"net"``
|
|
114
|
+
for network access). The adapter opens the matching door via
|
|
115
|
+
:meth:`grant_setup` (a config-file mechanism) when granted. Grants enable;
|
|
116
|
+
they never restrict (see ``docs/reference/grants.md``).
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def stdin_payload(self, context: str, prompt: str, system_prompt: str) -> Optional[str]:
|
|
120
|
+
"""Optional text to feed on stdin. Default: nothing."""
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def parse_output(self, stdout: str) -> ParsedOutput:
|
|
124
|
+
"""Lift the clean answer text and the usage envelope out of the client's
|
|
125
|
+
raw stdout.
|
|
126
|
+
|
|
127
|
+
The cache runs each client in its **structured (JSON) output mode** so it
|
|
128
|
+
can read usage -- which means raw stdout is no longer the bare answer but a
|
|
129
|
+
JSON object (or JSON-lines stream) with the answer as one field and the
|
|
130
|
+
token counts beside it. The adapter is the only place that knows its own
|
|
131
|
+
client's structure, so it does the extraction here: it returns the answer
|
|
132
|
+
text (which the cache then hands the caller on stdout, exactly as a plain
|
|
133
|
+
client would) and the normalized :class:`~..usage.Usage` it read.
|
|
134
|
+
|
|
135
|
+
Default: the client was *not* run in a structured mode, so stdout already
|
|
136
|
+
*is* the answer and there is no usage to read. Adapters that switch their
|
|
137
|
+
client to JSON override this.
|
|
138
|
+
|
|
139
|
+
An override MUST degrade rather than raise: if the output cannot be parsed
|
|
140
|
+
(an unexpected shape, a truncated stream), return ``ParsedOutput(stdout,
|
|
141
|
+
None)`` so a parsing surprise never breaks the core call -- the caller
|
|
142
|
+
still gets the client's output, just without a usage envelope.
|
|
143
|
+
"""
|
|
144
|
+
return ParsedOutput(text=stdout, usage=None)
|
|
145
|
+
|
|
146
|
+
def read_access_argv(self, paths: List[str]) -> List[str]:
|
|
147
|
+
"""Extra argv granting the client read access to ``paths`` (directories).
|
|
148
|
+
|
|
149
|
+
Default: none -- the client relies on the soft prime-directive door only.
|
|
150
|
+
Adapters with a real per-client read mechanism override this (Claude:
|
|
151
|
+
``--add-dir``). Codex and Cursor have one too but it is heterogeneous and
|
|
152
|
+
currently unverified, so they stay on the directive until adapter
|
|
153
|
+
hardening verifies them against the live CLIs.
|
|
154
|
+
"""
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
def write_access_argv(self, run_dir: Path) -> List[str]:
|
|
158
|
+
"""Extra argv opening the client's WRITE/TRUST door for its own ``run_dir``.
|
|
159
|
+
|
|
160
|
+
Headless clients refuse to write by default: they pause on a permission
|
|
161
|
+
prompt, or decline a workspace they have not been told to trust. Without
|
|
162
|
+
this the client only *narrates* the file it was asked to produce, the
|
|
163
|
+
before/after diff captures nothing, and a file-producing call records an
|
|
164
|
+
empty ``response.files`` -- the v0.0.5 record-path bug this fixes.
|
|
165
|
+
|
|
166
|
+
The run folder is the client's own isolated, ephemeral sandbox, so writing
|
|
167
|
+
into it is the normal case and the grant is **on by default**. It is scoped
|
|
168
|
+
to that folder: reads *outside* it are unaffected and remain gated by the
|
|
169
|
+
prime directive and :meth:`read_access_argv`. Unlike ``read_access_argv``
|
|
170
|
+
(appended after :meth:`build_argv`), each adapter splices this into
|
|
171
|
+
``build_argv`` itself, because some CLIs take the prompt as a trailing
|
|
172
|
+
positional and reject flags placed after it.
|
|
173
|
+
|
|
174
|
+
Default: none. The per-client flags below are verified against the live
|
|
175
|
+
CLIs (see ``docs/client-mapping.md``); adapter hardening keeps them small
|
|
176
|
+
and correctable should a CLI change.
|
|
177
|
+
"""
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
#: capabilities the cache can OPEN via the uniform config-file mechanism.
|
|
181
|
+
GRANTS: ClassVar[tuple] = ("net", "read", "write", "shell", "web-search")
|
|
182
|
+
|
|
183
|
+
def grant_setup(self, run_dir: Path, config_home: Path, grants: Sequence[str]) -> dict:
|
|
184
|
+
"""Render this client's own config file into ``config_home`` so the file --
|
|
185
|
+
not a flag -- enables the granted capabilities, and return the environment
|
|
186
|
+
the run needs (the client's config-home variable pointed at ``config_home``,
|
|
187
|
+
e.g. ``{"CODEX_HOME": ...}``), seeding any credentials the relocated home
|
|
188
|
+
needs along the way.
|
|
189
|
+
|
|
190
|
+
This is the uniform door (v0.0.16): every client is driven the same way --
|
|
191
|
+
a private config home, its home variable pointed at it, the settings file
|
|
192
|
+
written inside. Writing into the run folder is always on (the client cannot
|
|
193
|
+
produce output otherwise); the named grants open capability *beyond* that.
|
|
194
|
+
Grants ENABLE, never restrict (``docs/reference/grants.md``); where a client has no
|
|
195
|
+
file-level way to *close* a capability, that is a documented limit, not a
|
|
196
|
+
door this method tries to shut.
|
|
197
|
+
|
|
198
|
+
``config_home`` is separate from ``run_dir``, so nothing written here is
|
|
199
|
+
ever mistaken for client output or captured into a stored record. Default: no
|
|
200
|
+
config home, empty env (adapters override).
|
|
201
|
+
"""
|
|
202
|
+
return {}
|
|
203
|
+
|
|
204
|
+
def grant_argv(self, grants: Sequence[str]) -> List[str]:
|
|
205
|
+
"""Operational flags a client FORCES for a grant its config file cannot
|
|
206
|
+
express -- not a capability door, a transport necessity (Cursor's external
|
|
207
|
+
network egress is ignored in its sandbox file headless, so ``net`` still
|
|
208
|
+
needs ``--force``). Default: none.
|
|
209
|
+
"""
|
|
210
|
+
return []
|
|
211
|
+
|
|
212
|
+
def stream_event(self, raw_line: str) -> Optional[dict]:
|
|
213
|
+
"""Map ONE raw stdout line of this client's streaming output into a small
|
|
214
|
+
normalized progress event, e.g. ``{"kind": "thinking"}`` or
|
|
215
|
+
``{"kind": "tool", "name": "web_search"}`` -- or ``None`` to ignore the
|
|
216
|
+
line. Used only to feed the opt-in live stream (``--stream``; see
|
|
217
|
+
``stream.py``); it never affects what is recorded. Default: ignore every
|
|
218
|
+
line (a client whose stream we do not normalize still shows the cache's own
|
|
219
|
+
``run.start`` / ``run.end`` events).
|
|
220
|
+
"""
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def final_result_object(stdout: str):
|
|
225
|
+
"""Return the client's final result object, whether its output arrived as a
|
|
226
|
+
single JSON object (``--output-format json``) or as the last ``type:result``
|
|
227
|
+
line of an NDJSON stream (``--output-format stream-json``).
|
|
228
|
+
|
|
229
|
+
Claude and Cursor emit *the same* result object in both forms, so the recorded
|
|
230
|
+
answer and usage are identical either way -- this is what lets the live stream
|
|
231
|
+
switch the client to streaming mode without changing the stored record. Returns
|
|
232
|
+
``None`` if nothing parseable is present (the adapter then degrades to raw
|
|
233
|
+
stdout with no usage).
|
|
234
|
+
"""
|
|
235
|
+
import json
|
|
236
|
+
|
|
237
|
+
text = stdout.strip()
|
|
238
|
+
if not text:
|
|
239
|
+
return None
|
|
240
|
+
# Single object (today's --output-format json): parses whole, in one shot.
|
|
241
|
+
try:
|
|
242
|
+
doc = json.loads(text)
|
|
243
|
+
if isinstance(doc, dict):
|
|
244
|
+
return doc
|
|
245
|
+
except (json.JSONDecodeError, ValueError):
|
|
246
|
+
pass # not a single object -> it is an NDJSON stream; scan for the result
|
|
247
|
+
last = None
|
|
248
|
+
for line in stdout.splitlines():
|
|
249
|
+
line = line.strip()
|
|
250
|
+
if not line:
|
|
251
|
+
continue
|
|
252
|
+
try:
|
|
253
|
+
event = json.loads(line)
|
|
254
|
+
except (json.JSONDecodeError, ValueError):
|
|
255
|
+
continue
|
|
256
|
+
if isinstance(event, dict) and event.get("type") == "result":
|
|
257
|
+
last = event
|
|
258
|
+
return last
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def ensure_trailing_newline(text: str) -> str:
|
|
262
|
+
"""Append a newline to a client's answer when it lacks one.
|
|
263
|
+
|
|
264
|
+
A client's structured (JSON) ``result`` carries the bare answer text, without
|
|
265
|
+
the trailing newline a real CLI prints when it shows that answer. Without this
|
|
266
|
+
the replayed answer butts against the next shell prompt, and a piped capture
|
|
267
|
+
(``gmlcache run ... > file``) lacks the conventional final newline. Normalizing
|
|
268
|
+
here -- at the adapter boundary -- keeps record and replay byte-identical (the
|
|
269
|
+
recorded form simply includes the newline). Empty text is left untouched."""
|
|
270
|
+
if text and not text.endswith("\n"):
|
|
271
|
+
return text + "\n"
|
|
272
|
+
return text
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""BlobStorePort."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BlobStorePort(ABC):
|
|
12
|
+
"""Outbound port for storing and retrieving opaque execution output bytes.
|
|
13
|
+
|
|
14
|
+
The store is intentionally dumb: it translates a key to its own address and
|
|
15
|
+
reads/writes bytes. It never parses a payload, never computes a key, and
|
|
16
|
+
never interprets content. This is what makes it swappable (filesystem, S3,
|
|
17
|
+
in-memory) without touching the core.
|
|
18
|
+
|
|
19
|
+
The key is always produced by CallIdentity.generate_key().
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def get(self, key: str) -> Optional[bytes]:
|
|
24
|
+
"""Return the stored bytes for ``key``, or None if not present."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def put(self, key: str, output: bytes) -> None:
|
|
28
|
+
"""Persist ``output`` under ``key``, overwriting any prior value."""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def remove(self, key: str) -> None:
|
|
32
|
+
"""Delete the bytes stored under ``key``; a no-op if nothing is stored.
|
|
33
|
+
|
|
34
|
+
Removal is driven by a reference-counted prune (a blob is content-
|
|
35
|
+
addressed and may be shared by many executions), so a caller removes a
|
|
36
|
+
key only after confirming no execution still references it.
|
|
37
|
+
"""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""ClientRunnerPort."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
from generic_ml_cache_core.application.domain.model.run.client_run_request import ClientRunRequest
|
|
10
|
+
from generic_ml_cache_core.application.domain.model.run.client_run_result import ClientRunResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClientRunnerPort(ABC):
|
|
14
|
+
"""Outbound port for launching a local ML client and capturing its output.
|
|
15
|
+
|
|
16
|
+
The adapter is the only place that knows a specific client's CLI flags,
|
|
17
|
+
isolation mechanism, and output format. The core names only this contract.
|
|
18
|
+
The runner returns a raw ``ClientRunResult`` — it never hashes, never
|
|
19
|
+
computes a key, never stores; turning the result into stored artifacts is
|
|
20
|
+
the use case's job.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def run(self, client_run_request: ClientRunRequest) -> ClientRunResult:
|
|
25
|
+
"""Launch the client described by ``client_run_request`` and return its
|
|
26
|
+
raw captured result. Raises on unrecoverable launch failure."""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""ClockPort."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ClockPort(ABC):
|
|
12
|
+
"""Outbound port for reading the current time.
|
|
13
|
+
|
|
14
|
+
Time is ambient I/O, so the core never calls ``datetime.now()`` directly —
|
|
15
|
+
it reads the clock through this port. The composition root injects a real
|
|
16
|
+
clock; a test injects a fixed one. This keeps the engine deterministic and
|
|
17
|
+
free of hidden wall-clock dependencies.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def now(self) -> datetime:
|
|
22
|
+
"""Return the current instant as a timezone-aware UTC datetime."""
|