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,158 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""``ClaudeCliCaller``: launch Claude Code, install the status line, meter the run."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from generic_ml_wrapper.adapter.outbound.caller import context_file, status_line_config
|
|
13
|
+
from generic_ml_wrapper.adapter.outbound.caller.status_line_config import StatusLineSnapshot
|
|
14
|
+
from generic_ml_wrapper.adapter.outbound.gateway.relay import MeteringRelay
|
|
15
|
+
from generic_ml_wrapper.application.domain.model.run import RunContext
|
|
16
|
+
from generic_ml_wrapper.application.port.outbound.cli_caller import CliCaller
|
|
17
|
+
from generic_ml_wrapper.common.log import log
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from generic_ml_wrapper.application.domain.service.interceptor_chain import InterceptorChain
|
|
21
|
+
from generic_ml_wrapper.application.port.outbound.per_turn_metering import PerTurnMeteringPort
|
|
22
|
+
from generic_ml_wrapper.application.port.outbound.transcript import TranscriptPort
|
|
23
|
+
|
|
24
|
+
BINARY = "claude"
|
|
25
|
+
_SETTINGS = Path.home() / ".claude" / "settings.json"
|
|
26
|
+
_STATUSLINE: dict[str, object] = {
|
|
27
|
+
"type": "command",
|
|
28
|
+
"command": status_line_config.statusline_command(),
|
|
29
|
+
"padding": 0,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ClaudeCliCaller(CliCaller):
|
|
34
|
+
"""Launch Claude Code for a run, metered via the status line and a local relay.
|
|
35
|
+
|
|
36
|
+
``start_metering`` points Claude Code's ``statusLine`` at ``gmlw statusline`` and
|
|
37
|
+
stands up a :class:`MeteringRelay`, pointing Claude at it via ``ANTHROPIC_BASE_URL``
|
|
38
|
+
to record per-turn tokens; ``end_metering`` tears both down. If the relay cannot
|
|
39
|
+
start, the launch falls back to unmetered (status line only). A new session may
|
|
40
|
+
carry injected ``context`` (Claude's system prompt), an opening ``kickoff``, and a
|
|
41
|
+
``cwd``; the run's identity is exported as ``GMLW_JOB`` / ``GMLW_SESSION`` /
|
|
42
|
+
``GMLW_CLIENT``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
run: RunContext,
|
|
48
|
+
metering: PerTurnMeteringPort,
|
|
49
|
+
interceptors: InterceptorChain | None = None,
|
|
50
|
+
transcript: TranscriptPort | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Bind the caller to a run, its metering store, and the interceptor chain.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
run: The run this caller will launch and meter.
|
|
56
|
+
metering: Where the relay records per-turn usage.
|
|
57
|
+
interceptors: The interceptor chain the relay applies to wire traffic.
|
|
58
|
+
transcript: Where the relay records each call's transcript, or ``None``.
|
|
59
|
+
"""
|
|
60
|
+
super().__init__(run)
|
|
61
|
+
self._metering = metering
|
|
62
|
+
self._interceptors = interceptors
|
|
63
|
+
self._transcript = transcript
|
|
64
|
+
self._snapshot: StatusLineSnapshot | None = None
|
|
65
|
+
self._relay: MeteringRelay | None = None
|
|
66
|
+
|
|
67
|
+
def can_deliver_statusline(self) -> bool:
|
|
68
|
+
"""Claude Code hosts a command-backed status line the wrapper renders into."""
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
def can_meter_per_call(self) -> bool:
|
|
72
|
+
"""This caller records per-turn usage via its metering relay."""
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def start_metering(self) -> None:
|
|
76
|
+
"""Install the status line and start the metering relay for this session."""
|
|
77
|
+
if self.can_deliver_statusline():
|
|
78
|
+
self._snapshot = status_line_config.install(_SETTINGS, _STATUSLINE)
|
|
79
|
+
relay = MeteringRelay(
|
|
80
|
+
job=self.run.job,
|
|
81
|
+
session=self.run.session_id,
|
|
82
|
+
metering=self._metering,
|
|
83
|
+
client=self.run.client,
|
|
84
|
+
transcript=self._transcript,
|
|
85
|
+
interceptors=self._interceptors,
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
relay.start()
|
|
89
|
+
except OSError as error:
|
|
90
|
+
log.warning(f"metering relay failed to start ({error}); launching unmetered")
|
|
91
|
+
return
|
|
92
|
+
self._relay = relay
|
|
93
|
+
|
|
94
|
+
def end_metering(self) -> None:
|
|
95
|
+
"""Stop the metering relay and restore the status line."""
|
|
96
|
+
if self._relay is not None:
|
|
97
|
+
self._relay.stop()
|
|
98
|
+
self._relay = None
|
|
99
|
+
if self._snapshot is not None:
|
|
100
|
+
status_line_config.restore(_SETTINGS, self._snapshot)
|
|
101
|
+
|
|
102
|
+
def command(self, context_file: str | None = None) -> list[str]:
|
|
103
|
+
"""Build the ``claude`` command line for this run.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
context_file: A file whose contents to append to the system prompt, or
|
|
107
|
+
``None`` (only used for a new session).
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The argv list to execute.
|
|
111
|
+
"""
|
|
112
|
+
run = self.run
|
|
113
|
+
if run.resume:
|
|
114
|
+
argv = [BINARY, "--resume", run.uuid or run.session_id]
|
|
115
|
+
else:
|
|
116
|
+
argv = [BINARY, "-n", run.session_id]
|
|
117
|
+
if run.uuid is not None:
|
|
118
|
+
argv += ["--session-id", run.uuid]
|
|
119
|
+
if context_file is not None:
|
|
120
|
+
argv += ["--append-system-prompt-file", context_file]
|
|
121
|
+
if run.kickoff is not None:
|
|
122
|
+
argv.append(run.kickoff)
|
|
123
|
+
return argv
|
|
124
|
+
|
|
125
|
+
def start_client(self) -> int:
|
|
126
|
+
"""Launch Claude, blocking until it exits.
|
|
127
|
+
|
|
128
|
+
Injected context (new sessions only) is persisted per session and passed via
|
|
129
|
+
``--append-system-prompt-file`` -- a durable provenance artifact.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The client's exit code.
|
|
133
|
+
"""
|
|
134
|
+
if self.run.context is not None and not self.run.resume:
|
|
135
|
+
path = context_file.write(self.run.job, self.run.session_id, self.run.context)
|
|
136
|
+
return self._run(self.command(str(path)))
|
|
137
|
+
return self._run(self.command())
|
|
138
|
+
|
|
139
|
+
def _extra_env(self) -> dict[str, str]:
|
|
140
|
+
"""Point Claude at the relay when it is running.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
``ANTHROPIC_BASE_URL`` for the relay, or empty when it isn't running.
|
|
144
|
+
"""
|
|
145
|
+
return {} if self._relay is None else {"ANTHROPIC_BASE_URL": self._relay.base_url}
|
|
146
|
+
|
|
147
|
+
def _run(self, argv: list[str]) -> int:
|
|
148
|
+
env = {
|
|
149
|
+
**os.environ,
|
|
150
|
+
**dict(self.run.env),
|
|
151
|
+
**self._extra_env(),
|
|
152
|
+
"GMLW_JOB": self.run.job,
|
|
153
|
+
"GMLW_SESSION": self.run.session_id,
|
|
154
|
+
"GMLW_CLIENT": self.run.client,
|
|
155
|
+
}
|
|
156
|
+
# Trusted argv from our resolved run; no shell. The program is PATH-resolved (BINARY).
|
|
157
|
+
completed = subprocess.run(argv, check=False, cwd=self.run.cwd, env=env) # noqa: S603
|
|
158
|
+
return completed.returncode
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Codex callers: launch codex, optionally through a per-turn metering relay."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from generic_ml_wrapper.adapter.outbound.caller import context_file
|
|
12
|
+
from generic_ml_wrapper.adapter.outbound.caller.context_opening import read_first_opening
|
|
13
|
+
from generic_ml_wrapper.adapter.outbound.gateway import openai_responses
|
|
14
|
+
from generic_ml_wrapper.adapter.outbound.gateway.relay import MeteringRelay
|
|
15
|
+
from generic_ml_wrapper.application.port.outbound.cli_caller import CliCaller
|
|
16
|
+
from generic_ml_wrapper.common.log import log
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from generic_ml_wrapper.application.domain.model.run import RunContext
|
|
20
|
+
from generic_ml_wrapper.application.domain.service.interceptor_chain import InterceptorChain
|
|
21
|
+
from generic_ml_wrapper.application.port.outbound.per_turn_metering import PerTurnMeteringPort
|
|
22
|
+
from generic_ml_wrapper.application.port.outbound.transcript import TranscriptPort
|
|
23
|
+
|
|
24
|
+
BINARY = "codex"
|
|
25
|
+
# ChatGPT-sign-in upstream (the verified default). API-key mode would target
|
|
26
|
+
# api.openai.com with a /v1 prefix; a config-driven option is a later follow-up.
|
|
27
|
+
_UPSTREAM = "https://chatgpt.com"
|
|
28
|
+
_UPSTREAM_PREFIX = "/backend-api/codex"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CodexCliCaller(CliCaller):
|
|
32
|
+
"""Launch codex for a run, routed through a per-turn metering relay.
|
|
33
|
+
|
|
34
|
+
Codex has no status-line hook, so none is installed. It takes its operating
|
|
35
|
+
context via a "read this file first" opening message (it has no system-prompt
|
|
36
|
+
flag). ``start_metering`` stands up a relay pointed at the ChatGPT-Codex backend
|
|
37
|
+
and ``_provider_flags`` adds the ``model_providers`` overrides pointing codex at
|
|
38
|
+
it; if the relay cannot start, codex launches unmetered.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
run: RunContext,
|
|
44
|
+
metering: PerTurnMeteringPort,
|
|
45
|
+
interceptors: InterceptorChain | None = None,
|
|
46
|
+
transcript: TranscriptPort | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Bind the caller to a run, its metering store, and the interceptor chain.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
run: The run this caller will launch and meter.
|
|
52
|
+
metering: Where the relay records per-turn usage.
|
|
53
|
+
interceptors: The interceptor chain the relay applies to wire traffic.
|
|
54
|
+
transcript: Where the relay records each call's transcript, or ``None``.
|
|
55
|
+
"""
|
|
56
|
+
super().__init__(run)
|
|
57
|
+
self._metering = metering
|
|
58
|
+
self._interceptors = interceptors
|
|
59
|
+
self._transcript = transcript
|
|
60
|
+
self._relay: MeteringRelay | None = None
|
|
61
|
+
|
|
62
|
+
def can_resume(self) -> bool:
|
|
63
|
+
"""Codex mints a fresh conversation and exposes no session id to resume.
|
|
64
|
+
|
|
65
|
+
Resuming would require scanning ``~/.codex`` for the local rollout, which
|
|
66
|
+
the wrapper deliberately does not do; ``--resume-latest`` is refused for
|
|
67
|
+
codex rather than silently starting a new session.
|
|
68
|
+
"""
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def can_meter_per_call(self) -> bool:
|
|
72
|
+
"""This caller records per-turn usage via its metering relay."""
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def start_metering(self) -> None:
|
|
76
|
+
"""Start the Codex metering relay for this session."""
|
|
77
|
+
relay = MeteringRelay(
|
|
78
|
+
job=self.run.job,
|
|
79
|
+
session=self.run.session_id,
|
|
80
|
+
metering=self._metering,
|
|
81
|
+
client=self.run.client,
|
|
82
|
+
transcript=self._transcript,
|
|
83
|
+
upstream_base=_UPSTREAM,
|
|
84
|
+
path_map=_codex_path_map,
|
|
85
|
+
usage_reader=openai_responses.read_usage,
|
|
86
|
+
is_metered=_codex_metered,
|
|
87
|
+
interceptors=self._interceptors,
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
relay.start()
|
|
91
|
+
except OSError as error:
|
|
92
|
+
log.warning(f"metering relay failed to start ({error}); launching codex unmetered")
|
|
93
|
+
return
|
|
94
|
+
self._relay = relay
|
|
95
|
+
|
|
96
|
+
def end_metering(self) -> None:
|
|
97
|
+
"""Stop the metering relay."""
|
|
98
|
+
if self._relay is not None:
|
|
99
|
+
self._relay.stop()
|
|
100
|
+
self._relay = None
|
|
101
|
+
|
|
102
|
+
def _provider_flags(self) -> list[str]:
|
|
103
|
+
if self._relay is None:
|
|
104
|
+
return []
|
|
105
|
+
base_url = f"{self._relay.base_url}/v1"
|
|
106
|
+
return [
|
|
107
|
+
"-c",
|
|
108
|
+
'model_providers.gml.name="gmlcache"',
|
|
109
|
+
"-c",
|
|
110
|
+
f'model_providers.gml.base_url="{base_url}"',
|
|
111
|
+
"-c",
|
|
112
|
+
'model_providers.gml.wire_api="responses"',
|
|
113
|
+
"-c",
|
|
114
|
+
"model_providers.gml.requires_openai_auth=true",
|
|
115
|
+
"-c",
|
|
116
|
+
'model_provider="gml"',
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
def command(self, opening: str | None = None) -> list[str]:
|
|
120
|
+
"""Build the ``codex`` command line for this run.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
opening: The opening message to start the session on, or ``None``.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The argv list to execute.
|
|
127
|
+
"""
|
|
128
|
+
argv = [BINARY, *self._provider_flags()]
|
|
129
|
+
if opening is not None:
|
|
130
|
+
argv.append(opening)
|
|
131
|
+
return argv
|
|
132
|
+
|
|
133
|
+
def start_client(self) -> int:
|
|
134
|
+
"""Launch codex, blocking until it exits.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
The client's exit code.
|
|
138
|
+
"""
|
|
139
|
+
if self.run.context is not None and not self.run.resume:
|
|
140
|
+
path = context_file.write(self.run.job, self.run.session_id, self.run.context)
|
|
141
|
+
return self._run(self.command(read_first_opening(str(path), self.run.kickoff)))
|
|
142
|
+
return self._run(self.command(self.run.kickoff))
|
|
143
|
+
|
|
144
|
+
def _run(self, argv: list[str]) -> int:
|
|
145
|
+
env = {
|
|
146
|
+
**os.environ,
|
|
147
|
+
**dict(self.run.env),
|
|
148
|
+
"GMLW_JOB": self.run.job,
|
|
149
|
+
"GMLW_SESSION": self.run.session_id,
|
|
150
|
+
"GMLW_CLIENT": self.run.client,
|
|
151
|
+
}
|
|
152
|
+
# Trusted argv from our resolved run; no shell. The program is PATH-resolved (BINARY).
|
|
153
|
+
completed = subprocess.run(argv, check=False, cwd=self.run.cwd, env=env) # noqa: S603
|
|
154
|
+
return completed.returncode
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _codex_path_map(path: str) -> str:
|
|
158
|
+
"""Map codex's ``/v1/x`` (its base_url ends ``/v1``) to the backend prefix."""
|
|
159
|
+
sub = path[len("/v1") :] if path.startswith("/v1") else path
|
|
160
|
+
return _UPSTREAM_PREFIX + sub
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _codex_metered(method: str, path: str) -> bool:
|
|
164
|
+
"""A metered codex turn: ``POST`` to the Responses endpoint."""
|
|
165
|
+
return method == "POST" and path.split("?", 1)[0].endswith("/responses")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Persist a session's compiled context to a durable, inspectable file.
|
|
4
|
+
|
|
5
|
+
The exact operating context a session launches with (profile + rules + workflow) is
|
|
6
|
+
written per session to ``~/.gmlw/contexts/<job>/<session>.context.md`` and handed to
|
|
7
|
+
the client from there -- a durable provenance artifact you can inspect after the run,
|
|
8
|
+
instead of a temp file discarded the moment the client exits.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from generic_ml_wrapper.common import paths
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write(job: str, session: str, context: str) -> Path:
|
|
21
|
+
"""Write ``session``'s compiled ``context`` durably, returning the file path.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
job: The job the session belongs to.
|
|
25
|
+
session: The session id (``<job>_NNN``).
|
|
26
|
+
context: The compiled operating context to persist and inject.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The path to the written ``<session>.context.md`` file.
|
|
30
|
+
"""
|
|
31
|
+
directory = paths.CONTEXTS / job
|
|
32
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
path = directory / f"{session}.context.md"
|
|
34
|
+
# Atomic: same-dir temp + replace, so a crash mid-write can't leave a torn artifact.
|
|
35
|
+
descriptor, temporary = tempfile.mkstemp(dir=directory, suffix=".tmp")
|
|
36
|
+
temporary_path = Path(temporary)
|
|
37
|
+
try:
|
|
38
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
39
|
+
handle.write(context)
|
|
40
|
+
temporary_path.replace(path)
|
|
41
|
+
except BaseException:
|
|
42
|
+
temporary_path.unlink()
|
|
43
|
+
raise
|
|
44
|
+
return path
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Build the opening message that points a fileless-prompt agent at its context.
|
|
4
|
+
|
|
5
|
+
Clients without a system-prompt flag (cursor-agent, codex) receive their compiled
|
|
6
|
+
operating context by being told, in the opening message, to read a file first.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_first_opening(context_path: str, kickoff: str | None = None) -> str:
|
|
13
|
+
"""Build an opening message telling the agent to read its context file first.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
context_path: The path to the compiled operating-context file.
|
|
17
|
+
kickoff: An opening user message to append after the instruction, or ``None``.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The opening message.
|
|
21
|
+
"""
|
|
22
|
+
preamble = (
|
|
23
|
+
f"Your operating context for this session is in the file:\n{context_path}\n"
|
|
24
|
+
"Read that file in full FIRST — it is your instructions, profile, rules, and "
|
|
25
|
+
"the workflow steps — then proceed as it says."
|
|
26
|
+
)
|
|
27
|
+
return f"{preamble}\n\n{kickoff}" if kickoff else preamble
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""``CursorCliCaller``: launch cursor-agent and install its status line (no metering)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from generic_ml_wrapper.adapter.outbound.caller import context_file, status_line_config
|
|
12
|
+
from generic_ml_wrapper.adapter.outbound.caller.context_opening import read_first_opening
|
|
13
|
+
from generic_ml_wrapper.adapter.outbound.caller.status_line_config import StatusLineSnapshot
|
|
14
|
+
from generic_ml_wrapper.application.domain.model.run import RunContext
|
|
15
|
+
from generic_ml_wrapper.application.port.outbound.cli_caller import CliCaller
|
|
16
|
+
|
|
17
|
+
BINARY = "cursor-agent"
|
|
18
|
+
_CONFIG = Path.home() / ".cursor" / "cli-config.json"
|
|
19
|
+
_STATUSLINE: dict[str, object] = {
|
|
20
|
+
"type": "command",
|
|
21
|
+
"command": status_line_config.statusline_command(),
|
|
22
|
+
"updateIntervalMs": 2000,
|
|
23
|
+
"timeoutMs": 5000,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CursorCliCaller(CliCaller):
|
|
28
|
+
"""Launch cursor-agent for a run, with the wrapper's status line, without metering.
|
|
29
|
+
|
|
30
|
+
cursor-agent hosts a command-backed status line (``~/.cursor/cli-config.json``),
|
|
31
|
+
so ``start_metering`` points it at ``gmlw statusline`` and ``end_metering``
|
|
32
|
+
restores the prior setting. Its ``--resume <name>`` both creates and resumes a
|
|
33
|
+
session. It has no system-prompt flag, so injected context is written to a file
|
|
34
|
+
the agent is told to read first. This light client does not meter usage.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, run: RunContext) -> None:
|
|
38
|
+
"""Bind the caller to a run.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
run: The run this caller will launch.
|
|
42
|
+
"""
|
|
43
|
+
super().__init__(run)
|
|
44
|
+
self._snapshot: StatusLineSnapshot | None = None
|
|
45
|
+
|
|
46
|
+
def can_deliver_statusline(self) -> bool:
|
|
47
|
+
"""cursor-agent hosts a command-backed status line the wrapper renders into."""
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
def start_metering(self) -> None:
|
|
51
|
+
"""Point cursor-agent's status line at ``gmlw statusline`` for this session."""
|
|
52
|
+
if self.can_deliver_statusline():
|
|
53
|
+
self._snapshot = status_line_config.install(_CONFIG, _STATUSLINE)
|
|
54
|
+
|
|
55
|
+
def end_metering(self) -> None:
|
|
56
|
+
"""Restore cursor-agent's previous status-line setting."""
|
|
57
|
+
if self._snapshot is not None:
|
|
58
|
+
status_line_config.restore(_CONFIG, self._snapshot)
|
|
59
|
+
|
|
60
|
+
def command(self, opening: str | None = None) -> list[str]:
|
|
61
|
+
"""Build the ``cursor-agent`` command line for this run.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
opening: The opening message to start the session on, or ``None``.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
The argv list to execute.
|
|
68
|
+
"""
|
|
69
|
+
argv = [BINARY, "--resume", self.run.session_id]
|
|
70
|
+
if opening is not None:
|
|
71
|
+
argv.append(opening)
|
|
72
|
+
return argv
|
|
73
|
+
|
|
74
|
+
def start_client(self) -> int:
|
|
75
|
+
"""Launch cursor-agent, blocking until it exits.
|
|
76
|
+
|
|
77
|
+
Injected context (new sessions only) is written to a temporary file the
|
|
78
|
+
agent is told to read first, removed when the client exits.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The client's exit code.
|
|
82
|
+
"""
|
|
83
|
+
if self.run.context is not None and not self.run.resume:
|
|
84
|
+
path = context_file.write(self.run.job, self.run.session_id, self.run.context)
|
|
85
|
+
return self._run(self.command(read_first_opening(str(path), self.run.kickoff)))
|
|
86
|
+
return self._run(self.command(self.run.kickoff))
|
|
87
|
+
|
|
88
|
+
def _run(self, argv: list[str]) -> int:
|
|
89
|
+
env = {
|
|
90
|
+
**os.environ,
|
|
91
|
+
**dict(self.run.env),
|
|
92
|
+
"GMLW_JOB": self.run.job,
|
|
93
|
+
"GMLW_SESSION": self.run.session_id,
|
|
94
|
+
"GMLW_CLIENT": self.run.client,
|
|
95
|
+
}
|
|
96
|
+
# Trusted argv from our resolved run; no shell. The program is PATH-resolved (BINARY).
|
|
97
|
+
completed = subprocess.run(argv, check=False, cwd=self.run.cwd, env=env) # noqa: S603
|
|
98
|
+
return completed.returncode
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""``DefaultCliCallerProvider``: config overrides first, then built-in callers."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from generic_ml_wrapper.adapter.outbound.caller.claude_cli_caller import ClaudeCliCaller
|
|
10
|
+
from generic_ml_wrapper.adapter.outbound.caller.codex_cli_caller import CodexCliCaller
|
|
11
|
+
from generic_ml_wrapper.adapter.outbound.caller.cursor_cli_caller import CursorCliCaller
|
|
12
|
+
from generic_ml_wrapper.adapter.outbound.caller.loader import load_caller_class
|
|
13
|
+
from generic_ml_wrapper.adapter.outbound.caller.vibe_cli_caller import VibeCliCaller
|
|
14
|
+
from generic_ml_wrapper.application.port.outbound.cli_caller import CliCaller, CliCallerProvider
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from generic_ml_wrapper.application.domain.model.run import RunContext
|
|
18
|
+
from generic_ml_wrapper.application.domain.service.interceptor_chain import InterceptorChain
|
|
19
|
+
from generic_ml_wrapper.application.port.outbound.per_turn_metering import PerTurnMeteringPort
|
|
20
|
+
from generic_ml_wrapper.application.port.outbound.transcript import TranscriptPort
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UnsupportedClientError(ValueError):
|
|
24
|
+
"""Raised when a run's client has no override and no built-in caller."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DefaultCliCallerProvider(CliCallerProvider):
|
|
28
|
+
"""Resolve a run's caller: a config override if present, else the built-in one."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
overrides: dict[str, str] | None = None,
|
|
33
|
+
*,
|
|
34
|
+
metering: PerTurnMeteringPort,
|
|
35
|
+
transcript: TranscriptPort | None = None,
|
|
36
|
+
interceptors: InterceptorChain | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Bind the provider to its overrides, metering store, and interceptor chain.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
overrides: A client-to-spec mapping (``"module:Class"``) from config; an
|
|
42
|
+
override is loaded in place of the built-in caller for that client.
|
|
43
|
+
metering: The per-turn store the built-in gateway callers record to. Every
|
|
44
|
+
built-in client except cursor routes through a relay that records here.
|
|
45
|
+
transcript: Where the relay records each call's transcript, or ``None``.
|
|
46
|
+
interceptors: The interceptor chain the relay applies to wire traffic.
|
|
47
|
+
"""
|
|
48
|
+
self._overrides = overrides or {}
|
|
49
|
+
self._metering = metering
|
|
50
|
+
self._transcript = transcript
|
|
51
|
+
self._interceptors = interceptors
|
|
52
|
+
|
|
53
|
+
def for_run(self, run: RunContext) -> CliCaller:
|
|
54
|
+
"""Return the caller for the run's client.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
run: The run to launch.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
A caller bound to the run — an overridden one when configured, else the
|
|
61
|
+
built-in caller (every built-in but cursor routes through the relay).
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
UnsupportedClientError: If the client has neither an override nor a
|
|
65
|
+
built-in caller.
|
|
66
|
+
"""
|
|
67
|
+
spec = self._overrides.get(run.client)
|
|
68
|
+
if spec is not None:
|
|
69
|
+
return load_caller_class(spec)(run)
|
|
70
|
+
if run.client == "claude":
|
|
71
|
+
return ClaudeCliCaller(run, self._metering, self._interceptors, self._transcript)
|
|
72
|
+
if run.client == "cursor":
|
|
73
|
+
return CursorCliCaller(run)
|
|
74
|
+
if run.client == "codex":
|
|
75
|
+
return CodexCliCaller(run, self._metering, self._interceptors, self._transcript)
|
|
76
|
+
if run.client == "vibe":
|
|
77
|
+
return VibeCliCaller(run, self._metering, self._interceptors, self._transcript)
|
|
78
|
+
message = f"unsupported client: {run.client!r}"
|
|
79
|
+
raise UnsupportedClientError(message)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Load an external ``CliCaller`` class from a config spec, at runtime."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from generic_ml_wrapper.application.port.outbound.cli_caller import CliCaller
|
|
8
|
+
from generic_ml_wrapper.common.spec_loader import SpecLoadError, load_class
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CallerLoadError(ValueError):
|
|
12
|
+
"""Raised when a caller spec cannot be resolved to a ``CliCaller`` subclass."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_caller_class(spec: str) -> type[CliCaller]:
|
|
16
|
+
"""Resolve a ``"module:Class"`` or ``"/path/to/file.py:Class"`` spec to a class.
|
|
17
|
+
|
|
18
|
+
The spec lets a private metering caller be plugged in via config without ever
|
|
19
|
+
living in this repo.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
spec: The import spec, ``"<module-or-path>:<ClassName>"``.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The referenced ``CliCaller`` subclass.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
CallerLoadError: If the spec is malformed, cannot be imported, or does not
|
|
29
|
+
name a ``CliCaller`` subclass.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
return load_class(spec, CliCaller)
|
|
33
|
+
except SpecLoadError as error:
|
|
34
|
+
raise CallerLoadError(str(error)) from error
|