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,402 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """A local metering relay: forward a client's API traffic and record per-turn usage.
4
+
5
+ The relay listens on ``127.0.0.1``; the caller points the client at it via the
6
+ client's official base-URL override (Claude: ``ANTHROPIC_BASE_URL``). Each request
7
+ is forwarded verbatim to the upstream API and the response is streamed straight
8
+ back to the client, so the interactive experience is unchanged. While streaming,
9
+ the relay tees the response to read the turn's token usage and record it.
10
+
11
+ The upstream forwarder is injectable so the relay is testable against a fake
12
+ upstream; the default talks HTTPS to the real API. When a transcript port is
13
+ injected, each metered turn's request, response, and usage are handed to it, so the
14
+ run is recorded per call.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import http.client
20
+ import itertools
21
+ import secrets
22
+ import threading
23
+ import time
24
+ from dataclasses import dataclass
25
+ from http import HTTPStatus
26
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
27
+ from typing import TYPE_CHECKING, cast
28
+ from urllib.parse import urlsplit
29
+
30
+ from generic_ml_wrapper.adapter.outbound.gateway.anthropic_sse import read_usage as _anthropic_usage
31
+ from generic_ml_wrapper.application.domain.model.turn_usage import TurnUsage
32
+ from generic_ml_wrapper.application.domain.service.interceptor_chain import InterceptorChain
33
+ from generic_ml_wrapper.application.port.outbound.transcript import TranscriptCall
34
+ from generic_ml_wrapper.common.log import log
35
+
36
+ if TYPE_CHECKING:
37
+ from collections.abc import Callable, Iterable, Mapping
38
+
39
+ from generic_ml_wrapper.adapter.outbound.gateway.anthropic_sse import StreamUsage
40
+ from generic_ml_wrapper.application.port.outbound.per_turn_metering import PerTurnMeteringPort
41
+ from generic_ml_wrapper.application.port.outbound.transcript import TranscriptPort
42
+
43
+ UsageReader = Callable[[str], "StreamUsage | None"]
44
+ MeteredPredicate = Callable[[str, str], bool]
45
+ PathMap = Callable[[str], str]
46
+
47
+ _ANTHROPIC = "https://api.anthropic.com"
48
+
49
+
50
+ def _claude_metered(method: str, path: str) -> bool:
51
+ """The default metered-turn predicate: a Claude ``POST /v1/messages``."""
52
+ return method == "POST" and path.split("?", 1)[0].endswith("/v1/messages")
53
+
54
+
55
+ # Headers that describe one hop's connection, not the payload; never forwarded.
56
+ _HOP_BY_HOP = frozenset(
57
+ {
58
+ "host",
59
+ "content-length",
60
+ "connection",
61
+ "keep-alive",
62
+ "transfer-encoding",
63
+ "te",
64
+ "trailers",
65
+ "upgrade",
66
+ "proxy-authorization",
67
+ }
68
+ )
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class UpstreamResponse:
73
+ """An upstream response: a status, headers, and a streaming body."""
74
+
75
+ status: int
76
+ headers: list[tuple[str, str]]
77
+ body: Iterable[bytes]
78
+
79
+
80
+ class MeteringRelay:
81
+ """Forward a client's traffic to an upstream API and record each turn's usage."""
82
+
83
+ def __init__( # noqa: PLR0913 (per-client relay knobs, all keyword-only)
84
+ self,
85
+ *,
86
+ job: str,
87
+ session: str,
88
+ metering: PerTurnMeteringPort,
89
+ client: str = "claude",
90
+ transcript: TranscriptPort | None = None,
91
+ forwarder: Callable[[str, str, Mapping[str, str], bytes], UpstreamResponse] | None = None,
92
+ upstream_base: str = _ANTHROPIC,
93
+ path_map: PathMap | None = None,
94
+ usage_reader: UsageReader | None = None,
95
+ is_metered: MeteredPredicate | None = None,
96
+ interceptors: InterceptorChain | None = None,
97
+ clock: Callable[[], float] = time.time,
98
+ ) -> None:
99
+ """Bind the relay to a run and its metering store.
100
+
101
+ Args:
102
+ job: The job the turns belong to.
103
+ session: The session the turns belong to.
104
+ metering: Where per-turn usage is recorded.
105
+ client: The client name, placed in the capability URL for observability.
106
+ transcript: Where each call's request/response/usage is recorded, or ``None``.
107
+ forwarder: The upstream forwarder; defaults to real HTTPS to ``upstream_base``.
108
+ upstream_base: The upstream API base URL for the default forwarder.
109
+ path_map: Maps the incoming request path to the upstream path for the
110
+ default forwarder (e.g. Codex ``/v1/x`` -> ``/backend-api/codex/x``);
111
+ identity when ``None``.
112
+ usage_reader: Reads a turn's usage from a response body; defaults to the
113
+ Anthropic reader. A per-client reader plugs in a different wire shape.
114
+ is_metered: Predicate ``(method, path) -> bool`` selecting the turn
115
+ endpoint; defaults to Claude's ``POST /v1/messages``.
116
+ interceptors: The interceptor chain applied to the wire — ``request`` to
117
+ the outbound body, ``response`` to the captured reply; empty when ``None``.
118
+ clock: Returns the current time (epoch seconds); injectable for tests.
119
+ Used to stamp each turn's timestamp and duration.
120
+ """
121
+ self._job = job
122
+ self._session = session
123
+ self._metering = metering
124
+ self._transcript = transcript
125
+ self._client = client
126
+ # A per-run capability token: the client is pointed at
127
+ # http://127.0.0.1:<port>/<client>/<token>, so only a caller that was handed
128
+ # this base URL (our client) can drive the relay. This is the sole auth guard;
129
+ # the client segment is for observability + a routing sanity check.
130
+ self._token = secrets.token_urlsafe(16)
131
+ self._forward = forwarder or _https_forwarder(upstream_base, path_map)
132
+ self._read_usage = usage_reader or _anthropic_usage
133
+ self._metered = is_metered or _claude_metered
134
+ self._interceptors = interceptors or InterceptorChain(())
135
+ self._clock = clock
136
+ self._server: ThreadingHTTPServer | None = None
137
+ # A thread-safe capture counter: the server is threaded, so two concurrent turns
138
+ # must not read-modify-write a shared int (which could collide on a filename).
139
+ self._turns = itertools.count(1)
140
+
141
+ def start(self) -> None:
142
+ """Bind an ephemeral local port and serve in a background thread."""
143
+ self._server = _RelayServer(("127.0.0.1", 0), _Handler, self)
144
+ threading.Thread(target=self._server.serve_forever, daemon=True).start()
145
+
146
+ def stop(self) -> None:
147
+ """Stop serving and release the port."""
148
+ if self._server is not None:
149
+ self._server.shutdown()
150
+ self._server.server_close()
151
+ self._server = None
152
+
153
+ @property
154
+ def port(self) -> int:
155
+ """The bound port (only valid after ``start``)."""
156
+ if self._server is None:
157
+ message = "relay is not started"
158
+ raise RuntimeError(message)
159
+ return self._server.server_address[1]
160
+
161
+ @property
162
+ def base_url(self) -> str:
163
+ """The capability base URL to point the client at (only valid after ``start``).
164
+
165
+ Carries the run's client name (observability) and the per-run token (auth):
166
+ ``http://127.0.0.1:<port>/<client>/<token>``. The client appends its API path
167
+ (e.g. ``/v1/messages``) to this, so requests arrive already authenticated.
168
+ """
169
+ return f"http://127.0.0.1:{self.port}/{self._client}/{self._token}"
170
+
171
+ def authorize(self, path: str) -> str | None:
172
+ """Validate a request path's ``/<client>/<token>`` prefix and strip it.
173
+
174
+ Args:
175
+ path: The incoming request path.
176
+
177
+ Returns:
178
+ The upstream path (prefix removed), or ``None`` if the client segment or
179
+ token does not match this run -- in which case the request is refused.
180
+ """
181
+ prefix = f"/{self._client}/{self._token}"
182
+ if path == prefix:
183
+ return "/"
184
+ if path.startswith(prefix + "/"):
185
+ return path[len(prefix) :]
186
+ return None
187
+
188
+ def forward(
189
+ self, method: str, path: str, headers: Mapping[str, str], body: bytes
190
+ ) -> UpstreamResponse:
191
+ """Forward one request upstream."""
192
+ return self._forward(method, path, headers, body)
193
+
194
+ def now(self) -> float:
195
+ """The current time, from the injected clock."""
196
+ return self._clock()
197
+
198
+ def is_metered(self, method: str, path: str) -> bool:
199
+ """Whether a request to ``path`` is a metered turn for this client."""
200
+ return self._metered(method, path)
201
+
202
+ def wants_body(self, method: str, path: str) -> bool:
203
+ """Whether the full response body must be captured this request.
204
+
205
+ True only when something consumes it -- a metered turn, a ``response``
206
+ interceptor, or raw capture. When False the relay streams the response
207
+ straight through without buffering it in memory.
208
+ """
209
+ return self._metered(method, path) or self._interceptors.has("response")
210
+
211
+ def intercept_request(self, body: bytes) -> bytes:
212
+ """Apply ``request`` interceptors to the outbound body (identity if none).
213
+
214
+ The body is left byte-for-byte untouched when no ``request`` interceptor is
215
+ configured, so the common path never re-encodes.
216
+
217
+ Args:
218
+ body: The raw request body from the client.
219
+
220
+ Returns:
221
+ The (possibly transformed) body to forward upstream.
222
+ """
223
+ if not self._interceptors.has("request"):
224
+ return body
225
+ transformed = self._interceptors.apply("request", body.decode("utf-8", "replace"))
226
+ return transformed.encode("utf-8")
227
+
228
+ def intercept_response(self, captured: bytes) -> None:
229
+ """Run ``response`` interceptors over the captured body (observe only).
230
+
231
+ The response has already streamed to the client, so this is for logging or
232
+ redacting-for-logs; the returned text is intentionally discarded.
233
+
234
+ Args:
235
+ captured: The full captured response body.
236
+ """
237
+ if self._interceptors.has("response"):
238
+ self._interceptors.apply("response", captured.decode("utf-8", "replace"))
239
+
240
+ def record(self, request: bytes, captured: bytes, started_at: float) -> None:
241
+ """Record the turn's usage (if any) and its transcript (if configured).
242
+
243
+ Args:
244
+ request: The request body forwarded upstream.
245
+ captured: The full response body.
246
+ started_at: The turn's start time (from :meth:`now`), for timestamp/duration.
247
+ """
248
+ usage = self._read_usage(captured.decode("utf-8", "replace"))
249
+ turn: TurnUsage | None = None
250
+ if usage is not None:
251
+ turn = TurnUsage(
252
+ self._session,
253
+ usage.input_tokens,
254
+ usage.output_tokens,
255
+ None,
256
+ usage.model,
257
+ cache_creation_tokens=usage.cache_creation_tokens,
258
+ cache_read_tokens=usage.cache_read_tokens,
259
+ timestamp=started_at,
260
+ duration_s=round(self._clock() - started_at, 3),
261
+ turn_id=usage.turn_id,
262
+ )
263
+ self._metering.record(self._job, turn)
264
+ if self._transcript is not None:
265
+ self._transcript.record(
266
+ TranscriptCall(self._job, self._session, next(self._turns), request, captured, turn)
267
+ )
268
+
269
+
270
+ class _RelayServer(ThreadingHTTPServer):
271
+ def __init__(
272
+ self,
273
+ address: tuple[str, int],
274
+ handler: type[BaseHTTPRequestHandler],
275
+ relay: MeteringRelay,
276
+ ) -> None:
277
+ super().__init__(address, handler)
278
+ self.relay = relay
279
+
280
+
281
+ class _Handler(BaseHTTPRequestHandler):
282
+ def do_GET(self) -> None:
283
+ self._proxy()
284
+
285
+ def do_POST(self) -> None:
286
+ self._proxy()
287
+
288
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002 (stdlib signature)
289
+ log.debug("gateway " + (format % args if args else format))
290
+
291
+ def _proxy(self) -> None:
292
+ relay = cast("_RelayServer", self.server).relay
293
+ # A browser cross-origin POST carries Origin; a real client SDK does not. And
294
+ # any request whose /<client>/<token>/ prefix doesn't match this run is refused,
295
+ # so a stray local process can't drive the relay.
296
+ if self.headers.get("Origin") is not None:
297
+ self.send_error(HTTPStatus.FORBIDDEN)
298
+ return
299
+ upstream_path = relay.authorize(self.path)
300
+ if upstream_path is None:
301
+ self.send_error(HTTPStatus.NOT_FOUND)
302
+ return
303
+ length = int(self.headers.get("Content-Length") or 0)
304
+ body = relay.intercept_request(self.rfile.read(length) if length else b"")
305
+ # Force an uncompressed upstream response so usage can be read off it; the
306
+ # client accepts identity fine (only the localhost hop loses compression).
307
+ request_headers = {
308
+ name: value for name, value in self.headers.items() if name.lower() != "accept-encoding"
309
+ }
310
+ request_headers["Accept-Encoding"] = "identity"
311
+ started_at = relay.now()
312
+ response = relay.forward(self.command, upstream_path, request_headers, body)
313
+
314
+ self.close_connection = True # signal end-of-stream by closing (no content-length)
315
+ try:
316
+ self.send_response(response.status)
317
+ for name, value in response.headers:
318
+ if name.lower() not in _HOP_BY_HOP:
319
+ self.send_header(name, value)
320
+ self.send_header("Connection", "close")
321
+ self.end_headers()
322
+ except OSError:
323
+ pass # client already gone; still drain upstream below to record usage
324
+
325
+ if relay.wants_body(self.command, upstream_path):
326
+ captured = _tee(response.body, self._write_chunk)
327
+ relay.intercept_response(captured)
328
+ if relay.is_metered(self.command, upstream_path):
329
+ relay.record(body, captured, started_at)
330
+ else:
331
+ _stream(response.body, self._write_chunk)
332
+
333
+ def _write_chunk(self, chunk: bytes) -> None:
334
+ self.wfile.write(chunk)
335
+ self.wfile.flush()
336
+
337
+
338
+ def _stream(chunks: Iterable[bytes], sink: Callable[[bytes], None]) -> None:
339
+ """Stream ``chunks`` to the client without buffering (nothing consumes the body)."""
340
+ for chunk in chunks:
341
+ try:
342
+ sink(chunk)
343
+ except OSError:
344
+ return # client hung up; no body to record or capture, so stop
345
+
346
+
347
+ def _tee(chunks: Iterable[bytes], sink: Callable[[bytes], None]) -> bytes:
348
+ """Stream ``chunks`` to ``sink`` while capturing them all for usage extraction.
349
+
350
+ If the client hangs up mid-stream (e.g. it exits or cancels), writes raise and
351
+ are swallowed, but the upstream is still drained fully so the turn's usage —
352
+ which arrives at the end — is captured.
353
+
354
+ Args:
355
+ chunks: The upstream response body chunks.
356
+ sink: Writes a chunk to the client (may raise ``OSError`` once it hangs up).
357
+
358
+ Returns:
359
+ The full captured body.
360
+ """
361
+ captured = bytearray()
362
+ client_gone = False
363
+ for chunk in chunks:
364
+ captured.extend(chunk)
365
+ if not client_gone:
366
+ try:
367
+ sink(chunk)
368
+ except OSError:
369
+ client_gone = True
370
+ return bytes(captured)
371
+
372
+
373
+ def _https_forwarder(
374
+ base: str, path_map: PathMap | None = None
375
+ ) -> Callable[[str, str, Mapping[str, str], bytes], UpstreamResponse]:
376
+ parsed = urlsplit(base)
377
+ host = parsed.hostname or ""
378
+ port = parsed.port or 443
379
+
380
+ def forward(
381
+ method: str, path: str, headers: Mapping[str, str], body: bytes
382
+ ) -> UpstreamResponse: # pragma: no cover (real network; verified by a live round-trip)
383
+ connection = http.client.HTTPSConnection(host, port, timeout=600)
384
+ sent = {name: value for name, value in headers.items() if name.lower() not in _HOP_BY_HOP}
385
+ sent["Host"] = host
386
+ up_path = path_map(path) if path_map is not None else path
387
+ connection.request(method, up_path, body=body or None, headers=sent)
388
+ upstream = connection.getresponse()
389
+
390
+ def stream() -> Iterable[bytes]:
391
+ try:
392
+ while True:
393
+ chunk = upstream.read(8192)
394
+ if not chunk:
395
+ break
396
+ yield chunk
397
+ finally:
398
+ connection.close()
399
+
400
+ return UpstreamResponse(upstream.status, list(upstream.getheaders()), stream())
401
+
402
+ return forward
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Built-in interceptors that plug into the ``[[interceptors]]`` chain."""
@@ -0,0 +1,107 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """A context compressor interceptor that compresses through generic-ml-cache."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, cast
9
+
10
+ from generic_ml_cache_bootstrap.application import build_application_api
11
+ from generic_ml_cache_core.application.domain.model.execution.artifact import ArtifactType
12
+ from generic_ml_cache_core.application.domain.model.execution.execution_kind import ExecutionKind
13
+ from generic_ml_cache_core.application.domain.model.run.cache_mode import CacheMode
14
+ from generic_ml_cache_core.application.port.inbound.run_ml_execution import (
15
+ run_ml_execution_command as _run_cmd,
16
+ )
17
+ from generic_ml_cache_core.application.usecase.select_adapter_for_execution_service import (
18
+ SelectAdapterForExecutionService,
19
+ )
20
+
21
+ from generic_ml_wrapper.application.port.outbound.interceptor import InterceptorPort
22
+ from generic_ml_wrapper.common import config, paths
23
+ from generic_ml_wrapper.common.log import log
24
+
25
+ if TYPE_CHECKING:
26
+ from generic_ml_cache_core.application.domain.model.execution.ml_execution import MlExecution
27
+ from generic_ml_cache_core.application.port.outbound.adapter_catalog_port import (
28
+ AdapterCatalogPort,
29
+ )
30
+ from generic_ml_cache_core.application.port.outbound.adapter_resolver_port import (
31
+ AdapterResolverPort,
32
+ )
33
+ from generic_ml_cache_core.application.port.outbound.registered_adapter_port import (
34
+ RegisteredAdapterPort,
35
+ )
36
+
37
+
38
+ class CompressorInterceptor(InterceptorPort):
39
+ """Compress a context section through the generic-ml-cache record/replay cache.
40
+
41
+ Runs the configured model (default gpt-5.4 at low effort via the cursor adapter)
42
+ with the user's compression prompt, keyed and cached by content — so compressing
43
+ the same context replays for free and returns an identical result. Off until
44
+ ``[compress] prompt`` names a prompt file; non-destructive — any failure leaves
45
+ the text uncompressed.
46
+ """
47
+
48
+ def intercept(self, text: str, target: str) -> str: # noqa: ARG002 (target-agnostic)
49
+ """Return the compressed text, or the input unchanged when off or on failure.
50
+
51
+ Args:
52
+ text: The section text to compress.
53
+ target: The target it is running for (unused; a compressor is target-agnostic).
54
+
55
+ Returns:
56
+ The compressed text, or ``text`` unchanged.
57
+ """
58
+ settings = config.compress()
59
+ if settings.prompt is None:
60
+ return text # compression off until [compress] prompt is set
61
+ try:
62
+ prompt = Path(settings.prompt).read_text(encoding="utf-8")
63
+ except OSError as error:
64
+ log.warning(f"cannot read compress prompt {settings.prompt!r} ({error}); skipping")
65
+ return text
66
+ try:
67
+ execution = self._compress(text, prompt, settings)
68
+ except Exception as error: # noqa: BLE001 (a compile must never die on the cache/LLM)
69
+ log.warning(f"compression failed ({error}); leaving context uncompressed")
70
+ return text
71
+ return _stdout(execution) or text
72
+
73
+ def _compress( # pragma: no cover (real cache/LLM round-trip; verified by a live run)
74
+ self, context: str, prompt: str, settings: config.CompressSettings
75
+ ) -> MlExecution:
76
+ command = _run_cmd.RunMlExecutionCommand(
77
+ execution_kind=ExecutionKind.LOCAL_MANAGED,
78
+ client=settings.adapter,
79
+ model=settings.model,
80
+ effort=settings.effort,
81
+ context=context,
82
+ prompt=prompt,
83
+ cache_mode=CacheMode.CACHE,
84
+ )
85
+
86
+ def runners(
87
+ catalog: AdapterCatalogPort, resolver: AdapterResolverPort
88
+ ) -> dict[str, RegisteredAdapterPort]:
89
+ descriptor = SelectAdapterForExecutionService(catalog).select(
90
+ settings.adapter, ExecutionKind.LOCAL_MANAGED
91
+ )
92
+ client = resolver.resolve_local_client(descriptor.adapter_id)
93
+ return {settings.adapter: cast("RegisteredAdapterPort", client)}
94
+
95
+ paths.COMPRESS_CACHE.mkdir(parents=True, exist_ok=True)
96
+ api = build_application_api(paths.COMPRESS_CACHE, runners)
97
+ return api.run_ml.execute(command)
98
+
99
+
100
+ def _stdout(execution: MlExecution) -> str | None:
101
+ """Return the STDOUT artifact's text, or ``None`` on failure or if absent."""
102
+ if execution.failure is not None:
103
+ return None
104
+ for artifact in execution.artifacts:
105
+ if artifact.artifact_type is ArtifactType.STDOUT and artifact.content is not None:
106
+ return artifact.content.decode(artifact.encoding or "utf-8")
107
+ return None
@@ -0,0 +1,32 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """A reference interceptor that logs each message's size and returns it unchanged."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from generic_ml_wrapper.application.port.outbound.interceptor import InterceptorPort
8
+ from generic_ml_wrapper.common.log import log
9
+
10
+
11
+ class MessageSizeLogger(InterceptorPort):
12
+ """Log the size of every intercepted message; a non-transforming observer.
13
+
14
+ A reference interceptor and the simplest example of the plugin contract: it logs
15
+ how many characters flow through each target it is bound to — e.g. ``request`` for
16
+ outbound prompts, ``response`` for the replies — and returns the text untouched.
17
+ Configure it under ``[[interceptors]]`` on the targets you want traced; put it on
18
+ both ``request`` and ``response`` to log sizes in and out.
19
+ """
20
+
21
+ def intercept(self, text: str, target: str) -> str:
22
+ """Log the message size for ``target`` and return the text unchanged.
23
+
24
+ Args:
25
+ text: The intercepted message.
26
+ target: The target it is running for (e.g. ``request``, ``response``).
27
+
28
+ Returns:
29
+ The input text, unchanged.
30
+ """
31
+ log.info(f"[interceptor] {target}: {len(text)} chars")
32
+ return text
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Client status-payload parsing adapters."""
@@ -0,0 +1,76 @@
1
+ # SPDX-FileCopyrightText: 2026 Daniel Slobozian
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Parse Claude Code's status-line payload into a ``ClientStatus``."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import cast
8
+
9
+ from generic_ml_wrapper.application.domain.model.client_status import ClientStatus
10
+ from generic_ml_wrapper.application.port.outbound.client_status import ClientStatusParserPort
11
+
12
+
13
+ def _dig(payload: object, *keys: str) -> object:
14
+ current: object = payload
15
+ for key in keys:
16
+ if not isinstance(current, dict):
17
+ return None
18
+ current = cast("dict[str, object]", current).get(key)
19
+ return current
20
+
21
+
22
+ class ClaudeStatusParser(ClientStatusParserPort):
23
+ """Read model, context fill, and session cost from Claude Code's payload."""
24
+
25
+ def parse(self, payload: dict[str, object]) -> ClientStatus:
26
+ """Parse Claude Code's status payload.
27
+
28
+ Args:
29
+ payload: The decoded JSON Claude Code pipes to the status-line command
30
+ (``model.display_name``, ``context_window.used_percentage``,
31
+ ``cost.total_cost_usd``, and ``rate_limits`` buckets).
32
+
33
+ Returns:
34
+ The parsed status.
35
+ """
36
+ return ClientStatus(
37
+ model=_as_str(_dig(payload, "model", "display_name")),
38
+ context_pct=_as_pct(_dig(payload, "context_window", "used_percentage")),
39
+ session_cost_usd=_as_float(_dig(payload, "cost", "total_cost_usd")),
40
+ extras=_extras(payload),
41
+ )
42
+
43
+
44
+ # Claude reports two rolling allowance windows; each answers "am I burning it?".
45
+ _QUOTA_WINDOWS = (("5h", "five_hour"), ("wk", "seven_day"))
46
+
47
+
48
+ def _extras(payload: dict[str, object]) -> tuple[str, ...]:
49
+ """Claude's client-specific status blocks (currently its rate-limit quota)."""
50
+ quota = _quota(_dig(payload, "rate_limits"))
51
+ return (f"quota {quota}",) if quota else ()
52
+
53
+
54
+ def _quota(rate_limits: object) -> str | None:
55
+ parts: list[str] = []
56
+ for label, key in _QUOTA_WINDOWS:
57
+ pct = _as_pct(_dig(rate_limits, key, "used_percentage"))
58
+ if pct is not None:
59
+ parts.append(f"{label} {pct}%")
60
+ return " · ".join(parts) if parts else None
61
+
62
+
63
+ def _as_str(value: object) -> str | None:
64
+ return value if isinstance(value, str) and value else None
65
+
66
+
67
+ def _as_pct(value: object) -> int | None:
68
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
69
+ return None
70
+ return round(value)
71
+
72
+
73
+ def _as_float(value: object) -> float | None:
74
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
75
+ return None
76
+ return float(value)