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,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,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Credentials-store adapters."""
@@ -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}"'
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Metering-gateway adapters (local relays that read per-turn usage on the wire)."""