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,142 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Install/restore a client's ``statusLine`` command hook in its JSON settings file.
|
|
4
|
+
|
|
5
|
+
Both Claude Code (``~/.claude/settings.json``) and cursor-agent
|
|
6
|
+
(``~/.cursor/cli-config.json``) point a ``statusLine`` key at ``gmlw statusline``.
|
|
7
|
+
This snapshots the prior value on install and puts it back on restore, so the
|
|
8
|
+
user's own setting survives a run.
|
|
9
|
+
|
|
10
|
+
Two safety properties matter because this file is the user's, not ours:
|
|
11
|
+
- If the file exists but cannot be parsed as JSON, we NEVER overwrite it (that would
|
|
12
|
+
destroy the user's settings). We raise :class:`SettingsUnreadableError` and the run
|
|
13
|
+
aborts with guidance. "Absent" and "unparseable" are different: absent is fine.
|
|
14
|
+
- Writes are atomic (temp file + ``replace``), and restore is ownership-aware: it puts
|
|
15
|
+
the old value back only if this run's value is still installed, so two concurrent
|
|
16
|
+
runs can't clobber each other into leaving ``gmlw statusline`` behind.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import sys
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import cast
|
|
28
|
+
|
|
29
|
+
from generic_ml_wrapper.common.log import log
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def statusline_command() -> str:
|
|
33
|
+
"""The command a client's ``statusLine`` hook invokes.
|
|
34
|
+
|
|
35
|
+
``gmlw statusline`` when ``gmlw`` is on ``PATH`` (survives reinstalls); otherwise
|
|
36
|
+
the absolute path to the ``gmlw`` beside this interpreter -- so the hook still
|
|
37
|
+
resolves when the client is launched from an environment that lacks ``gmlw`` on
|
|
38
|
+
``PATH`` (a dev checkout, a venv that isn't activated).
|
|
39
|
+
"""
|
|
40
|
+
if shutil.which("gmlw"):
|
|
41
|
+
return "gmlw statusline"
|
|
42
|
+
candidate = Path(sys.executable).with_name("gmlw")
|
|
43
|
+
return f"{candidate} statusline" if candidate.exists() else "gmlw statusline"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SettingsUnreadableError(Exception):
|
|
47
|
+
"""A client settings file exists but is not a readable JSON object.
|
|
48
|
+
|
|
49
|
+
Overwriting it would destroy the user's settings, so the run aborts instead.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, path: Path) -> None:
|
|
53
|
+
"""Build the error with actionable guidance for ``path``."""
|
|
54
|
+
self.path = path
|
|
55
|
+
super().__init__(
|
|
56
|
+
f"{path} is not valid JSON.\n\n"
|
|
57
|
+
"The wrapper installs its status line by editing this file, and would have to\n"
|
|
58
|
+
"overwrite it to continue. Overwriting a file it cannot parse would destroy your\n"
|
|
59
|
+
"existing settings (model, permissions, env, hooks), so the run is aborted.\n\n"
|
|
60
|
+
"To fix:\n"
|
|
61
|
+
f" 1. Check it: python -m json.tool {path}\n"
|
|
62
|
+
" 2. Common causes: // or /* */ comments, or a trailing comma -- JSON allows none.\n"
|
|
63
|
+
f" 3. Or move it aside: mv {path} {path}.bak (a fresh one will be created)\n"
|
|
64
|
+
"Then run gmlw again."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class StatusLineSnapshot:
|
|
70
|
+
"""The ``statusLine`` before install, plus what this run installed (for restore)."""
|
|
71
|
+
|
|
72
|
+
had_status_line: bool
|
|
73
|
+
previous: object
|
|
74
|
+
installed: object
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def install(path: Path, status_line: dict[str, object]) -> StatusLineSnapshot:
|
|
78
|
+
"""Install ``status_line`` into the settings at ``path``, returning a snapshot.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
path: The client's JSON settings file.
|
|
82
|
+
status_line: The ``statusLine`` value to install.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
A snapshot of the prior ``statusLine`` for :func:`restore`.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
SettingsUnreadableError: If ``path`` exists but is not a JSON object; the file
|
|
89
|
+
is left untouched.
|
|
90
|
+
"""
|
|
91
|
+
settings = _load(path)
|
|
92
|
+
installed = dict(status_line)
|
|
93
|
+
snapshot = StatusLineSnapshot("statusLine" in settings, settings.get("statusLine"), installed)
|
|
94
|
+
settings["statusLine"] = installed
|
|
95
|
+
_write(path, settings)
|
|
96
|
+
return snapshot
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def restore(path: Path, snapshot: StatusLineSnapshot) -> None:
|
|
100
|
+
"""Restore the ``statusLine`` captured in ``snapshot`` -- if this run still owns it.
|
|
101
|
+
|
|
102
|
+
Best-effort teardown: if the file is no longer readable, or another run has
|
|
103
|
+
installed over this one's value, the file is left as-is (with a warning) rather
|
|
104
|
+
than clobbered.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
path: The client's JSON settings file.
|
|
108
|
+
snapshot: The snapshot returned by :func:`install`.
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
settings = _load(path)
|
|
112
|
+
except SettingsUnreadableError:
|
|
113
|
+
log.warning(f"leaving {path} untouched: it is no longer readable JSON")
|
|
114
|
+
return
|
|
115
|
+
if settings.get("statusLine") != snapshot.installed:
|
|
116
|
+
return # a later run owns the statusLine now -- don't clobber its value
|
|
117
|
+
if snapshot.had_status_line:
|
|
118
|
+
settings["statusLine"] = snapshot.previous
|
|
119
|
+
else:
|
|
120
|
+
settings.pop("statusLine", None)
|
|
121
|
+
_write(path, settings)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _load(path: Path) -> dict[str, object]:
|
|
125
|
+
if not path.exists():
|
|
126
|
+
return {}
|
|
127
|
+
try:
|
|
128
|
+
raw: object = json.loads(path.read_text(encoding="utf-8"))
|
|
129
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
130
|
+
raise SettingsUnreadableError(path) from error
|
|
131
|
+
if not isinstance(raw, dict):
|
|
132
|
+
raise SettingsUnreadableError(path)
|
|
133
|
+
return cast("dict[str, object]", raw)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _write(path: Path, settings: dict[str, object]) -> None:
|
|
137
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
# Same-directory, per-process temp then atomic replace: a crash mid-write can't
|
|
139
|
+
# truncate the user's file, and a unique name avoids two runs colliding on it.
|
|
140
|
+
temporary = path.parent / f"{path.name}.{os.getpid()}.tmp"
|
|
141
|
+
temporary.write_text(json.dumps(settings, indent=2), encoding="utf-8")
|
|
142
|
+
temporary.replace(path)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Vibe (Mistral CLI) callers: launch vibe, optionally through a per-turn relay."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from generic_ml_wrapper.adapter.outbound.caller import context_file, vibe_config
|
|
15
|
+
from generic_ml_wrapper.adapter.outbound.caller.context_opening import read_first_opening
|
|
16
|
+
from generic_ml_wrapper.adapter.outbound.gateway import openai_chat
|
|
17
|
+
from generic_ml_wrapper.adapter.outbound.gateway.relay import MeteringRelay
|
|
18
|
+
from generic_ml_wrapper.application.port.outbound.cli_caller import CliCaller
|
|
19
|
+
from generic_ml_wrapper.common.log import log
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from generic_ml_wrapper.application.domain.model.run import RunContext
|
|
23
|
+
from generic_ml_wrapper.application.domain.service.interceptor_chain import InterceptorChain
|
|
24
|
+
from generic_ml_wrapper.application.port.outbound.per_turn_metering import PerTurnMeteringPort
|
|
25
|
+
from generic_ml_wrapper.application.port.outbound.transcript import TranscriptPort
|
|
26
|
+
|
|
27
|
+
BINARY = "vibe"
|
|
28
|
+
_VIBE_CONFIG = Path.home() / ".vibe" / "config.toml"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class VibeCliCaller(CliCaller):
|
|
32
|
+
"""Launch vibe (Mistral's CLI) for a run, routed through a per-turn metering relay.
|
|
33
|
+
|
|
34
|
+
vibe mints its own UUID session id and exposes no way to set one at launch, so
|
|
35
|
+
the wrapper cannot bind or resume a session by its own id — :meth:`can_resume`
|
|
36
|
+
is ``False`` — and it has no status-line hook. It takes its operating context via
|
|
37
|
+
a "read this file first" opening message (it has no system-prompt flag).
|
|
38
|
+
``start_metering`` stands up a relay pointed at the active model's upstream and
|
|
39
|
+
writes a throwaway ``VIBE_HOME`` whose config repoints that provider at the relay;
|
|
40
|
+
if the relay cannot start (or the config can't be read), vibe launches unmetered.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
run: RunContext,
|
|
46
|
+
metering: PerTurnMeteringPort,
|
|
47
|
+
interceptors: InterceptorChain | None = None,
|
|
48
|
+
transcript: TranscriptPort | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Bind the caller to a run, its metering store, and the interceptor chain.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
run: The run this caller will launch and meter.
|
|
54
|
+
metering: Where the relay records per-turn usage.
|
|
55
|
+
interceptors: The interceptor chain the relay applies to wire traffic.
|
|
56
|
+
transcript: Where the relay records each call's transcript, or ``None``.
|
|
57
|
+
"""
|
|
58
|
+
super().__init__(run)
|
|
59
|
+
self._metering = metering
|
|
60
|
+
self._interceptors = interceptors
|
|
61
|
+
self._transcript = transcript
|
|
62
|
+
self._relay: MeteringRelay | None = None
|
|
63
|
+
self._vibe_home: str | None = None
|
|
64
|
+
|
|
65
|
+
def can_resume(self) -> bool:
|
|
66
|
+
"""Vibe mints its own session id and cannot be told one at launch."""
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def can_meter_per_call(self) -> bool:
|
|
70
|
+
"""This caller records per-turn usage via its metering relay."""
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
def start_metering(self) -> None:
|
|
74
|
+
"""Start the relay and write the throwaway VIBE_HOME pointed at it."""
|
|
75
|
+
try:
|
|
76
|
+
source_text = _VIBE_CONFIG.read_text(encoding="utf-8")
|
|
77
|
+
except OSError as error:
|
|
78
|
+
log.warning(f"cannot read {_VIBE_CONFIG} ({error}); launching vibe unmetered")
|
|
79
|
+
return
|
|
80
|
+
upstream = vibe_config.active_upstream(source_text)
|
|
81
|
+
if upstream is None:
|
|
82
|
+
log.warning("could not resolve vibe's active-model upstream; launching unmetered")
|
|
83
|
+
return
|
|
84
|
+
relay = MeteringRelay(
|
|
85
|
+
job=self.run.job,
|
|
86
|
+
session=self.run.session_id,
|
|
87
|
+
metering=self._metering,
|
|
88
|
+
client=self.run.client,
|
|
89
|
+
transcript=self._transcript,
|
|
90
|
+
upstream_base=upstream,
|
|
91
|
+
usage_reader=openai_chat.read_usage,
|
|
92
|
+
is_metered=_vibe_metered,
|
|
93
|
+
interceptors=self._interceptors,
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
relay.start()
|
|
97
|
+
except OSError as error:
|
|
98
|
+
log.warning(f"metering relay failed to start ({error}); launching vibe unmetered")
|
|
99
|
+
return
|
|
100
|
+
self._relay = relay
|
|
101
|
+
home = Path(tempfile.mkdtemp(prefix="gmlw-vibe-"))
|
|
102
|
+
(home / "config.toml").write_text(
|
|
103
|
+
vibe_config.redirect(source_text, upstream, relay.base_url), encoding="utf-8"
|
|
104
|
+
)
|
|
105
|
+
self._vibe_home = str(home)
|
|
106
|
+
|
|
107
|
+
def end_metering(self) -> None:
|
|
108
|
+
"""Stop the relay and remove the throwaway VIBE_HOME."""
|
|
109
|
+
if self._relay is not None:
|
|
110
|
+
self._relay.stop()
|
|
111
|
+
self._relay = None
|
|
112
|
+
if self._vibe_home is not None:
|
|
113
|
+
shutil.rmtree(self._vibe_home, ignore_errors=True)
|
|
114
|
+
self._vibe_home = None
|
|
115
|
+
|
|
116
|
+
def _provider_flags(self) -> list[str]:
|
|
117
|
+
# The throwaway VIBE_HOME has no trusted-folder record; trust the cwd for
|
|
118
|
+
# this invocation so vibe does not stop to prompt.
|
|
119
|
+
return [] if self._vibe_home is None else ["--trust"]
|
|
120
|
+
|
|
121
|
+
def command(self, opening: str | None = None) -> list[str]:
|
|
122
|
+
"""Build the ``vibe`` command line for this run.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
opening: The opening prompt to start the session on, or ``None``.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The argv list to execute.
|
|
129
|
+
"""
|
|
130
|
+
argv = [BINARY, *self._provider_flags()]
|
|
131
|
+
if opening is not None:
|
|
132
|
+
argv.append(opening)
|
|
133
|
+
return argv
|
|
134
|
+
|
|
135
|
+
def start_client(self) -> int:
|
|
136
|
+
"""Launch vibe, blocking until it exits.
|
|
137
|
+
|
|
138
|
+
Injected context is written to a temporary file the agent is told to read
|
|
139
|
+
first, removed when the client exits.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
The client's exit code.
|
|
143
|
+
"""
|
|
144
|
+
if self.run.context is not None and not self.run.resume:
|
|
145
|
+
path = context_file.write(self.run.job, self.run.session_id, self.run.context)
|
|
146
|
+
return self._run(self.command(read_first_opening(str(path), self.run.kickoff)))
|
|
147
|
+
return self._run(self.command(self.run.kickoff))
|
|
148
|
+
|
|
149
|
+
def _extra_env(self) -> dict[str, str]:
|
|
150
|
+
"""Point vibe at the throwaway config home when the relay is running."""
|
|
151
|
+
return {} if self._vibe_home is None else {"VIBE_HOME": self._vibe_home}
|
|
152
|
+
|
|
153
|
+
def _run(self, argv: list[str]) -> int:
|
|
154
|
+
env = {
|
|
155
|
+
**os.environ,
|
|
156
|
+
**dict(self.run.env),
|
|
157
|
+
**self._extra_env(),
|
|
158
|
+
"GMLW_JOB": self.run.job,
|
|
159
|
+
"GMLW_SESSION": self.run.session_id,
|
|
160
|
+
"GMLW_CLIENT": self.run.client,
|
|
161
|
+
}
|
|
162
|
+
# Trusted argv from our resolved run; no shell. The program is PATH-resolved (BINARY).
|
|
163
|
+
completed = subprocess.run(argv, check=False, cwd=self.run.cwd, env=env) # noqa: S603
|
|
164
|
+
return completed.returncode
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _vibe_metered(method: str, path: str) -> bool:
|
|
168
|
+
"""A metered vibe turn: ``POST`` to the Chat Completions endpoint."""
|
|
169
|
+
return method == "POST" and path.split("?", 1)[0].endswith("/chat/completions")
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Bootstrap an isolated vibe config that routes the active model through a relay.
|
|
4
|
+
|
|
5
|
+
The metering gateway copies the user's real ``~/.vibe/config.toml`` into a throwaway
|
|
6
|
+
``VIBE_HOME`` and repoints one thing: the ``api_base`` of the provider its active
|
|
7
|
+
model uses. Everything else — model definitions, prices, ``api_key_env_var``, tool
|
|
8
|
+
permissions — is preserved, so the metered run behaves like the real one but its
|
|
9
|
+
traffic detours through the local relay. The API key resolves from the OS keyring,
|
|
10
|
+
which is independent of ``VIBE_HOME``, so no credential is copied.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import tomllib
|
|
16
|
+
from typing import cast
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def active_upstream(source_text: str) -> str | None:
|
|
21
|
+
"""Return the ``api_base`` the config's active model talks to, or ``None``.
|
|
22
|
+
|
|
23
|
+
Resolves ``active_model`` to its ``[[models]]`` entry (matched by ``name`` or
|
|
24
|
+
``alias``), then that model's ``[[providers]]`` entry, and returns its
|
|
25
|
+
``api_base`` (e.g. ``https://api.mistral.ai/v1``).
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
source_text: The contents of a vibe ``config.toml``.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The active provider's ``api_base``, or ``None`` if it cannot be resolved.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
data = tomllib.loads(source_text)
|
|
35
|
+
except tomllib.TOMLDecodeError:
|
|
36
|
+
return None
|
|
37
|
+
active = data.get("active_model")
|
|
38
|
+
if not isinstance(active, str):
|
|
39
|
+
return None
|
|
40
|
+
provider_name = _provider_of(data.get("models"), active)
|
|
41
|
+
if provider_name is None:
|
|
42
|
+
return None
|
|
43
|
+
return _api_base_of(data.get("providers"), provider_name)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def redirect(source_text: str, upstream: str, relay_base_url: str) -> str:
|
|
47
|
+
"""Repoint ``upstream``'s host at the relay, keeping its path.
|
|
48
|
+
|
|
49
|
+
``https://api.mistral.ai/v1`` becomes ``http://127.0.0.1:PORT/v1`` — vibe then
|
|
50
|
+
posts ``/v1/chat/completions`` to the relay, which forwards to the real host.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
source_text: The contents of a vibe ``config.toml``.
|
|
54
|
+
upstream: The ``api_base`` to repoint (from :func:`active_upstream`).
|
|
55
|
+
relay_base_url: The relay's base URL (``http://127.0.0.1:PORT``).
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The config text with ``upstream`` replaced by the relay-pointed base URL, changed
|
|
59
|
+
only inside the active provider's ``[[providers]]`` table -- so the same URL in a
|
|
60
|
+
comment or in another provider is left alone. Unchanged if the active provider
|
|
61
|
+
cannot be resolved.
|
|
62
|
+
"""
|
|
63
|
+
new_api_base = relay_base_url + urlsplit(upstream).path
|
|
64
|
+
provider = _active_provider_name(source_text)
|
|
65
|
+
if provider is None:
|
|
66
|
+
return source_text
|
|
67
|
+
return _repoint_provider(source_text, provider, f'"{upstream}"', f'"{new_api_base}"')
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _active_provider_name(source_text: str) -> str | None:
|
|
71
|
+
try:
|
|
72
|
+
data = tomllib.loads(source_text)
|
|
73
|
+
except tomllib.TOMLDecodeError:
|
|
74
|
+
return None
|
|
75
|
+
active = data.get("active_model")
|
|
76
|
+
if not isinstance(active, str):
|
|
77
|
+
return None
|
|
78
|
+
return _provider_of(data.get("models"), active)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _repoint_provider(source_text: str, provider_name: str, old: str, new: str) -> str:
|
|
82
|
+
lines = source_text.splitlines(keepends=True)
|
|
83
|
+
index = 0
|
|
84
|
+
while index < len(lines):
|
|
85
|
+
if lines[index].strip() != "[[providers]]":
|
|
86
|
+
index += 1
|
|
87
|
+
continue
|
|
88
|
+
end = index + 1
|
|
89
|
+
name: str | None = None
|
|
90
|
+
api_line: int | None = None
|
|
91
|
+
while end < len(lines) and not lines[end].lstrip().startswith("["):
|
|
92
|
+
stripped = lines[end].strip()
|
|
93
|
+
if stripped.startswith("name") and "=" in stripped:
|
|
94
|
+
name = stripped.split("=", 1)[1].strip().strip('"')
|
|
95
|
+
elif stripped.startswith("api_base") and old in lines[end]:
|
|
96
|
+
api_line = end
|
|
97
|
+
end += 1
|
|
98
|
+
if name == provider_name and api_line is not None:
|
|
99
|
+
lines[api_line] = lines[api_line].replace(old, new, 1)
|
|
100
|
+
break
|
|
101
|
+
index = end
|
|
102
|
+
return "".join(lines)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _provider_of(models: object, active: str) -> str | None:
|
|
106
|
+
if not isinstance(models, list):
|
|
107
|
+
return None
|
|
108
|
+
for entry in cast("list[object]", models):
|
|
109
|
+
if not isinstance(entry, dict):
|
|
110
|
+
continue
|
|
111
|
+
model = cast("dict[str, object]", entry)
|
|
112
|
+
if model.get("name") == active or model.get("alias") == active:
|
|
113
|
+
provider = model.get("provider")
|
|
114
|
+
return provider if isinstance(provider, str) else None
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _api_base_of(providers: object, name: str) -> str | None:
|
|
119
|
+
if not isinstance(providers, list):
|
|
120
|
+
return None
|
|
121
|
+
for entry in cast("list[object]", providers):
|
|
122
|
+
if not isinstance(entry, dict):
|
|
123
|
+
continue
|
|
124
|
+
provider = cast("dict[str, object]", entry)
|
|
125
|
+
if provider.get("name") == name:
|
|
126
|
+
api_base = provider.get("api_base")
|
|
127
|
+
return api_base if isinstance(api_base, str) else None
|
|
128
|
+
return None
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Daniel Slobozian
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Filesystem ``CredentialsStorePort``: a ``0600`` ``credentials.toml`` we own."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
import tomllib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
from generic_ml_wrapper.application.port.outbound.credentials_store import CredentialsStorePort
|
|
14
|
+
|
|
15
|
+
_OWNER_READ_WRITE = 0o600
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CredentialsUnreadableError(Exception):
|
|
19
|
+
"""The credentials file exists but cannot be parsed as TOML.
|
|
20
|
+
|
|
21
|
+
Overwriting it would destroy every stored secret, so the run aborts instead.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, path: Path) -> None:
|
|
25
|
+
"""Build the error with actionable guidance for ``path``."""
|
|
26
|
+
self.path = path
|
|
27
|
+
super().__init__(
|
|
28
|
+
f"{path} is not valid TOML.\n\n"
|
|
29
|
+
"It holds your stored credentials. Rewriting it would parse the corruption as\n"
|
|
30
|
+
"'no secrets' and destroy them all, so the run is aborted.\n\n"
|
|
31
|
+
"To fix: repair the TOML by hand, or move it aside\n"
|
|
32
|
+
f" (mv {path} {path}.bak) and re-run `gmlw creds set` to start fresh."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FilesystemCredentialsStore(CredentialsStorePort):
|
|
37
|
+
"""Persist per-workflow credentials as ``[workflow]`` tables in one TOML file.
|
|
38
|
+
|
|
39
|
+
The file is written with owner-only permissions and is never committed; it lives
|
|
40
|
+
under ``~/.gmlw`` alongside the rest of the runtime state.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, path: Path) -> None:
|
|
44
|
+
"""Bind the store to its file.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
path: The ``credentials.toml`` file this store reads and writes.
|
|
48
|
+
"""
|
|
49
|
+
self._path = path
|
|
50
|
+
|
|
51
|
+
def resolve(self, workflow: str) -> dict[str, str]:
|
|
52
|
+
"""Return a workflow's credentials as an env-var-name to value mapping."""
|
|
53
|
+
table = self._load().get(workflow)
|
|
54
|
+
if not isinstance(table, dict):
|
|
55
|
+
return {}
|
|
56
|
+
return {
|
|
57
|
+
name: value
|
|
58
|
+
for name, value in cast("dict[str, object]", table).items()
|
|
59
|
+
if isinstance(value, str)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
def set(self, workflow: str, name: str, value: str) -> None:
|
|
63
|
+
"""Store one credential for a workflow, replacing any prior value.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
CredentialsUnreadableError: If the file exists but is corrupt; it is left
|
|
67
|
+
untouched rather than overwritten (which would destroy every secret).
|
|
68
|
+
"""
|
|
69
|
+
data = self._load()
|
|
70
|
+
table = data.get(workflow)
|
|
71
|
+
entries = cast("dict[str, object]", table) if isinstance(table, dict) else {}
|
|
72
|
+
entries[name] = value
|
|
73
|
+
data[workflow] = entries
|
|
74
|
+
self._write(_dump(data))
|
|
75
|
+
|
|
76
|
+
def _write(self, text: str) -> None:
|
|
77
|
+
# Atomic + owner-only from creation: mkstemp makes the temp 0600, so after the
|
|
78
|
+
# replace the secrets file is 0600 with no world-readable window, and a crash
|
|
79
|
+
# mid-write can't corrupt it. replace swaps the path itself, so it never writes
|
|
80
|
+
# THROUGH a symlink the file might have been redirected to.
|
|
81
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
descriptor, temporary = tempfile.mkstemp(dir=self._path.parent, suffix=".tmp")
|
|
83
|
+
temporary_path = Path(temporary)
|
|
84
|
+
try:
|
|
85
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
86
|
+
handle.write(text)
|
|
87
|
+
temporary_path.replace(self._path)
|
|
88
|
+
except BaseException:
|
|
89
|
+
temporary_path.unlink()
|
|
90
|
+
raise
|
|
91
|
+
self._path.chmod(_OWNER_READ_WRITE)
|
|
92
|
+
|
|
93
|
+
def _load(self) -> dict[str, object]:
|
|
94
|
+
if not self._path.exists():
|
|
95
|
+
return {}
|
|
96
|
+
try:
|
|
97
|
+
with self._path.open("rb") as handle:
|
|
98
|
+
return tomllib.load(handle)
|
|
99
|
+
except (OSError, tomllib.TOMLDecodeError) as error:
|
|
100
|
+
raise CredentialsUnreadableError(self._path) from error
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _dump(data: dict[str, object]) -> str:
|
|
104
|
+
"""Serialize ``{workflow: {name: value}}`` as TOML tables of basic strings."""
|
|
105
|
+
blocks: list[str] = []
|
|
106
|
+
for workflow in sorted(data):
|
|
107
|
+
table = data[workflow]
|
|
108
|
+
if not isinstance(table, dict):
|
|
109
|
+
continue
|
|
110
|
+
entries = cast("dict[str, object]", table)
|
|
111
|
+
lines = [f"[{workflow}]"]
|
|
112
|
+
lines += [
|
|
113
|
+
f"{name} = {_basic_string(value)}"
|
|
114
|
+
for name, value in sorted(entries.items())
|
|
115
|
+
if isinstance(value, str)
|
|
116
|
+
]
|
|
117
|
+
if len(lines) > 1:
|
|
118
|
+
blocks.append("\n".join(lines))
|
|
119
|
+
return "\n\n".join(blocks) + "\n" if blocks else ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _basic_string(value: str) -> str:
|
|
123
|
+
escaped = (
|
|
124
|
+
value.replace("\\", "\\\\")
|
|
125
|
+
.replace('"', '\\"')
|
|
126
|
+
.replace("\n", "\\n")
|
|
127
|
+
.replace("\t", "\\t")
|
|
128
|
+
.replace("\r", "\\r")
|
|
129
|
+
)
|
|
130
|
+
return f'"{escaped}"'
|