generic-ml-wrapper 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.
- generic_ml_wrapper/__init__.py +13 -0
- generic_ml_wrapper/adapter/__init__.py +3 -0
- generic_ml_wrapper/adapter/inbound/__init__.py +3 -0
- generic_ml_wrapper/adapter/inbound/cli/__init__.py +3 -0
- generic_ml_wrapper/adapter/inbound/cli/app.py +369 -0
- generic_ml_wrapper/adapter/inbound/cli/banner.py +30 -0
- generic_ml_wrapper/adapter/outbound/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/bootstrap/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/bootstrap/filesystem_layout_seeder.py +104 -0
- generic_ml_wrapper/adapter/outbound/caller/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/caller/claude_cli_caller.py +158 -0
- generic_ml_wrapper/adapter/outbound/caller/codex_cli_caller.py +165 -0
- generic_ml_wrapper/adapter/outbound/caller/context_file.py +44 -0
- generic_ml_wrapper/adapter/outbound/caller/context_opening.py +27 -0
- generic_ml_wrapper/adapter/outbound/caller/cursor_cli_caller.py +98 -0
- generic_ml_wrapper/adapter/outbound/caller/default_provider.py +79 -0
- generic_ml_wrapper/adapter/outbound/caller/loader.py +34 -0
- generic_ml_wrapper/adapter/outbound/caller/status_line_config.py +142 -0
- generic_ml_wrapper/adapter/outbound/caller/vibe_cli_caller.py +169 -0
- generic_ml_wrapper/adapter/outbound/caller/vibe_config.py +128 -0
- generic_ml_wrapper/adapter/outbound/credentials/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/credentials/filesystem_credentials_store.py +130 -0
- generic_ml_wrapper/adapter/outbound/gateway/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/gateway/anthropic_sse.py +148 -0
- generic_ml_wrapper/adapter/outbound/gateway/openai_chat.py +112 -0
- generic_ml_wrapper/adapter/outbound/gateway/openai_responses.py +85 -0
- generic_ml_wrapper/adapter/outbound/gateway/relay.py +402 -0
- generic_ml_wrapper/adapter/outbound/interceptor/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/interceptor/compressor.py +107 -0
- generic_ml_wrapper/adapter/outbound/interceptor/size_logger.py +32 -0
- generic_ml_wrapper/adapter/outbound/status/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/status/claude_status_parser.py +76 -0
- generic_ml_wrapper/adapter/outbound/status/cursor_status_parser.py +71 -0
- generic_ml_wrapper/adapter/outbound/store/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/store/filesystem_transcript_store.py +65 -0
- generic_ml_wrapper/adapter/outbound/store/ledger.py +104 -0
- generic_ml_wrapper/adapter/outbound/store/sqlite_per_turn_store.py +68 -0
- generic_ml_wrapper/adapter/outbound/store/sqlite_session_store.py +73 -0
- generic_ml_wrapper/adapter/outbound/store/sqlite_usage_store.py +44 -0
- generic_ml_wrapper/adapter/outbound/workflow/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/workflow/filesystem_workflow_source.py +165 -0
- generic_ml_wrapper/adapter/outbound/workspace/__init__.py +3 -0
- generic_ml_wrapper/adapter/outbound/workspace/local_workspace_inspector.py +75 -0
- generic_ml_wrapper/application/__init__.py +3 -0
- generic_ml_wrapper/application/domain/__init__.py +3 -0
- generic_ml_wrapper/application/domain/model/__init__.py +3 -0
- generic_ml_wrapper/application/domain/model/client_status.py +44 -0
- generic_ml_wrapper/application/domain/model/identifiers.py +76 -0
- generic_ml_wrapper/application/domain/model/run.py +35 -0
- generic_ml_wrapper/application/domain/model/session.py +24 -0
- generic_ml_wrapper/application/domain/model/turn_usage.py +62 -0
- generic_ml_wrapper/application/domain/model/workspace.py +31 -0
- generic_ml_wrapper/application/domain/service/__init__.py +3 -0
- generic_ml_wrapper/application/domain/service/interceptor.py +30 -0
- generic_ml_wrapper/application/domain/service/interceptor_chain.py +57 -0
- generic_ml_wrapper/application/domain/service/rule_cleaner.py +35 -0
- generic_ml_wrapper/application/domain/service/session_naming.py +30 -0
- generic_ml_wrapper/application/domain/service/statusline_renderer.py +81 -0
- generic_ml_wrapper/application/port/__init__.py +3 -0
- generic_ml_wrapper/application/port/inbound/__init__.py +3 -0
- generic_ml_wrapper/application/port/inbound/bootstrap.py +15 -0
- generic_ml_wrapper/application/port/inbound/export_usage.py +109 -0
- generic_ml_wrapper/application/port/inbound/list_jobs.py +33 -0
- generic_ml_wrapper/application/port/inbound/list_sessions.py +36 -0
- generic_ml_wrapper/application/port/inbound/list_workflows.py +19 -0
- generic_ml_wrapper/application/port/inbound/new_workflow.py +48 -0
- generic_ml_wrapper/application/port/inbound/render_statusline.py +24 -0
- generic_ml_wrapper/application/port/inbound/set_credential.py +35 -0
- generic_ml_wrapper/application/port/inbound/start_job.py +53 -0
- generic_ml_wrapper/application/port/outbound/__init__.py +3 -0
- generic_ml_wrapper/application/port/outbound/cli_caller.py +97 -0
- generic_ml_wrapper/application/port/outbound/client_status.py +27 -0
- generic_ml_wrapper/application/port/outbound/credentials_store.py +36 -0
- generic_ml_wrapper/application/port/outbound/interceptor.py +25 -0
- generic_ml_wrapper/application/port/outbound/layout_seeder.py +18 -0
- generic_ml_wrapper/application/port/outbound/per_turn_metering.py +38 -0
- generic_ml_wrapper/application/port/outbound/session_store.py +62 -0
- generic_ml_wrapper/application/port/outbound/transcript.py +46 -0
- generic_ml_wrapper/application/port/outbound/usage_store.py +32 -0
- generic_ml_wrapper/application/port/outbound/workflow_source.py +58 -0
- generic_ml_wrapper/application/port/outbound/workspace.py +27 -0
- generic_ml_wrapper/application/usecase/__init__.py +3 -0
- generic_ml_wrapper/application/usecase/bootstrap.py +24 -0
- generic_ml_wrapper/application/usecase/export_usage.py +93 -0
- generic_ml_wrapper/application/usecase/list_jobs.py +31 -0
- generic_ml_wrapper/application/usecase/list_sessions.py +34 -0
- generic_ml_wrapper/application/usecase/list_workflows.py +28 -0
- generic_ml_wrapper/application/usecase/new_workflow.py +109 -0
- generic_ml_wrapper/application/usecase/render_statusline.py +117 -0
- generic_ml_wrapper/application/usecase/set_credential.py +27 -0
- generic_ml_wrapper/application/usecase/start_job.py +125 -0
- generic_ml_wrapper/application/wiring/__init__.py +3 -0
- generic_ml_wrapper/application/wiring/composition.py +230 -0
- generic_ml_wrapper/common/__init__.py +3 -0
- generic_ml_wrapper/common/config.py +179 -0
- generic_ml_wrapper/common/log.py +83 -0
- generic_ml_wrapper/common/paths.py +25 -0
- generic_ml_wrapper/common/spec_loader.py +67 -0
- generic_ml_wrapper/py.typed +0 -0
- generic_ml_wrapper/resources/workflows/_common/base.md +25 -0
- generic_ml_wrapper/resources/workflows/create-workflow/workflow.md +35 -0
- generic_ml_wrapper-0.1.0.dist-info/METADATA +172 -0
- generic_ml_wrapper-0.1.0.dist-info/RECORD +107 -0
- generic_ml_wrapper-0.1.0.dist-info/WHEEL +4 -0
- generic_ml_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
- generic_ml_wrapper-0.1.0.dist-info/licenses/LICENSE +201 -0
- generic_ml_wrapper-0.1.0.dist-info/licenses/NOTICE +8 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The inbound port for listing the jobs with recorded activity."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class JobSummary:
|
|
13
|
+
"""A one-line summary of a job's recorded activity.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
job: The job identifier.
|
|
17
|
+
session_count: How many sessions have been recorded for the job.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
job: str
|
|
21
|
+
session_count: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ListJobs(ABC):
|
|
25
|
+
"""List the jobs that have recorded sessions."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def execute(self) -> list[JobSummary]:
|
|
29
|
+
"""List the jobs with recorded activity.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
One summary per job, sorted by job id.
|
|
33
|
+
"""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The inbound port for listing a job's sessions."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class SessionSummary:
|
|
13
|
+
"""A one-line summary of a recorded session.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
session_id: The session's human-readable id.
|
|
17
|
+
client: The client the session runs on.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
session_id: str
|
|
21
|
+
client: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ListSessions(ABC):
|
|
25
|
+
"""List the sessions recorded for a job."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def execute(self, job: str) -> list[SessionSummary]:
|
|
29
|
+
"""List a job's sessions.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
job: The job identifier.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
One summary per session, oldest first.
|
|
36
|
+
"""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The inbound port for listing the available workflows."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ListWorkflows(ABC):
|
|
11
|
+
"""List the runnable workflows."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def execute(self) -> list[str]:
|
|
15
|
+
"""List the runnable workflow names.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
The workflow names, sorted (empty if none exist).
|
|
19
|
+
"""
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The inbound port for authoring a new workflow."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class NewWorkflowCommand:
|
|
13
|
+
"""A request to author a new workflow.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
name: The new workflow's name (lowercase letters, digits, dashes).
|
|
17
|
+
client: The client to run the authoring session on.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
client: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WorkflowNameError(ValueError):
|
|
25
|
+
"""Raised when a workflow name is invalid or reserved."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WorkflowExistsError(ValueError):
|
|
29
|
+
"""Raised when a workflow with the requested name already exists."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NewWorkflow(ABC):
|
|
33
|
+
"""Author a new workflow through the create-workflow interview."""
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def execute(self, command: NewWorkflowCommand) -> int:
|
|
37
|
+
"""Run the authoring session for a new workflow.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
command: The request describing the workflow name and client.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The client's exit code.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
WorkflowNameError: If the name is invalid or reserved.
|
|
47
|
+
WorkflowExistsError: If a workflow with that name already exists.
|
|
48
|
+
"""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The inbound port for rendering a status line and recording usage."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RenderStatusline(ABC):
|
|
11
|
+
"""Render a client's status line and record its usage."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def execute(self, payload_json: str, job: str | None, session: str | None) -> str:
|
|
15
|
+
"""Parse the client's payload, record usage, and render the status line.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
payload_json: The raw JSON the client piped to the status-line command.
|
|
19
|
+
job: The active job, or ``None`` if unknown (usage is not recorded then).
|
|
20
|
+
session: The active session, or ``None`` if unknown.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The status line to print (may be empty if the client reported nothing).
|
|
24
|
+
"""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The inbound port for storing a workflow credential."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class SetCredentialCommand:
|
|
13
|
+
"""A request to store one credential for a workflow.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
workflow: The workflow the credential belongs to.
|
|
17
|
+
name: The environment-variable name to export at launch.
|
|
18
|
+
value: The secret value.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
workflow: str
|
|
22
|
+
name: str
|
|
23
|
+
value: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SetCredential(ABC):
|
|
27
|
+
"""Store a single workflow credential in the wrapper's own store."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def execute(self, command: SetCredentialCommand) -> None:
|
|
31
|
+
"""Store the credential described by the command.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
command: The workflow, environment-variable name, and secret value.
|
|
35
|
+
"""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The inbound port for starting work on a job."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class StartJobCommand:
|
|
13
|
+
"""A request to start (or resume) a session on a job.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
job: The job identifier.
|
|
17
|
+
client: The client to launch.
|
|
18
|
+
resume_latest: Resume the job's most recent session instead of minting one.
|
|
19
|
+
workflow: A workflow to run on the job, or ``None`` for the plain wrapper.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
job: str
|
|
23
|
+
client: str
|
|
24
|
+
resume_latest: bool = False
|
|
25
|
+
workflow: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UnknownWorkflowError(ValueError):
|
|
29
|
+
"""Raised when a requested workflow does not exist."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ResumeNotSupportedError(ValueError):
|
|
33
|
+
"""Raised when resuming is requested for a client that cannot resume (e.g. codex)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StartJob(ABC):
|
|
37
|
+
"""Start or resume a session on a job and hand over to the client."""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def execute(self, command: StartJobCommand) -> int:
|
|
41
|
+
"""Run the use case.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
command: The request describing job, client, resume, and workflow.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The client's exit code.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
UnknownWorkflowError: If a workflow was requested but does not exist.
|
|
51
|
+
ResumeNotSupportedError: If resume was requested for a client whose
|
|
52
|
+
caller cannot resume a session.
|
|
53
|
+
"""
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The outbound port for launching and metering a client — the CliCaller seam."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
from generic_ml_wrapper.application.domain.model.run import RunContext
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CliCaller(ABC):
|
|
13
|
+
"""Launch and meter one client run.
|
|
14
|
+
|
|
15
|
+
One stateful instance per run: state set up in ``start_metering`` (before
|
|
16
|
+
launch) is used by ``start_client`` and torn down in ``end_metering`` (after
|
|
17
|
+
the client exits). ``start_client`` blocks, so quitting the client is the stop
|
|
18
|
+
signal — the caller runs ``start_metering`` → ``start_client`` → ``end_metering``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, run: RunContext) -> None:
|
|
22
|
+
"""Bind the caller to a run.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
run: The run this caller will launch and meter.
|
|
26
|
+
"""
|
|
27
|
+
self.run = run
|
|
28
|
+
|
|
29
|
+
def can_deliver_statusline(self) -> bool:
|
|
30
|
+
"""Whether this client hosts a status line the wrapper renders into.
|
|
31
|
+
|
|
32
|
+
``True`` only for clients with a command-backed status-line hook (Claude
|
|
33
|
+
Code, cursor-agent); ``False`` for clients that expose no such hook (Codex
|
|
34
|
+
shows fixed built-ins; vibe has its own UI). The wrapper drives its
|
|
35
|
+
status-line rendering only when this is ``True``. Default: ``False``.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
``True`` if the wrapper can render a status line for this client.
|
|
39
|
+
"""
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
def can_meter_per_call(self) -> bool:
|
|
43
|
+
"""Whether this caller records per-turn usage (e.g. via a metering gateway).
|
|
44
|
+
|
|
45
|
+
``True`` only for callers that route the client's traffic through a gateway
|
|
46
|
+
able to read each request/response's token usage. Together with a config
|
|
47
|
+
toggle this gates deep metering: it runs only when the caller can do it and
|
|
48
|
+
the user asked for it. Default: ``False``.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
``True`` if this caller can record per-turn usage.
|
|
52
|
+
"""
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
def can_resume(self) -> bool:
|
|
56
|
+
"""Whether this client can resume a prior session.
|
|
57
|
+
|
|
58
|
+
``True`` for clients whose launch can reopen a named/identified session
|
|
59
|
+
(Claude ``--resume``, cursor-agent ``--resume``, vibe ``--resume``);
|
|
60
|
+
``False`` for clients with no usable resume path (Codex mints a fresh
|
|
61
|
+
conversation and exposes no session id we can target without scanning its
|
|
62
|
+
local store). ``--resume-latest`` is refused when this is ``False`` rather
|
|
63
|
+
than silently starting a new session. Default: ``True``.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
``True`` if this caller can resume a prior session.
|
|
67
|
+
"""
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
def start_metering(self) -> None: # noqa: B027 (optional hook; default no-op by design)
|
|
71
|
+
"""Set up metering before launch. Default: do nothing."""
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def start_client(self) -> int:
|
|
75
|
+
"""Launch the client, blocking until it exits.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The client's exit code.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def end_metering(self) -> None: # noqa: B027 (optional hook; default no-op by design)
|
|
82
|
+
"""Tear down metering after the client exits. Default: do nothing."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CliCallerProvider(ABC):
|
|
86
|
+
"""Resolve the caller to use for a given run."""
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def for_run(self, run: RunContext) -> CliCaller:
|
|
90
|
+
"""Return the caller instance for a run.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
run: The run to launch.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
A caller bound to the run.
|
|
97
|
+
"""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The per-client outbound port for parsing a status-line payload."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
from generic_ml_wrapper.application.domain.model.client_status import ClientStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClientStatusParserPort(ABC):
|
|
13
|
+
"""Parse a client's status-line payload into a client-agnostic ``ClientStatus``.
|
|
14
|
+
|
|
15
|
+
Each client pipes a different payload shape, so this is implemented per client.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def parse(self, payload: dict[str, object]) -> ClientStatus:
|
|
20
|
+
"""Parse a client's status payload.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
payload: The decoded JSON the client piped to the status-line command.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
The parsed status (fields the client omitted are ``None``).
|
|
27
|
+
"""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The outbound port for the wrapper's own credentials store."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CredentialsStorePort(ABC):
|
|
11
|
+
"""Store and resolve per-workflow credentials the wrapper owns.
|
|
12
|
+
|
|
13
|
+
Credentials are grouped by workflow; within a workflow each entry's name is the
|
|
14
|
+
exact environment variable to export at launch, and its value is the secret.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def resolve(self, workflow: str) -> dict[str, str]:
|
|
19
|
+
"""Return a workflow's credentials as an env-var-name to value mapping.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
workflow: The workflow whose credentials to read.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The mapping of environment-variable name to secret (empty if none).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def set(self, workflow: str, name: str, value: str) -> None:
|
|
30
|
+
"""Store one credential for a workflow, replacing any prior value.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
workflow: The workflow the credential belongs to.
|
|
34
|
+
name: The environment-variable name to export at launch.
|
|
35
|
+
value: The secret value.
|
|
36
|
+
"""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The outbound port for an interceptor: a text transform applied at a named target."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC
|
|
8
|
+
|
|
9
|
+
from generic_ml_wrapper.application.domain.service.interceptor import Interceptor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InterceptorPort(Interceptor, ABC):
|
|
13
|
+
"""Outbound port for an interceptor; the contract is the domain :class:`Interceptor`.
|
|
14
|
+
|
|
15
|
+
Interceptors are chained (0..N), ordered, and each targets a name: the
|
|
16
|
+
compile-time context sections (``profile``, ``rules``, ``workflow``, ``context``)
|
|
17
|
+
or, for clients routed through the metering relay, the live wire (``request`` for
|
|
18
|
+
the outbound request body, ``response`` for the captured response body). A logger,
|
|
19
|
+
a compressor, and a secret-anonymiser are all interceptors. An interceptor must be
|
|
20
|
+
non-destructive on failure — it returns the text unchanged rather than raising — so
|
|
21
|
+
a misconfigured interceptor never kills a compile or a turn.
|
|
22
|
+
|
|
23
|
+
Adapters implement :meth:`Interceptor.intercept`; this port exists so the composition
|
|
24
|
+
root can resolve interceptor specs to a stable outbound contract.
|
|
25
|
+
"""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The outbound port for seeding the runtime layout on first run."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LayoutSeederPort(ABC):
|
|
11
|
+
"""Create the wrapper's runtime directories and a default config, missing-only."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def ensure(self) -> None:
|
|
15
|
+
"""Create any missing runtime directories and seed a default config.
|
|
16
|
+
|
|
17
|
+
Idempotent: existing directories and an existing config are left untouched.
|
|
18
|
+
"""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The outbound port for recording per-turn usage a gateway meters."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
from generic_ml_wrapper.application.domain.model.turn_usage import TurnUsage
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PerTurnMeteringPort(ABC):
|
|
13
|
+
"""Persist and read the per-turn token usage a metering gateway observes.
|
|
14
|
+
|
|
15
|
+
This is the deep-metering counterpart to the session-level cost store: where the
|
|
16
|
+
status line records one cumulative cost per session, a gateway records one entry
|
|
17
|
+
per request/response round.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def record(self, job: str, turn: TurnUsage) -> None:
|
|
22
|
+
"""Append one metered turn for a job.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
job: The job the turn belongs to.
|
|
26
|
+
turn: The turn's usage.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def turns_for_job(self, job: str) -> list[TurnUsage]:
|
|
31
|
+
"""Return every recorded turn for a job, in the order recorded.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
job: The job identifier.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The recorded turns (empty if none).
|
|
38
|
+
"""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The outbound port for persisting and reading sessions."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
from generic_ml_wrapper.application.domain.model.session import Session
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SessionStorePort(ABC):
|
|
13
|
+
"""Persist and read the sessions recorded for a job."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def jobs(self) -> list[str]:
|
|
17
|
+
"""Return the ids of all jobs that have recorded sessions.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The job ids, sorted (empty if nothing has been recorded).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def record(self, session: Session) -> None:
|
|
25
|
+
"""Append a session to its job's record.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
session: The session to persist.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def sessions_for_job(self, job: str) -> list[Session]:
|
|
33
|
+
"""Return the sessions recorded for a job, oldest first.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
job: The job identifier.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The sessions, oldest first (empty if the job is unknown).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def ids_for_job(self, job: str) -> list[str]:
|
|
44
|
+
"""Return the session ids recorded for a job, oldest first.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
job: The job identifier.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The session ids, oldest first (empty if the job is unknown).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def latest_for_job(self, job: str) -> Session | None:
|
|
55
|
+
"""Return the most recently recorded session for a job.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
job: The job identifier.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The latest session, or ``None`` if the job has none.
|
|
62
|
+
"""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The outbound port for recording a session's transcript: each call's in/out/usage."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from generic_ml_wrapper.application.domain.model.turn_usage import TurnUsage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class TranscriptCall:
|
|
17
|
+
"""One recorded call: its request, response, usage, and place in the session.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
job: The job the call belongs to.
|
|
21
|
+
session: The session the call belongs to.
|
|
22
|
+
call_seq: The per-session call number (1-based).
|
|
23
|
+
request: The request body forwarded upstream.
|
|
24
|
+
response: The response body returned upstream (raw).
|
|
25
|
+
usage: The turn's usage, or ``None`` if none could be read.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
job: str
|
|
29
|
+
session: str
|
|
30
|
+
call_seq: int
|
|
31
|
+
request: bytes
|
|
32
|
+
response: bytes
|
|
33
|
+
usage: TurnUsage | None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TranscriptPort(ABC):
|
|
37
|
+
"""Persist a session's transcript -- the request, response, and usage of each call.
|
|
38
|
+
|
|
39
|
+
This is the opt-in provenance/cost-ledger counterpart to metering: where metering
|
|
40
|
+
records tokens, the transcript keeps the full request and response too, so a user
|
|
41
|
+
can later see, per call, what went in, what came back, and what it cost.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def record(self, call: TranscriptCall) -> None:
|
|
46
|
+
"""Persist one call's request, response, and usage."""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The outbound port for persisting metered usage."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UsageStorePort(ABC):
|
|
11
|
+
"""Persist and read per-session usage recorded from a client's status payload."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def record_session_cost(self, job: str, session: str, cost_usd: float) -> None:
|
|
15
|
+
"""Record a session's cumulative cost (monotonic: the highest seen wins).
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
job: The job the session belongs to.
|
|
19
|
+
session: The session id.
|
|
20
|
+
cost_usd: The session's cumulative cost in USD.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def session_costs(self, job: str) -> dict[str, float]:
|
|
25
|
+
"""Return the recorded cost per session for a job.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
job: The job identifier.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A mapping of session id to cumulative cost (empty if none recorded).
|
|
32
|
+
"""
|