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,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,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,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)
|