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.
Files changed (107) hide show
  1. generic_ml_wrapper/__init__.py +13 -0
  2. generic_ml_wrapper/adapter/__init__.py +3 -0
  3. generic_ml_wrapper/adapter/inbound/__init__.py +3 -0
  4. generic_ml_wrapper/adapter/inbound/cli/__init__.py +3 -0
  5. generic_ml_wrapper/adapter/inbound/cli/app.py +369 -0
  6. generic_ml_wrapper/adapter/inbound/cli/banner.py +30 -0
  7. generic_ml_wrapper/adapter/outbound/__init__.py +3 -0
  8. generic_ml_wrapper/adapter/outbound/bootstrap/__init__.py +3 -0
  9. generic_ml_wrapper/adapter/outbound/bootstrap/filesystem_layout_seeder.py +104 -0
  10. generic_ml_wrapper/adapter/outbound/caller/__init__.py +3 -0
  11. generic_ml_wrapper/adapter/outbound/caller/claude_cli_caller.py +158 -0
  12. generic_ml_wrapper/adapter/outbound/caller/codex_cli_caller.py +165 -0
  13. generic_ml_wrapper/adapter/outbound/caller/context_file.py +44 -0
  14. generic_ml_wrapper/adapter/outbound/caller/context_opening.py +27 -0
  15. generic_ml_wrapper/adapter/outbound/caller/cursor_cli_caller.py +98 -0
  16. generic_ml_wrapper/adapter/outbound/caller/default_provider.py +79 -0
  17. generic_ml_wrapper/adapter/outbound/caller/loader.py +34 -0
  18. generic_ml_wrapper/adapter/outbound/caller/status_line_config.py +142 -0
  19. generic_ml_wrapper/adapter/outbound/caller/vibe_cli_caller.py +169 -0
  20. generic_ml_wrapper/adapter/outbound/caller/vibe_config.py +128 -0
  21. generic_ml_wrapper/adapter/outbound/credentials/__init__.py +3 -0
  22. generic_ml_wrapper/adapter/outbound/credentials/filesystem_credentials_store.py +130 -0
  23. generic_ml_wrapper/adapter/outbound/gateway/__init__.py +3 -0
  24. generic_ml_wrapper/adapter/outbound/gateway/anthropic_sse.py +148 -0
  25. generic_ml_wrapper/adapter/outbound/gateway/openai_chat.py +112 -0
  26. generic_ml_wrapper/adapter/outbound/gateway/openai_responses.py +85 -0
  27. generic_ml_wrapper/adapter/outbound/gateway/relay.py +402 -0
  28. generic_ml_wrapper/adapter/outbound/interceptor/__init__.py +3 -0
  29. generic_ml_wrapper/adapter/outbound/interceptor/compressor.py +107 -0
  30. generic_ml_wrapper/adapter/outbound/interceptor/size_logger.py +32 -0
  31. generic_ml_wrapper/adapter/outbound/status/__init__.py +3 -0
  32. generic_ml_wrapper/adapter/outbound/status/claude_status_parser.py +76 -0
  33. generic_ml_wrapper/adapter/outbound/status/cursor_status_parser.py +71 -0
  34. generic_ml_wrapper/adapter/outbound/store/__init__.py +3 -0
  35. generic_ml_wrapper/adapter/outbound/store/filesystem_transcript_store.py +65 -0
  36. generic_ml_wrapper/adapter/outbound/store/ledger.py +104 -0
  37. generic_ml_wrapper/adapter/outbound/store/sqlite_per_turn_store.py +68 -0
  38. generic_ml_wrapper/adapter/outbound/store/sqlite_session_store.py +73 -0
  39. generic_ml_wrapper/adapter/outbound/store/sqlite_usage_store.py +44 -0
  40. generic_ml_wrapper/adapter/outbound/workflow/__init__.py +3 -0
  41. generic_ml_wrapper/adapter/outbound/workflow/filesystem_workflow_source.py +165 -0
  42. generic_ml_wrapper/adapter/outbound/workspace/__init__.py +3 -0
  43. generic_ml_wrapper/adapter/outbound/workspace/local_workspace_inspector.py +75 -0
  44. generic_ml_wrapper/application/__init__.py +3 -0
  45. generic_ml_wrapper/application/domain/__init__.py +3 -0
  46. generic_ml_wrapper/application/domain/model/__init__.py +3 -0
  47. generic_ml_wrapper/application/domain/model/client_status.py +44 -0
  48. generic_ml_wrapper/application/domain/model/identifiers.py +76 -0
  49. generic_ml_wrapper/application/domain/model/run.py +35 -0
  50. generic_ml_wrapper/application/domain/model/session.py +24 -0
  51. generic_ml_wrapper/application/domain/model/turn_usage.py +62 -0
  52. generic_ml_wrapper/application/domain/model/workspace.py +31 -0
  53. generic_ml_wrapper/application/domain/service/__init__.py +3 -0
  54. generic_ml_wrapper/application/domain/service/interceptor.py +30 -0
  55. generic_ml_wrapper/application/domain/service/interceptor_chain.py +57 -0
  56. generic_ml_wrapper/application/domain/service/rule_cleaner.py +35 -0
  57. generic_ml_wrapper/application/domain/service/session_naming.py +30 -0
  58. generic_ml_wrapper/application/domain/service/statusline_renderer.py +81 -0
  59. generic_ml_wrapper/application/port/__init__.py +3 -0
  60. generic_ml_wrapper/application/port/inbound/__init__.py +3 -0
  61. generic_ml_wrapper/application/port/inbound/bootstrap.py +15 -0
  62. generic_ml_wrapper/application/port/inbound/export_usage.py +109 -0
  63. generic_ml_wrapper/application/port/inbound/list_jobs.py +33 -0
  64. generic_ml_wrapper/application/port/inbound/list_sessions.py +36 -0
  65. generic_ml_wrapper/application/port/inbound/list_workflows.py +19 -0
  66. generic_ml_wrapper/application/port/inbound/new_workflow.py +48 -0
  67. generic_ml_wrapper/application/port/inbound/render_statusline.py +24 -0
  68. generic_ml_wrapper/application/port/inbound/set_credential.py +35 -0
  69. generic_ml_wrapper/application/port/inbound/start_job.py +53 -0
  70. generic_ml_wrapper/application/port/outbound/__init__.py +3 -0
  71. generic_ml_wrapper/application/port/outbound/cli_caller.py +97 -0
  72. generic_ml_wrapper/application/port/outbound/client_status.py +27 -0
  73. generic_ml_wrapper/application/port/outbound/credentials_store.py +36 -0
  74. generic_ml_wrapper/application/port/outbound/interceptor.py +25 -0
  75. generic_ml_wrapper/application/port/outbound/layout_seeder.py +18 -0
  76. generic_ml_wrapper/application/port/outbound/per_turn_metering.py +38 -0
  77. generic_ml_wrapper/application/port/outbound/session_store.py +62 -0
  78. generic_ml_wrapper/application/port/outbound/transcript.py +46 -0
  79. generic_ml_wrapper/application/port/outbound/usage_store.py +32 -0
  80. generic_ml_wrapper/application/port/outbound/workflow_source.py +58 -0
  81. generic_ml_wrapper/application/port/outbound/workspace.py +27 -0
  82. generic_ml_wrapper/application/usecase/__init__.py +3 -0
  83. generic_ml_wrapper/application/usecase/bootstrap.py +24 -0
  84. generic_ml_wrapper/application/usecase/export_usage.py +93 -0
  85. generic_ml_wrapper/application/usecase/list_jobs.py +31 -0
  86. generic_ml_wrapper/application/usecase/list_sessions.py +34 -0
  87. generic_ml_wrapper/application/usecase/list_workflows.py +28 -0
  88. generic_ml_wrapper/application/usecase/new_workflow.py +109 -0
  89. generic_ml_wrapper/application/usecase/render_statusline.py +117 -0
  90. generic_ml_wrapper/application/usecase/set_credential.py +27 -0
  91. generic_ml_wrapper/application/usecase/start_job.py +125 -0
  92. generic_ml_wrapper/application/wiring/__init__.py +3 -0
  93. generic_ml_wrapper/application/wiring/composition.py +230 -0
  94. generic_ml_wrapper/common/__init__.py +3 -0
  95. generic_ml_wrapper/common/config.py +179 -0
  96. generic_ml_wrapper/common/log.py +83 -0
  97. generic_ml_wrapper/common/paths.py +25 -0
  98. generic_ml_wrapper/common/spec_loader.py +67 -0
  99. generic_ml_wrapper/py.typed +0 -0
  100. generic_ml_wrapper/resources/workflows/_common/base.md +25 -0
  101. generic_ml_wrapper/resources/workflows/create-workflow/workflow.md +35 -0
  102. generic_ml_wrapper-0.1.0.dist-info/METADATA +172 -0
  103. generic_ml_wrapper-0.1.0.dist-info/RECORD +107 -0
  104. generic_ml_wrapper-0.1.0.dist-info/WHEEL +4 -0
  105. generic_ml_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
  106. generic_ml_wrapper-0.1.0.dist-info/licenses/LICENSE +201 -0
  107. 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,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Outbound ports: what the app needs from the world."""
@@ -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
+ """